ABN Gallery was a web challenge in DreamHack CTF Season 7, Round 22, Division 2. It is a small Express application with two interesting routes and a guard between them, and the entire solution is one character long: the host 0.
The application
Three routes matter.
GET /fetch?url=… is a proxy. It extracts the host from the URL, asks whether that host is public, and refuses anything that is not:
app.get("/fetch", async (req, res) => {
const { url } = req.query;
const host = extractHost(url);
if (!host || !hostIsPublic(host)) {
return res.status(400).send("only public hosts are allowed");
}
const upstream = await fetch(url);
res.send(await upstream.text());
});
GET /admin?log=… reads a log file, and is restricted to callers on a private address:
app.get("/admin", (req, res) => {
if (!ip.isPrivate(req.socket.remoteAddress)) {
return res.status(403).send("admins only");
}
const { log } = req.query;
res.sendFile(path.join(LOG_DIR, log));
});
LOG_DIR is /abn_gallery/logs, and the flag is at /flag.
The two routes are each other's complement. /admin will read any file under a joined path with no sanitisation at all, but only for a local caller. /fetch will make a request on our behalf, but only to somewhere that is not local. Put them together and the challenge is a single question: can we make /fetch call /admin?
The guard
hostIsPublic is a thin wrapper over the ip package and is not the weak link — ip.isPrivate knows about loopback, the RFC 1918 ranges, link-local, and the IPv6 equivalents. The weak link is the function that decides what string gets handed to it:
function extractHost(url) {
let s = String(url);
const scheme = s.indexOf("://");
if (scheme !== -1) s = s.slice(scheme + 3);
const slash = s.indexOf("/");
if (slash !== -1) s = s.slice(0, slash);
const colon = s.indexOf(":");
if (colon !== -1) s = s.slice(0, colon);
return s;
}
Read as prose, that is "after the scheme, before the path, before the port" — which is a fair description of where a host sits in a URL, and it is wrong in the way every hand-written URL parser is wrong: it reimplements a grammar that the platform already implements, and the two implementations will not agree everywhere.
The parser that actually matters is the one inside fetch, because that is the one that decides which socket gets opened. Our job is to find a string where the two disagree.
Four attempts, and why three failed
Credentials in the authority. http://example.com@127.0.0.1/ is the classic: a naive parser that cuts at the first / and ignores @ keeps example.com@127.0.0.1, while a correct one takes everything after the @ as the host. Here it fails on the other side — extractHost returns the whole example.com@127.0.0.1, which ip cannot parse, so the guard rejects it before fetch ever sees it. The guard is too strict to be fooled this way, not too lax.
Loopback by name or by number. 127.0.0.1, localhost, and [::1] are all exactly what ip.isPrivate was written to catch.
Alternative integer and short forms. 2130706433 is 127.0.0.1 as a single decimal, 0x7f000001 as hex, 0177.0.0.1 with an octal first octet, and 127.1 in the historical short form. These are the forms that classically split a hand-rolled check from a real resolver — and the ip package normalises enough of them that the guard still says private. Worth trying in any engagement; not the way in here.
A bare zero. http://0:3000/ is the one that works, and it works because both parsers behave correctly and still disagree:
-
extractHostcuts at://, finds no/before the port, cuts at:, and returns the two-character string"0". Asked whether"0"is private,ipsays no — it is not in any private range, because on its own it is not a meaningful address at all. So the guard calls it public and lets it through. -
fetchhands0to the platform's own resolver, where a bare integer is a legal address literal. It expands to0.0.0.0, the wildcard address, which on a client connection means this host. The socket lands on the local server.
The request arrives at the application from 127.0.0.1. ip.isPrivate on req.socket.remoteAddress says yes, and /admin opens.
The full chain
With /admin reachable, the unsanitised path.join finishes it:
path.join("/abn_gallery/logs", "../../flag") → "/flag"
One request:
GET /fetch?url=http://0:3000/admin%3Flog%3D../../flag
The ? and = of the inner query need encoding so the outer router does not claim them as its own parameters. With curl:
curl -sG 'http://host1.dreamhack.games:PORT/fetch' \
--data-urlencode 'url=http://0:3000/admin?log=../../flag'
DH{7hANks_f0r_vI5i71nG_7he_9A11ery:+93NJHK0nyCSznWM7i4Xxg==}
TIP
0is worth remembering in both directions. As an SSRF payload it is the shortest loopback alias there is, and it survives blocklists that enumerate127.*,localhost, and::1because it is none of them. As a bind address it is the opposite mistake: a service that binds0.0.0.0when it meant127.0.0.1is listening on every interface, which is how a "local-only" admin endpoint ends up on the internet.
What the bug actually is
It is tempting to file this as "SSRF filter bypass" and move on, but the class is more specific and more useful than that: a parser differential. The vulnerability is not in either parser. extractHost returns a defensible answer for the string it was given, and the resolver returns the correct answer for the string it was given. The bug lives in the gap between them — the value that was validated and the value that was used were derived from the same input by different code, so no amount of hardening on either side closes it.
Three design rules follow, and they generalise well past SSRF:
- Parse once, then pass the parsed object. Here that means using
new URL(url)and reading.hostname, then handing the guard and the fetch the same parsed value. Validating a string and then letting a second parser re-derive the interesting part from the original string is the whole bug. The same shape shows up in content-type sniffing, in signature verification over a re-serialised body, and in any filename that is checked as text and then resolved as a path. - Validate after resolution, not before. Even one parser is not enough when DNS is in play: a hostname that resolves to a public address at check time can resolve to a private one at connect time. Resolving explicitly and checking every returned address — or pinning the connection to the address that was checked — is what actually holds. A string check cannot.
- An allowlist of destinations beats a blocklist of addresses.
/fetchexists to retrieve gallery images, so it has a small set of hosts it ever legitimately needs. Blocklists lose to notation:0,127.1,2130706433,[::ffff:127.0.0.1],0177.0.0.1, a*.nip.ioname, a redirect to any of them. An allowlist does not care how many spellings of loopback exist.
And the traversal in /admin is worth naming separately, because it is the half that turns a reachability bug into a disclosure. path.join is not a security function: it normalises .. rather than rejecting it, which is exactly what makes it convenient. The check that belongs there is to resolve the result and confirm it is still inside the intended directory — path.resolve(LOG_DIR, log).startsWith(LOG_DIR + path.sep) — or, better, to never accept a path from the user at all and look the requested log up in a fixed table of known names.
Neither half of this challenge is exploitable alone. The access control on /admin is real, and the SSRF guard on /fetch catches everything it was written to catch. They fail together, which is the usual way.