webfreemedium

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/
ssrf_localhost_bypassip_blocklist_bypassdns_rebinding_toctoutime_of_check_time_of_useurllib3_retry_resolution_desync

$ 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 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):

...

$ grep --similar

Similar writeups