Capture!
Lab Info
| Detail | Info |
|---|---|
| Room | Capture! |
| Platform | TryHackMe |
| Difficulty | Medium |
| Type | CTF / Automated Login Brute Force with Dynamic CAPTCHA Bypass |
| Knowledge Required | Web, Python (requests, re), Burp Suite, Regex |
Tools Used
Nmap — Port scanning
Burp Suite — Capturing the raw login POST request to understand its shape
Python 3 (
requests,re) — Scripting the entire brute force, including solving the CAPTCHA on the flyGobuster (optional) — Confirming there's nothing else on the site worth checking
First Impressions
Last one for the month, and it's a fitting close — no shell to pop, no privesc chain, just a login form standing between you and the flag, backed by a provided username list, password list, and a math-equation CAPTCHA that regenerates on every failed attempt. The entire room is really one skill: writing a script patient enough to read the page's own error messages and adapt automatically, rather than trying to brute-force blindly and hoping something sticks.
Recon
nmap -sC -sV -p- <ip>
PORT STATE SERVICE
80/tcp open http
One port. The room hands over usernames.txt and passwords.txt directly as supporting files — a strong, explicit signal that this is a credential-stuffing exercise rather than something requiring separate enumeration to discover valid accounts.
The App — An Intranet Login With a Talkative Backend
Browsing to the site lands on a plain "Intranet Login" form. Testing an arbitrary guess (admin:12345) by hand first, before writing any automation, pays off immediately — the response text is specific and useful:
The user 'admin' does not exist.
That single message is the whole key to automating username discovery: any response that doesn't contain this exact phrase means the username tried is valid, regardless of whether the password was right.
Finding the CAPTCHA
Repeating a few login attempts by hand quickly triggers a different response:
Captcha enabled
Along with a simple arithmetic problem embedded directly in the page's HTML (something like 134 + 116 =). The CAPTCHA isn't an image or anything requiring OCR — it's plain text math, solvable with a regex and a calculator function, which is exactly what makes this room approachable to automate rather than needing anything heavier like pytesseract.
Capturing the Real Request Shape
Intercepting a login attempt in Burp confirms the form submits a straightforward POST with three fields:
username=<value>&password=<value>&captcha=<value>
captcha is absent on the first request in any sequence and only required on the retry once the server responds with "Captcha enabled" — meaning the script needs to send an initial request, detect the CAPTCHA condition, parse and solve the equation from that response, then resend with the solved value appended.
Building the CAPTCHA Solver
A regex tuned to the page's equation format extracts the two operands and the operator in one pass:
regex_pattern = r"(\d+)\s*([+\-*\/])\s*(\d+)\s*\="
def get_captcha(first, second, operator):
first, second = int(first), int(second)
if operator == '+': return first + second
if operator == '-': return first - second
if operator == '*': return first * second
if operator == '/': return first / second
Straightforward — the actual value of the equation matters far less than reliably detecting when the CAPTCHA has appeared and correctly parsing its three pieces out of whatever HTML surrounds them.
Brute-Forcing the Username
Reading the leaked message pattern back into a reusable function, wrapped around a loop over every line in usernames.txt:
def brute_force(url, username, password):
s = requests.Session()
payload = {"username": username, "password": password}
r = s.post(url, data=payload, verify=False)
if "Captcha enabled" in r.text:
m = re.search(regex_pattern, r.text)
result = get_captcha(m.group(1), m.group(3), m.group(2))
payload["captcha"] = result
r = s.post(url, data=payload, verify=False)
if "The user" in r.text and "does not exist" in r.text:
pass # not a valid username
else:
return username # no "does not exist" message = valid user
return None
Running this against every line of usernames.txt with a fixed placeholder password stops as soon as one attempt comes back without the "does not exist" message — that's the valid username, found without ever needing the right password yet.
Brute-Forcing the Password
Same structure, same CAPTCHA-solving logic, but now the username is fixed to the one just discovered and the loop runs over passwords.txt instead. The oracle message changes at this stage too — a wrong password on a valid username returns:
Invalid password for user '<username>'
So the winning condition flips: the correct password is whichever attempt's response contains neither the "does not exist" message nor the "Invalid password" message.
if "The user" in r.text and "does not exist" in r.text:
pass
elif "Invalid password" not in r.text:
return (username, password)
Running the loop returns the full valid credential pair once it hits the right entry in the password list.
Logging In
Submitting the recovered username and password through the actual login form (not just the script) confirms access and completes the room.
Flag Summary
| Flag | Method |
|---|---|
| Room flag | Scripted username enumeration via a "user does not exist" oracle message, then scripted password brute force via an "invalid password" oracle message — both loops solving a regenerating arithmetic CAPTCHA automatically on every attempt |
Key Takeaways
Manually testing a login form by hand before writing any automation is worth the two minutes it takes. The exact wording of both error messages ("does not exist" vs. "Invalid password") is what made the entire brute force logically separable into two clean stages — that distinction is easy to miss if you jump straight to scripting.
A CAPTCHA doesn't have to mean OCR or a paid solving service. Plain-text arithmetic embedded in the page is trivially regex-extractable — always check what kind of CAPTCHA is actually in play before reaching for heavier tooling than the problem needs.
Splitting username discovery and password discovery into two separate loops is far more efficient than a combined username×password brute force. Confirming the username first collapses what could have been thousands of combined guesses down to one list pass plus a second, smaller list pass.
Response-text oracles (specific error message present or absent) are just as reliable a signal as status codes or response length, and sometimes more reliable — this room's status codes reportedly stayed a flat 200 throughout, exactly the kind of case where length/status-based detection (the usual first instinct) fails and reading the actual response body becomes necessary.
A script doesn't need to be elegant to be effective. The straightforward, slightly repetitive structure here (test, check for CAPTCHA, solve, resend, check oracle) is genuinely the right level of engineering for a room like this — reaching for something more complex wouldn't have solved it any faster.
Day 30 of 30 ✅ — That's a wrap for the month!