Simple food notifications
gpn24
Task: Flask food-ordering app with an SSRF sink that validates the resolved IP with ipaddress.is_global before urllib3 fetches the URL; flag served at /vip-meal only to remote_addr 127.0.0.1. Solution: DNS rebinding TOCTOU via 1u.ms — host resolves to public 8.8.8.8 during the is_global check (passes), then urllib3's connect-timeout-and-retry triggers a fresh resolution after the dnsmasq 2s cache expires, rebinding to 127.0.0.1 and hitting the loopback-only VIP endpoint.
$ ls tags/ techniques/
$ cat /etc/rate-limit
Rate limit reached (20 reads/hour per IP). Showing preview only — full content returns at the next hour roll-over.
Simple food notifications — GPN CTF 2024 (gpn24)
Description
We are a new high tech startup in the food industry. In other words we are a new restaurant. Our last system was too complex, we made it simpler for you.
A Flask app handout (tar.gz) with full source was provided. The title/theme
("simpler") foreshadows the flag: why make it complex when you can make it
simple. The goal is to coerce the server into requesting an internal,
loopback-only endpoint that returns the flag.
Analysis
The app is a small restaurant ordering system. Key routes and logic from
app/app.py:
- The
FLAGis read from/flag. /vip-mealreturns the flag only ifrequest.remote_addr == "127.0.0.1", otherwise401 "You are not dressed appropriate to see even vip meals."So the server itself must requesthttp://127.0.0.1/vip-meal(classic SSRF to loopback)./order(POST, form paramurl) is the SSRF sink. Globally rate-limited to one request per 60s. It spawns a background threadcreate_meal(id, url)./notification/<id>(GET) returns JSON{id, message, status}— this is how the attacker reads back the SSRF response body (and thus the flag).
create_meal(id, url) flow:
time.sleep(secrets.randbelow(15-5)+5)— random 5–15s "let him cook".- CHECK (DNS resolution #1):
addresses = socket.getaddrinfo(urllib3.util.parse_url(url).host, 80). - For each resolved address:
if not ipaddress.ip_address(addr).is_global: -> REJECTED. This blocks every private/loopback/link-local IP. Confirmed:127.0.0.1,0.0.0.0,169.254.169.254,10/192.168,::1,::ffff:127.0.0.1all haveis_global == False;8.8.8.8/1.2.3.4areis_global == True. - USE (DNS resolution #2):
r = urllib3.request('GET', url, redirect=False, timeout=urllib3.Timeout(30)). urllib3 re-resolves the host independently when it opens the connection. - Stores
r.datainnotifications[id]["message"], statusDONE.
Environment (entrypoint.sh, dnsmasq.conf):
...
$ grep --similar
Similar writeups
- [web][free]Fancy Food Notifications— GPNCTF 2025 (KITCTF)
- [web][free]cookoff— gpnctf
- [infra][free]Old food— gpnctf24
- [web][Pro]QuickBite — SSTI via Registration Name Field— hackadvisor
- [web][free]chained— tjctf