The challenge gives us a webhook service. We save a URL, then ask the server to send a POST request to it.

The flag lives at 127.0.0.1:5001/flag. We cannot ask for that URL directly because the service blocks loopback and private IP addresses. The mistake is that it checks the hostname first, then looks up the same hostname again when it sends the webhook.

DNS rebinding lets us change the answer between those two lookups.

Understanding the webhook service

The app starts a second HTTP server on loopback port 5001. It returns the flag only for POST /flag:

class FlagHandler(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.path == '/flag':
            self.send_response(200)
            self.send_header('Content-Type', 'text/plain')
            self.end_headers()
            self.wfile.write(FLAG.encode())
        else:
            self.send_response(404)
            self.end_headers()

threading.Thread(target=lambda: HTTPServer(('127.0.0.1', 5001), FlagHandler).serve_forever(), daemon=True).start()

The webhook URL goes through is_ip_allowed() before it is saved:

def is_ip_allowed(url):
    parsed = urlparse(url)
    host = parsed.hostname or ''
    try:
        ip = socket.gethostbyname(host)
    except Exception:
        return False, f'Could not resolve host'
    ip_obj = ipaddress.ip_address(ip)
    if ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_link_local or ip_obj.is_reserved:
        return False, f'IP "{ip}" not allowed'
    return True, None

socket.gethostbyname() turns the hostname into an IPv4 address. The code rejects private, loopback, link-local, and reserved addresses, so http://127.0.0.1:5001/flag is rejected straight away.

Both /register and /trigger call this function. /register saves the original URL, not the checked IP. /trigger checks the saved URL one more time, then sends the request:

allowed, reason = is_ip_allowed(url)
if not allowed:
    return jsonify({'error': reason}), 400

resp = requests.post(url, timeout=5, allow_redirects=False)

The checked IP never reaches requests.post(). Requests gets the original URL, including the hostname, and resolves that hostname when it opens the connection.

allow_redirects=False does not stop this. There is no HTTP redirect. The URL and hostname stay the same; the DNS answer changes.

The DNS rebinding idea

I used this webhook URL:

http://rebind.test:5001/flag

rebind.test is a hostname I control. The application needs these answers:

Step What the app does Answer for rebind.test
1 Check the URL while registering it 8.8.8.8
2 Check the saved URL while triggering it 8.8.8.8
3 Resolve the URL for the POST request 127.0.0.1

8.8.8.8 is only a public IP that passes the filter. It does not need to be Google's DNS address; any allowed public IP would do.

The second public answer is easy to miss. The app checks the URL again in /trigger. If the name changed to 127.0.0.1 right after registration, that second check would reject it.

On our own machine, 127.0.0.1 means our own machine. The final POST comes from the challenge container, though. For that request, 127.0.0.1:5001 points back to the container and reaches FlagHandler. The trigger endpoint then gives its response back to us.

These are three resolution calls in the application's flow, not a promise of exactly three DNS packets. A resolver can retry, ask extra questions, or reuse an answer.

Building the DNS responder

rebind_dns.py is a small UDP DNS responder. It listens on port 53, reads a DNS question, and sends back an A record. An A record maps a hostname to an IPv4 address.

The important part is its counter:

seen += 1
answer_ip = "8.8.8.8" if seen <= 2 else "127.0.0.1"

The first two accepted questions get the public address. Every later one gets loopback. The counter is global, so it also counts unrelated DNS questions that reach this responder.

The A record has a TTL of zero:

struct.pack("!HHIH", 1, 1, 0, 4)

TTL tells a resolver how long it may keep a DNS answer. Zero tells it not to keep this answer after the current lookup. That helps the rebinding sequence, but it cannot make the counter exact. Caching, retries, and extra DNS queries can still change the order the script sees.

Triggering the webhook

The page already has the two controls we need:

  1. Open the webhook page.
  2. Enter http://rebind.test:5001/flag in Register Webhook and click Register.
  3. Copy the id from the JSON response.
  4. Put that ID in Trigger Webhook and click Launch.
  5. The returned JSON contains the status and response from the internal request. The response is the flag.

solve.py automates those same two form submissions. It registers the URL, reads the webhook ID, triggers it, and prints the returned response. The DNS responder is what makes the final POST reach port 5001.

Reproducing the exploit

The supplied challenge archive and scripts reproduce the full flow with Docker:

iws_dir=$(mktemp -d)
tar -xzf intergalactic-webhook-service-official-source.tar.gz -C "$iws_dir"
docker build -t iws-local "$iws_dir/src"

docker network create iws-rebind
docker run -d --rm --name iws-dns --user 0 --network iws-rebind \
  -v "$PWD/rebind_dns.py:/srv/rebind.py:ro" \
  python:3.11-slim python /srv/rebind.py

dns_ip=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' iws-dns)
docker run -d --rm --name iws-local --network iws-rebind --dns "$dns_ip" \
  -p 127.0.0.1:31361:8000 iws-local

python3 -m pip install requests
python3 solve.py --url http://127.0.0.1:31361 --host rebind.test

local solver run

The Docker --dns setting sends this reproduction's DNS questions to the responder. On the event server, the hostname would need to be in a DNS zone we control so the target's normal resolver can receive changing answers.

rebind_dns.py
solve.py