August 4, 2026
The Server Is a Servant: SSRF in Labs
Last time in The Server Is a Servant, I got an XML parser to fetch things on my behalf, and it ended on a single change: swap file:// for…
By Ömer Habip
10 min read
Last time in The Server Is a Servant, I got an XML parser to fetch things on my behalf, and it ended on a single change: swap file:// for http:// and the parser stops reading disks and starts making requests from inside, as a trusted source. That was SSRF (Server-Side Request Forgery) arriving through the back door. However, this time it comes through the front.
Server-Side Request Forgery is the simpler bug of the two. The application takes a URL from the request and fetches it, and nobody checked where that URL points. No specification corners, no nested declarations. Just a fetch that goes somewhere it shouldn't.
What makes it worth a whole post is where the server is standing. It's inside the network, past the firewall, and everything in there was built on the assumption that only trusted things could reach it. Admin panels with no login because they're "internal". Metadata endpoints that hand out credentials to anyone who asks, because only the machine itself can ask. Until it asks on my behalf. In this post I'll walk through where that URL hides when it doesn't look like a URL, what the server can reach once it fetches on my behalf, and what happens when someone finally puts a filter in front of it. The filters are where it gets interesting, because every one of them fails for the same reason.
What is SSRF
At its core, SSRF is a vulnerability that lets an attacker reach places they were never supposed to reach: internal services, admin panels, the cloud metadata endpoint. The trick is that the request doesn't come from the attacker. It comes from something the network already trusts.
Think of it the way you were taught as a kid. You don't take candy from a stranger. But if a friend hands you one, you take it happily. Notice what changed: not the candy, but the medium. And the friend will keep handing them over for as long as the stranger keeps supplying them.
That's the whole bug. My browser sits outside, where the firewall decides what I can talk to. The server sits inside, where it can reach localhost, private address ranges, admin interfaces that never got a login because they were only ever meant to be internal. When the application fetches a URL I chose, it fetches from there, not from here.
Where It Hides
XXE was hard to find and fiddly to exploit. SSRF is the other way round. Half the internet is applications fetching URLs for perfectly good reasons, so the surface is enormous. The work is noticing it.
The easy half is where the application admits what it's doing: a link preview, a feed importer, an "upload from URL" box, a webhook you configure yourself, a PDF generator that pulls in images. If a feature's entire job is to go and get a page, then the destination is user input by design and nobody had to make a mistake for you to control it. The labs work this way too: the parameter is called stockApi and it holds a full URL, sitting in the request where anyone can see it.
The harder half is where the fetch is a side effect of something else. The application takes a hostname, or a path, or a port, and builds the URL itself, so you only ever control a fragment. That feels harmless until you remember that most of this code is string concatenation. Or the fetch isn't the application's at all: an analytics package reads the Referer header and goes to visit whatever it finds, on the same machine, with the same access. Shops run this kind of software on purpose, because they want to know which sites are sending them customers and where to spend more. One of the labs runs on exactly that, and nothing in the request gives it away. You find it by sending a Collaborator URL into a header nobody claims to read, and waiting.
So the question worth asking isn't "where's the URL parameter". It's whether the server has to leave the building to do what I just asked it to do. If it does, something on the way there is choosing a destination, and I want to know how much of that choice is mine.
The Inside Is Trusted
The first lab is a stock checker with a parameter called stockApi holding a full URL. Point it at http://localhost/admin and the admin panel comes back in the response, no login, no session, nothing. It was never protected because the only thing that could reach it was the server itself, and that was considered protection enough.
The second lab moves the target off the machine. The admin panel is on another host somewhere in 192.168.0.0/24, and you don't know which one. The trick is that there isn't one, just Burp Intruder walking the last octet against http://192.168.0.X:8080/admin and watching for a status code that stands out from the rest.
Both labs finish the same way. Find the delete link on the panel, put its URL through stockApi, and the server carries out the request for you. The panel is reached through the server, and then the action is performed through it too.
Worth sitting with what these two labs are actually showing, because it isn't a parsing bug or a filter bypass. It's an assumption. Someone decided the admin panel didn't need a login because it was internal, and someone else wrote a feature that fetches URLs, and neither decision is wrong on its own. The vulnerability lives in the gap between them, which is why SSRF keeps appearing in applications where every individual piece looks reasonable.
Of course developers know about this, and they write filters. That's where it stops being about finding a URL parameter and starts being about how much two pieces of code can disagree over one string.
When the Filter Fights Back
There are two common types of filtering: blacklisting and whitelisting. Blacklisting is good at blocking known keywords, as the XSS post covered. The catch is arithmetic. For a blacklist to work it has to be complete, and no blacklist ever is.
Loopback alone has more spellings than a filter is going to enumerate. 127.1, 127.0.1 and 2130706433 all land on 127.0.0.1, because an address can be written in fewer than four parts and the last part simply fills whatever bits are left, or written as a single integer with no dots at all. Then there are the ones that aren't addresses: localhost, or a domain you registered yourself that resolves to 127.0.0.1. Add open redirects and encoding tricks and the list stops being a list.
This lab is built on exactly that. The request looks like this:
stockApi=http://stock.weliketoshop.net:8080/product/stock/check?productId=3&storeId=1stockApi=http://stock.weliketoshop.net:8080/product/stock/check?productId=3&storeId=1First I changed the URL to http://localhost and got External stock check blocked for security reasons. So the filter recognises localhost as a string, not as an address. Which raised the obvious question: does it care about case? I sent http://loCalhost and it went straight through. The admin panel came back in the response. Seeing the panel isn't the same as using it. I opened it in my browser and the delete link did nothing, because I was neither an admin nor coming from loopback. So back to the payload. http://loCalhost/admin returned the same error, which meant admin was on the blacklist too. However, the payload below was able to bypass the blacklist.
So I thought they were looking for the presence of the admin keyword. This time I tried encoding instead. http://loCalhost/ad%6din gave me the same error, which told me something useful: the filter was decoding the string before checking it, so a single layer of encoding hid nothing. So I encoded the % as well and sent ad%256din. The filter decodes once and sees ad%6din, which doesn't match admin. The server decodes again and fetches admin. This is the same arithmetic as the entity escaping in the last post. Count the passes, and encode for the one you want the character to survive to. carlos deleted.
Writing this up, I realised I never needed the encoding at all. The case trick that worked on localhost works on admin too, and http://loCalhost/Admin solves the lab on its own. Two separate case-sensitivity bugs in the same filter, and I only noticed one of them.
Whitelists Don't Fail Better
A blacklist has to be complete to work, which is why it never is. Invert it and the arithmetic looks much better: instead of naming everything forbidden, name the one thing allowed. In this lab the URL has to point at stock.weliketoshop.net, and no spelling of localhost satisfies that.
So the attack changes shape. I'm not hiding where I'm going any more. I'm writing one string that the validator reads as the allowed host while the fetch resolves somewhere else.
URLs are unusually accommodating about this. The authority section can carry credentials, and everything before the @ is user info rather than host. A # cuts from the other end, marking where the host stops. Two standard features, both about where a hostname begins and ends, and neither side of the application has to agree on the answer.
With those two features in mind, here is how the lab went.
http://localhost came back with external stock check host must be stock.weliketoshop.net. That already rules out everything from the previous lab. Case changes and encoding get you nowhere when the filter isn't hunting a banned word, it's demanding a specific host.
My first assumption was that the allowed hostname simply had to appear somewhere in the URL, so I put it in front of a Collaborator domain:
http://stock.weliketoshop.net@COLLABORATOR.oastify.comhttp://stock.weliketoshop.net@COLLABORATOR.oastify.comSame error. That failure told me more than a success would have. The filter isn't doing a substring match, it's parsing the URL properly and taking the host from after the last @. Encoding the @ didn't help either, once or twice.
So the filter reads the host correctly. Which meant I didn't want to fool it about what the host was. I wanted the host to change between the filter and the fetch. That turned my URL around:
http://COLLABORATOR.oastify.com@stock.weliketoshop.nethttp://COLLABORATOR.oastify.com@stock.weliketoshop.netNow the filter approves, because the last @ is followed by the allowed host. But so does the fetch, and it goes to stock.weliketoshop.net exactly as intended. Nothing gained.
This is where the # earns its place. Put it after my hostname and the fetch stops reading the host there, landing on my domain, while the filter still sees a URL that ends in the right place.
http://COLLABORATOR.oastify.com%23@stock.weliketoshop.nethttp://COLLABORATOR.oastify.com%23@stock.weliketoshop.netSame error, and I'd forgotten my own lesson from the previous lab. There are two decodes here, one before the filter and one before the fetch. A single layer gets stripped too early and the filter sees the # for what it is.
http://COLLABORATOR.oastify.com%2523@stock.weliketoshop.nethttp://COLLABORATOR.oastify.com%2523@stock.weliketoshop.netThat went through. From there it's a matter of pointing it somewhere worth reaching :
http://localhost%2523@stock.weliketoshop.net/admin/delete?username=carloshttp://localhost%2523@stock.weliketoshop.net/admin/delete?username=carloscarlos deleted. Every step of that is the same bet as the blacklist lab: how many times does a string get read before anyone acts on it, and does everyone agree on what it says.
Borrowing a Host That's Already Allowed
Sometimes the whitelist holds. The validator parses properly, takes the host from the right place, and rejects everything that isn't on the list. There's no ambiguity left to exploit.
So stop attacking the check and start looking at what it approves. The allowed host is a real application with its own features, and one of those features might be a redirect. Plenty of sites have a parameter that sends you somewhere else after a click, a login, or a product view. If that parameter isn't restricted, the allowed host will happily point anywhere you like.
http://stock.weliketoshop.net/product/nextProduct?path=http://192.168.0.12/adminhttp://stock.weliketoshop.net/product/nextProduct?path=http://192.168.0.12/adminEvery part of that is legitimate. The validator inspects the URL, sees the allowed host, and approves it correctly. The server fetches it and gets back a 302 pointing at the internal address. Then it follows the redirect, because that's what HTTP clients do, and the second request never goes past a filter at all.
This one is different from the two before it, and the difference is worth naming. The blacklist and whitelist bypasses both depended on two pieces of code disagreeing about one string. Here nobody disagrees. Both sides read the URL the same way and both are right about it. The failure is that validation happened once, at the start, while the fetch was free to keep travelling. A URL isn't a destination, it's the first step of one.
Which also explains why open redirects get triaged as low severity on their own and then turn up as the missing piece in something serious. On its own it sends a user somewhere unexpected. Attached to a server-side fetch, it converts a whitelist into a directory of hosts that will forward requests on your behalf.
Wrapping Up
Nothing I reached in these labs was protected by anything except location. The admin panel had no login because it was internal. The back-end host was safe because it was on a private range. Those aren't controls, they're assumptions about who can knock.
The filters show the pattern. The blacklist failed because it had to be complete. The whitelist failed because two pieces of code read one string and disagreed about where the hostname ended. The open redirect failed even though both sides read it correctly, because validation happened once and the fetch kept travelling.
So the question worth asking is a small one: how many times does this string get read, by what, and does any of it have to agree.
How to Prevent This
Validate the destination, not the string. Resolve the hostname first, then check the resulting IP against an whitelist and reject loopback, private ranges and link-local. That order alone removes most of this post, because alternative spellings and @ tricks only matter while the check is happening on text.
Then close the gaps around it. Don't follow redirects on server-side fetches, or revalidate every hop if you must. Allow only the schemes you actually use. Never build the URL by concatenating user input.
And stop treating the network as a control. Internal services should authenticate their callers, because "only the machine can reach this" stops being true the first time the machine takes requests from strangers.
One last note: everything here was done in intentionally vulnerable labs built for practice, like PortSwigger's Web Security Academy. Never test these techniques on systems you don't own or don't have explicit permission to test. The difference between security research and a crime is authorization.