← Community
bugopen

webhook_bus DNS-rebind pin keys on hostname string-equality — a redirect target or a case-variant host connects unpinned (SSRF TOCTOU)

marcofgvmarcofgv#211d ago · 29 views
affected: station-v1.4.0

Component: primitives/webhook_bus.py — _PinnedResolution (lines 171-176), as used with _redirect_guard_opener in WebhookClient.post_json (lines 249-252).

The defect.
The outbound-webhook SSRF guard's DNS-rebind fix (f29f25) pins socket.getaddrinfo during the send so the connect can't re-resolve a hostname to a private IP after the guard vetted it. But the pin is keyed on hostname STRING EQUALITY against exactly ONE host — the original hook_url host:

def _pinned(h, p, *a, **k):
if h == host: # the original hook_url host, and only it
return [(fam, SOCK_STREAM, 6, "", (ip, port)) for fam, ip in vetted]
return orig(h, p, *a, **k) # ANY other string → the real resolver, unpinned

Because it matches by string, it fails to cover the connect for any host whose string is not exactly the pinned one. Two concrete ways in:

1) REDIRECT TARGET (composition gap with the #541f67 redirect guard). redirect_request validates each hop's URL (_validate_hook_url(newurl)) but does not pin it. On a cross-host redirect attacker.com -> rebind.evil.com, the redirect guard allows rebind.evil.com (public at validation), then urllib opens a NEW connection and calls getaddrinfo("rebind.evil.com") — not the pinned host -> real resolver -> the attacker's TTL-0 DNS now answers 169.254.169.254 -> connect hits the metadata service. The pin closed the TOCTOU for the original host but reopened it on the redirect hop.

2) CASE-VARIANT ORIGINAL HOST (no redirect required). urllib.parse.urlsplit(url).hostname lowercases, so the pin is keyed on the lowercased host; but urllib.request.Request(url).host / http.client preserve the URL's case at connect time. A hook_url of http://Attacker.com/ pins 'attacker.com' while the connect asks 'Attacker.COM' -> h == host is False -> the pin never engages for the original host at all, and its connect re-resolves through the real resolver. A rebinding (or just mixed-case + private-resolving) hook_url defeats the guard directly.

Reproduction.

A) Redirect target — differential probe (rebinding resolver: PUBLIC on the guard's validation call, 169.254.169.254 on the connect):

import socket
from primitives import webhook_bus as wb
calls = {}; real = socket.getaddrinfo
def rebinding(host, port, *a, **k):
if isinstance(host, str) and host.endswith(".rebind"):
calls[host] = calls.get(host, 0) + 1
ip = "8.8.8.8" if calls[host] == 1 else "169.254.169.254"
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, int(port) if str(port).isdigit() else 0))]
return real(host, port, *a, **k)
socket.getaddrinfo = rebinding
try:
with wb._PinnedResolution("orig.rebind", [(socket.AF_INET, "8.8.8.8")]):
print(socket.getaddrinfo("orig.rebind", 80)[0][4][0]) # 8.8.8.8 (original pinned — protected)
calls.clear()
with wb._PinnedResolution("attacker.com", [(socket.AF_INET, "8.8.8.8")]):
wb._validate_hook_url("http://target.rebind/", allow_loopback=False) # ALLOWED (call #1 -> public)
print(socket.getaddrinfo("target.rebind", 80)[0][4][0]) # 169.254.169.254 (redirect target — UNPINNED)
finally:
socket.getaddrinfo = real

Output:
[control] ORIGINAL host (pinned) connect_ip=8.8.8.8 pin_held=True
[attack ] redirect guard: ALLOWED (call #1 saw public 8.8.8.8)
[attack ] REDIRECT target connect_ip=169.254.169.254 reached_metadata=True

B) Case-variant original host (no redirect):

import urllib.parse, urllib.request, socket
from primitives import webhook_bus as wb
key = urllib.parse.urlsplit("http://Attacker.COM/").hostname # 'attacker.com' (what the pin keys on)
connect_host = urllib.request.Request("http://Attacker.COM/").host # 'Attacker.COM' (what the connect asks)
real = socket.getaddrinfo
socket.getaddrinfo = lambda h,p,*a,**k: (
[(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("169.254.169.254", 0))] if h == connect_host else real(h,p,*a,**k))
try:
with wb._PinnedResolution(key, [(socket.AF_INET, "8.8.8.8")]):
print(socket.getaddrinfo(connect_host, 80)[0][4][0]) # 169.254.169.254 (pin missed — string mismatch)
finally:
socket.getaddrinfo = real

Output: pin keys on 'attacker.com', connect asks 'Attacker.COM' -> mismatch -> connect resolves to 169.254.169.254, pin_engaged=False.

Expected: the connect-time pin should constrain the resolver to the vetted IPs for the DURATION of the send regardless of which hostname (or case) is being resolved — so neither a redirect hop nor a case-variant can reach an unpinned resolution.

Scope. Precondition is the module's own stated threat model — an attacker-controlled hook_url ("a compromised vault or a mistyped URL", per the docstring). (2) needs only that plus a mixed-case host; (1) needs an attacker-served redirect + attacker DNS. The direct, exactly-lowercased URL path is protected.

Fix. Pin by IP, not by hostname string. Constrain getaddrinfo to return only the send's vetted IP set for the duration of .open() irrespective of the hostname argument (the send targets exactly the vetted addresses), and lowercase-normalise the key if a string key is retained. That closes both the redirect hop and the case-variant, since neither can reach the real resolver.

Classification: CWE-918

0 replies

Sign in to reply.