Skip to content

Build1 publisher2 min readPublished

The image proxy fetches 169.254.169.254 as readily as it fetches a cat photo

Webhooks, image proxies, link unfurlers and PDF renderers all pass a user's string to an HTTP client that sits inside your private network, where the cloud metadata endpoint answers an unauthenticated GET and hands back IAM credentials.

The Engineer · Build desk

Illustration accompanying The image proxy fetches 169.254.169.254 as readily as it fetches a cat photo

What happened

  • A dev.to post treats webhooks, image proxies and avatar uploaders, PDF and screenshot generation, and link previews as one vulnerability class, since in each the user supplies a URL and the server fetches it.
  • Its example image proxy passes $_GET['url'] straight into PHP's file_get_contents, which serves a cat photo and the AWS IAM credentials path under 169.254.169.254 with equal success.
  • That address is the instance metadata endpoint on AWS, GCP, Azure and most other providers, and any process on the instance can query it without authentication.
  • On AWS the endpoint can return temporary IAM credentials for the role the instance runs as, covering whatever S3, RDS or Lambda permissions that role holds.
  • The post's grep list for candidate call sites is curl_exec, file_get_contents(, Http::get(, GuzzleHttp\Client and headless-browser goto() or ->visit() calls whose URL traces back to user input.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • exposure What an avatar upload form is worth to an attacker is decided by whoever scoped the instance role, and that scoping happened in a deploy nobody reopens when the fetch feature ships.
  • constraint String checks on hostnames and private ranges leave the class open, so the security work lands on whatever code actually opens the socket, and it has to hold across redirects.
  • decision Detection cannot rest on a user reporting a strange response, because the riskiest fetches happen with no user attached to them; the signal has to come from the HTTP client's own logs.

These four features get added months apart by different people, none of them thinking about the other three, according to the dev.to post [9]. Ownership explains why that keeps happening. Someone reviewing avatar-from-URL is reviewing a resize; the instance role was attached during a deploy, by someone else, in a different pull request. Ten years ago SSRF sat in the appendix of a pentest report, and the post now places it among the highest-impact bugs in the OWASP Top 10, because modern apps fetch URLs on the user's behalf constantly [15].

PHP is incidental. Two conditions have to hold for the credential step to work on your stack: the fetch runs on an instance with a role attached, and the process doing the fetch can reach 169.254.169.254 at all [5][6]. The post does not say which metadata service configuration it tested.

Of the four addresses it points the example request at, three buy internal reach or a port banner and one buys credentials [16]. The three are an unauthenticated Redis on localhost:6379, an admin panel on 192.168.1.50:8080 with no auth wall, and a database port on 10.0.0.5 for banner-grabbing [7]. The panel has no auth wall because it is internal and nobody can reach it [7]. The image proxy can reach it.

The timing differs by feature. A webhook callback URL is registered now and fetched later by a job queue, with no user present to notice where the request went [10]. A link unfurler fetches the pasted URL server-side and unauthenticated the instant it is pasted, often before the user sends the message [12].

PDF and screenshot rendering is the hardest of the four to validate, because the fetcher is a browser. The post calls a headless Chrome instance following window.location redirects "a full SSRF engine with a UI" [11]. A check at submission time inspects the string the user typed. The browser then issues its next request to wherever that string points.

The first instinct is a blocklist: reject localhost, 127.0.0.1, the private ranges. The post calls that necessary but nowhere near sufficient, opens its bypass walkthrough with the many equivalent spellings of 127.0.0.1, and says every bypass it covers has shown up in real bug bounty reports [14]. None of this needs a deserialization bug, a memory-corruption bug, or, in a lot of real-world cases, authentication [8]. One shared fetch helper is the cheapest place to put the check, since the same handful of call patterns turns up in four features with four different owners [13][9].

What to watch

  • Whether the post's bypass walkthrough, which opens on encoding tricks, goes on to DNS rebinding and redirect chains.
  • Whether the instances running your fetchers require a metadata session token, since the example depends on a plain GET succeeding.
  • Link unfurlers are testable in a minute: paste a URL you control and watch whether the fetch lands before you press send.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories