Summary
9router validates image URLs by resolving the host before fetching, but the later
server-side fetch performs a separate DNS resolution. An attacker-controlled DNS name can
resolve to a public IP during validation and then rebind to an internal Docker/private IP
during the fetch. This allows the server-side image prefetch to reach internal-only HTTP
services (SSRF).
Details
- Affected version / commit: 9router
v0.4.80 @ b282f05.
- Reachable through
/v1/chat/completions with a vision-capable model and an
image_url content part. A vision-capable model name is required so the image survives
modality stripping and the server-side prefetch is armed.
- The provider used in this reproduction is the bundled mock provider — no real API
key and no real provider call.
- internal-admin (the SSRF target) is not exposed to the host network; it is
reachable only from inside the Docker network.
- rebind-dns behaviour for
rebind.9r.test:
- first A response →
1.1.1.1 (public) to pass the public-host guard,
- second A response →
172.29.0.10 (internal-admin) during the fetch.
- internal-admin logs
GET /ssrf-marker with peer=172.29.0.30 (the proxied-router
container), proving the server-side fetch landed on the internal service.
- mock-provider receives
POST /api/chat and the flow completes with HTTP 200.
- Root cause: DNS TOCTOU — the IP is not pinned between the validation resolution
(the public-host guard) and the fetch resolution. The guard and the fetch each resolve the
hostname independently, so a TTL-0 rebinding authority can return a public IP to the guard
and an internal IP to the fetch.
Proof of Concept
This repository is a self-contained Docker Compose reproduction. No real provider is called
and no real API key is required.
- Build and start the stack:
docker compose up --build
- Confirm
internal-admin is unreachable from the host:
curl -i http://127.0.0.1:18083/ssrf-marker # connection refused / fail
docker compose ps # internal-admin has NO host port mapping
- Send the request named
POST image-prefetch DNS rebinding trigger from
requests.http, or with curl:
curl -i -X POST http://127.0.0.1:18082/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "ollama-local/gemma3",
"messages": [{"role":"user","content":[
{"type":"text","text":"reproduction image-prefetch trigger"},
{"type":"image_url","image_url":{"url":"http://rebind.9r.test:8080/ssrf-marker?case=rebind-trigger"}}
]}],
"stream": false
}'
Impact
- SSRF to internal HTTP services reachable from the 9router host/container.
- Depending on the environment, this can reach cloud metadata endpoints, internal admin
panels, or be used for internal service discovery.
- Blind / semi-blind SSRF when the fetched response is not returned to the attacker; an
exfil variant (pointing the image at an internal endpoint that returns valid image bytes)
can return internal content base64-encoded to the upstream.
- Requires a code path that prefetches/normalizes remote images for vision-capable
providers.
- No real credential is needed for the reproduction.
Suggested Fix
- Pin the resolved IP after validation and connect to that IP (resolve once, then
reuse the address for the fetch).
- Block private, loopback, link-local, multicast, and cloud-metadata ranges at connect
time, not only at validation time.
- Perform DNS resolution and IP checks immediately before the request and against the
address actually used to connect.
- Disable redirects, or re-validate every redirect target with the same checks.
- Enforce an allowlist for image-fetch domains where feasible.
- Add a timeout, a response size limit, and a content-type check.
References
Summary
9router validates image URLs by resolving the host before fetching, but the later
server-side fetch performs a separate DNS resolution. An attacker-controlled DNS name can
resolve to a public IP during validation and then rebind to an internal Docker/private IP
during the fetch. This allows the server-side image prefetch to reach internal-only HTTP
services (SSRF).
Details
v0.4.80@b282f05./v1/chat/completionswith a vision-capable model and animage_urlcontent part. A vision-capable model name is required so the image survivesmodality stripping and the server-side prefetch is armed.
key and no real provider call.
reachable only from inside the Docker network.
rebind.9r.test:1.1.1.1(public) to pass the public-host guard,172.29.0.10(internal-admin) during the fetch.GET /ssrf-markerwithpeer=172.29.0.30(theproxied-routercontainer), proving the server-side fetch landed on the internal service.
POST /api/chatand the flow completes withHTTP 200.(the public-host guard) and the fetch resolution. The guard and the fetch each resolve the
hostname independently, so a TTL-0 rebinding authority can return a public IP to the guard
and an internal IP to the fetch.
Proof of Concept
This repository is a self-contained Docker Compose reproduction. No real provider is called
and no real API key is required.
internal-adminis unreachable from the host:POST image-prefetch DNS rebinding triggerfromrequests.http, or with curl:Impact
panels, or be used for internal service discovery.
exfil variant (pointing the image at an internal endpoint that returns valid image bytes)
can return internal content base64-encoded to the upstream.
providers.
Suggested Fix
reuse the address for the fetch).
time, not only at validation time.
address actually used to connect.
References