September 12, 2026
Capture Returns — Bypassing a Stateless CAPTCHA Wall with OCR and OpenCV (TryHackMe)
Capture Returns is a TryHackMe web room that looks like a routine login-panel bruteforce until the target throws a curveball: a stateless…

By Roshan Rajbanshi
6 min read
Capture Returns is a TryHackMe web room that looks like a routine login-panel bruteforce until the target throws a curveball: a stateless, image-based CAPTCHA wall that demands three correct answers in a row before it will accept another login attempt. There is no session cookie, no hidden token, and no per-IP throttle header to lean on, only a randomized math or shape image the server expects solved blind. The real work of this room is not finding the vulnerability, it is engineering an automated solver, OCR for arithmetic captchas and contour detection for shape captchas, fast enough to clear the wall before a wordlist attack can land. A naive bruteforce script nearly produced a false positive here, which turned into the room's sharpest lesson: never trust "absence of a failure string" as proof of success.
Attack Path: Stateless CAPTCHA bypass (OCR + OpenCV solver) → wordlist bruteforce on /login → valid session → flag disclosure
Platform: TryHackMe
Machine: Capture Returns
Difficulty: Hard
OS: Linux (Ubuntu)
Date: September 2026
Table of Contents
1. Reconnaissance
1.1 Port Scanning
1.2 Web Enumeration
2. Initial Access
2.1 The CAPTCHA Wall
2.2 Building the Solver
2.3 The False-Positive Bug
2.4 Bruteforce Execution
3. Privilege Escalation
4. Proof of Compromise
5. Vulnerability Summary
6. Defense & MitigationPlatform: TryHackMe
Machine: Capture Returns
Difficulty: Hard
OS: Linux (Ubuntu)
Date: September 2026
Table of Contents
1. Reconnaissance
1.1 Port Scanning
1.2 Web Enumeration
2. Initial Access
2.1 The CAPTCHA Wall
2.2 Building the Solver
2.3 The False-Positive Bug
2.4 Bruteforce Execution
3. Privilege Escalation
4. Proof of Compromise
5. Vulnerability Summary
6. Defense & Mitigation1. Reconnaissance
1.1 Port Scanning
A quick service-version scan against the target revealed exactly two open ports.
nmap -Pn -F -sV <TARGET_IP>
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.13
80/tcp open http Gunicorn 20.0.4nmap -Pn -F -sV <TARGET_IP>
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.13
80/tcp open http Gunicorn 20.0.4💡 Gunicorn serving port 80 directly, with no Nginx or Apache in front of it, is a strong signal of a Python WSGI application (Flask or Django) rather than a static site. This shapes expectations for the rest of the engagement: templated routes, form-based POST handling, and potential framework-specific quirks such as debug tracebacks or SSTI.
A vulnerability scan of the Gunicorn version turned up CVE-2024–1135, an HTTP request smuggling flaw patched in Gunicorn 22.0.0. It requires a reverse proxy in front of Gunicorn to be exploitable, and none was present here, so it was ruled out early and not pursued further.
1.2 Web Enumeration
Requesting the web root returned Flask's default redirect response.
curl -s http://<TARGET_IP>/ | head -5
<!doctype html>
<html lang=en>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="/login">/login</a>.curl -s http://<TARGET_IP>/ | head -5
<!doctype html>
<html lang=en>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="/login">/login</a>.
The /login endpoint rendered a standard administrator login form accepting username and password fields via POST, footed with a "SecureSolaCoders" branding string. Nmap's NSE vulnerability, enumeration, and header scripts (--script vuln, vulners, http-enum, http-headers) returned nothing actionable against the web server.
2. Initial Access
2.1 The CAPTCHA Wall
Submitting a deliberately incorrect login returned a clear failure string.
curl -s -X POST http://<TARGET_IP>/login -d "username=test&password=test"
<p class="error"><strong>Error:</strong> Invalid username or passwordcurl -s -X POST http://<TARGET_IP>/login -d "username=test&password=test"
<p class="error"><strong>Error:</strong> Invalid username or passwordThis looked like a straightforward target for Hydra. However, after three failed attempts the server began responding with a CAPTCHA instead of the login form, demanding three correct solves in a row before granting further attempts.
<h2>Detected 3 incorrect login attempts!</h2>
<h3>You need to successfully solve 3 captchas in a row</h3><h2>Detected 3 incorrect login attempts!</h2>
<h3>You need to successfully solve 3 captchas in a row</h3>
The CAPTCHA type varied randomly between requests: sometimes a rendered PNG of a math equation (for example 292+74=?), sometimes a PNG of a shape with the prompt "Describe the shape below (circle, square, or triangle)."
💡 Critically, neither a
Set-Cookieheader nor a hidden form field was ever present in any response, confirmed withgrep -i "set-cookie"on both GET and POST, andgrep -o '<input[^>]*>'on the locked-out form. This ruled out session-based or token-based CAPTCHA validation entirely and pointed toward the server tracking the expected answer in a single shared, global variable rather than per-client state, a common mistake when a Flasksession[]is not used correctly.
That statelessness made the CAPTCHA bypassable: since the server did not bind the answer to any client identity, a fast enough automated solver could satisfy the check without ever needing to defeat CAPTCHA logic tied to a specific session.
2.2 Building the Solver
A Python script fetches a CAPTCHA, solves it, and resubmits it in the same request cycle before another request can overwrite the shared answer state. Full source: roshanrajbanshi/capture-returns-captcha-solver.
For math CAPTCHAs, the embedded PNG was decoded from its data:image/png;base64,... payload, upscaled 4x, and passed through pytesseract OCR restricted to a numeric/operator character set. The resulting equation was parsed with a regex and evaluated directly.
For shape CAPTCHAs, OpenCV was used instead of OCR: the image was thresholded, contours extracted, and approxPolyDP used to count polygon vertices, three sides mapped to triangle, four to square, and anything else to circle. The full shape-solving logic is available in solver.py.
The complete solver and bruteforce implementation is published at roshanrajbanshi/capture-returns-captcha-solver.
Run against filler credentials, the solver cleared three consecutive CAPTCHAs, correctly reading an OCR'd equation and classifying two shape images, and the login form reset to its normal, unlocked state.
2.3 The False-Positive Bug
The first version of the bruteforce script defined success as "the response contains neither the known failure string nor a CAPTCHA-lock string." This assumption broke silently. When the real credential pair was eventually reached, the server responded with a Set-Cookie: session=... header and the flag directly in the body, containing neither of the two known strings.
⚠️ Because that first hit satisfied the loose "absence of failure" check, the script logged it correctly as a success by accident, but every subsequent request in that already-authenticated session state also passed the same loose check, producing dozens of false-positive "SUCCESS" results for passwords that were never actually validated.
The fix replaced the absence-based check with an explicit allowlist of genuine success signals: a Set-Cookie header containing session=, or a "flag" string in the response body. Any response matching neither a known failure pattern, a CAPTCHA-lock pattern, nor an explicit success pattern is now logged as unrecognized and defaults to failure rather than being assumed successful. The corrected try_creds() logic is in bruteforce_v2.py.
2.4 Bruteforce Execution
With the CAPTCHA-solving logic wired into every login attempt and success detection corrected, the fixed script was run against the provided username and password wordlists, retrying automatically on connection resets with exponential backoff.
python3 bruteforce_v2.py <TARGET_IP> usernames.txt passwords.txt
[72/108] sherri:<REDACTED_PASSWORD> -> SUCCESS
*** VALID CREDS FOUND: sherri:<REDACTED_PASSWORD> ***python3 bruteforce_v2.py <TARGET_IP> usernames.txt passwords.txt
[72/108] sherri:<REDACTED_PASSWORD> -> SUCCESS
*** VALID CREDS FOUND: sherri:<REDACTED_PASSWORD> ***The successful request returned a Set-Cookie: session=... value and the flag directly in the response body, confirming valid administrator credentials.
3. Privilege Escalation
Not applicable to this room. The objective was fully satisfied through the web login bypass, no shell access was obtained or required, and the flag was disclosed directly in the authenticated HTTP response rather than via a filesystem read.
4. Proof of Compromise
No shell was obtained in this room, so no id output applies. Compromise is evidenced instead by the authenticated HTTP response below.
HTTP/1.1 200 OK
Set-Cookie: session=<REDACTED_TOKEN>; HttpOnly; Path=/
<h2>Flag.txt:</h2><h3>THM{<REDACTED_FLAG>}</h3>HTTP/1.1 200 OK
Set-Cookie: session=<REDACTED_TOKEN>; HttpOnly; Path=/
<h2>Flag.txt:</h2><h3>THM{<REDACTED_FLAG>}</h3>
5. Vulnerability Summary
# Vulnerability Severity Impact
1 Weak/guessable administrator credentials High Full admin panel access via wordlist bruteforce
2 Stateless CAPTCHA validation (no session/token) Medium Anti-automation control trivially bypassed with a fast solver
3 No rate limiting independent of CAPTCHA state Low Permits high-speed repeated login attempts once CAPTCHA cleared# Vulnerability Severity Impact
1 Weak/guessable administrator credentials High Full admin panel access via wordlist bruteforce
2 Stateless CAPTCHA validation (no session/token) Medium Anti-automation control trivially bypassed with a fast solver
3 No rate limiting independent of CAPTCHA state Low Permits high-speed repeated login attempts once CAPTCHA cleared6. Defense & Mitigation
Root Cause (Weak credentials): The administrator account used a common dictionary word as its password, with no complexity or breach-list enforcement at account creation.
⚠️ A default or weak administrative credential is often the single point of failure that renders every other control in a system irrelevant.
- Enforce minimum password complexity and check new/changed passwords against known-breach lists.
- Require multi-factor authentication on administrative login paths.
Root Cause (Stateless CAPTCHA): The expected CAPTCHA answer was stored in a global, shared server-side variable rather than bound to the requesting client's session.
- Bind CAPTCHA answers to a signed, per-session token (Flask
session[]) so one client's solve cannot be raced or reused by another request. - Use an established CAPTCHA service (hCaptcha, Turnstile) instead of a custom image-based implementation, which is inherently easier to automate.
Root Cause (No independent rate limiting): Login throttling depended entirely on the CAPTCHA mechanism, so defeating the CAPTCHA also defeated the only brake on request volume.
- Apply IP- or account-based rate limiting as a separate control layer that persists regardless of CAPTCHA outcome.