$ cat writeup.md…
$ cat writeup.md…
ASIS CTF 2026
Task: SSRF image proxy allowlists https://img.proxydough.net but follows redirects and echoes the fetched body, while the flag endpoint serves only loopback clients. Solution: Cloudflare /cdn-cgi/image with onerror=redirect yields an attacker-controlled 307 Location; a WHATWG-vs-PHP backslash authority parsing differential makes PHP connect to 127.0.0.1 and leak the flag.
"We want the secret recipe of this famous cookie dough, can you get it ? PS: The source code is given for information, however, we recommend solving the challenge on the remote instance. Bruteforce is not allowed, the source code on the remote instance is the same has given."
English summary: A cookie-shop web app exposes an SSRF-style image proxy (/api/proxy?url=...) that only accepts https://img.proxydough.net URLs, but follows redirects and echoes the final body back to the client. The secret recipe endpoint (/api/recipe) is served only to clients whose REMOTE_ADDR is 127.0.0.1 or ::1. Goal: make the origin fetch its own loopback recipe endpoint through the proxy.
api/recipe.php — the flag only for loopback clients:
if (!in_array($_SERVER["REMOTE_ADDR"], ["127.0.0.1", "::1"])) { http_response_code(403); die("Access not allowed."); } else { die("ASIS{flag}"); }
api/proxy.php — the SSRF sink:
parse_url($url) must yield scheme https and host img.proxydough.net (strict strtolower compare).file_get_contents($url, false, $context):
ignore_errors = true,user_agent = ProxyDough-ImageProxy/1.0,follow_location defaults ON (up to 20 redirects),Content-Type: image/png.So the sink follows redirects and prints the final body: if we can obtain any response with Location: http://127.0.0.1/... from inside the img.proxydough.net allowlist, the origin PHP will connect to its own loopback and hand us the recipe. .htaccess rewrites /api/proxy and /api/recipe to the PHP files and denies products.php; the direct file path /api/recipe.php also executes.
Infrastructure recon facts:
:80 only; all CF alt ports answer 521).img.proxydough.net is a separate Apache 2.4.66 vhost on the same origin: static files only, /api/* → 404, no open redirects (~60 targeted paths probed).img.proxydough.net to Cloudflare (a proxy-fetch of :8443 echoes the CF 521 body) — no split-DNS shortcut to loopback.recipe.php is 183 bytes vs 160 bytes in the source ⇒ the real flag is 38 chars (ASIS{ + 32 hex + })./cdn-cgi/image/<options>/<source-url> responds. This was the anomaly that hid the intended path.1. onerror=redirect on Cloudflare Image Resizing. When the source URL fetches successfully but is not a resizable image, Cloudflare responds 307 with Location: set to the source URL verbatim — a fully attacker-controlled Location string, raw bytes preserved.
2. URL-parsing differential on the backslash. The source string
http://proxydough.net\@127.0.0.1/api/recipe.php
is parsed differently by the two parsers in the chain:
| Parser | Backslash handling | Resulting host | Resulting path |
|---|---|---|---|
| Cloudflare (WHATWG) | \ terminates the authority | proxydough.net | /@127.0.0.1/api/recipe.php |
PHP (legacy php_url_parse, shared by parse_url and the stream wrapper in PHP ≤ 8.4) | ordinary host char; userinfo split at last @ | 127.0.0.1 (userinfo proxydough.net\) | /api/recipe.php |
Cloudflare fetches http://proxydough.net/@127.0.0.1/api/recipe.php (Apache 404 → not an image → onerror=redirect fires), then emits Location: http://proxydough.net\@127.0.0.1/api/recipe.php with the raw backslash intact. PHP's legacy parser reads that same string as host=127.0.0.1, path=/api/recipe.php — the allowlist check passed for img.proxydough.net, but the wrapper now connects to loopback.
3. The loopback hop. PHP on the origin follows the Location, connects to 127.0.0.1:80 (its own loopback), and lands on Apache's default vhost — the main app docroot. .htaccess rewrites are not in effect there, so the rewrite form /api/recipe 404s on this hop; the direct /api/recipe.php file runs, sees REMOTE_ADDR=127.0.0.1, and the flag is echoed back through the image proxy.
Single request:
GET https://proxydough.net/api/proxy?url=<urlencoded>
with the URL-encoded inner URL:
https://img.proxydough.net/cdn-cgi/image/onerror=redirect,width=32/http://proxydough.net\@127.0.0.1/api/recipe.php
Solver script:
#!/usr/bin/env python3 import urllib.parse import urllib.request TARGET = "https://proxydough.net/api/proxy" # CF (WHATWG) sees host=proxydough.net, path=/@127.0.0.1/api/recipe.php -> 404 # -> onerror=redirect -> 307 Location <verbatim source> ; PHP legacy parser # reads the same string as host=127.0.0.1, path=/api/recipe.php. inner = ("https://img.proxydough.net/cdn-cgi/image/onerror=redirect,width=32/" r"http://proxydough.net\@127.0.0.1/api/recipe.php") url = TARGET + "?url=" + urllib.parse.quote(inner, safe="") body = urllib.request.urlopen(url, timeout=30).read().decode(errors="replace") for line in body.splitlines(): if "ASIS{" in line: print(line.strip()) break
Details that matter for reproducing:
/api/recipe.php (direct file), not /api/recipe: the loopback hop hits the default vhost where the .htaccess rewrite does not apply, so the rewrite path returns 404 there./ returned the app's index.php HTML and an Apache 404 page signed "Server at 127.0.0.1 Port 80" — proof of the loopback hop before the final shot.ASIS{REDACTED}.parse_url vs stream-wrapper confusion: both share php_url_parse in PHP ≤ 8.4; PHP 8.5's RFC3986-RAW wrapper diverges from legacy parse_url, but not in the exploitable direction._ (verified with captured raw requests).in_array loose-comparison bypass: both operands are strings, PHP 8 compares literally — no./etc/hosts img → 127.0.0.1: disproven via the CF 521 body echo test (PHP resolves the host via public DNS).X-Forwarded-For / True-Client-IP / X-Real-IP): no effect on REMOTE_ADDR; spoofing CF-Connecting-IP is rejected by Cloudflare (error 1000).mod_speling/MultiViews off, mod_dir 301s are same-host only.[::1] / nip.io / localtest.me all → edge 403 Forbidden, no redirect fires). The bypass needed the backslash differential so that Cloudflare never sees 127.0.0.1 as the host.Host: proxydough.net) does reach the main vhost, but the PHP stream wrapper cannot set the Host header.$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar