Skip to content

Latest commit

 

History

History
298 lines (222 loc) · 12.8 KB

File metadata and controls

298 lines (222 loc) · 12.8 KB

Lab: Building a Secure Login System

In this lab you build a PHP login flow from a deliberately broken starting point, exploit its flaws, then harden it with password_hash(), session-fixation defence, and brute-force throttling.


Objective

By the end of this lab you will be able to:

  • Store credentials with password_hash() / password_verify() instead of plaintext or fast hashes.
  • Defeat session fixation by regenerating the session ID on privilege change.
  • Throttle repeated failed logins to blunt online password guessing.
  • Use hash_equals() and generic error messages to remove user-enumeration and timing side channels.

Prerequisites

  • PHP 8.2 or 8.3 CLI with PDO SQLite enabled (php -m | grep pdo_sqlite).
  • Basic familiarity with sessions, cookies, and curl.
  • Understanding of SQL prepared statements — see Prepared-Statements.

Lab Setup

Create a working directory and the SQLite schema.

-- schema.sql
CREATE TABLE users (
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
    username      TEXT NOT NULL UNIQUE,
    password_hash TEXT NOT NULL,
    fail_count    INTEGER NOT NULL DEFAULT 0,
    locked_until  INTEGER NOT NULL DEFAULT 0
);

Seed one user with a properly hashed password:

<?php
// seed.php
declare(strict_types=1);

$pdo = new PDO('sqlite:' . __DIR__ . '/app.db', options: [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$pdo->exec(file_get_contents(__DIR__ . '/schema.sql'));

$hash = password_hash('S3cr3t-Pass!', PASSWORD_DEFAULT);
$stmt = $pdo->prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)');
$stmt->execute(['alice', $hash]);

echo "Seeded user alice\n";

Run the setup and start the built-in server:

php seed.php
php -S 127.0.0.1:8000

Walkthrough

Step 1 — Meet the vulnerable login

Save this as login_vuln.php. It "works" but is riddled with flaws.

<?php
// login_vuln.php  -- DO NOT SHIP
declare(strict_types=1);
session_start();

$pdo = new PDO('sqlite:' . __DIR__ . '/app.db', options: [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $user = $_POST['username'] ?? '';
    $pass = $_POST['password'] ?? '';

    // VULNERABLE: string-concatenated SQL -> SQL injection
    $row = $pdo->query(
        "SELECT * FROM users WHERE username = '$user'"
    )->fetch(PDO::FETCH_ASSOC);

    // VULNERABLE: plaintext comparison + user enumeration
    if (!$row) {
        exit('No such user');            // reveals the account does not exist
    }
    if ($row['password_hash'] === $pass) {
        // VULNERABLE: no session_regenerate_id() -> session fixation
        $_SESSION['uid'] = $row['id'];
        exit('Welcome ' . $row['username']);
    }
    exit('Wrong password');              // distinct message -> enumeration
}
?>
<form method="post">
  <input name="username"><input name="password" type="password">
  <button>Login</button>
</form>

Step 2 — Exploit it

User enumeration. Two different error strings leak which usernames exist:

curl -s -d 'username=alice&password=x'   http://127.0.0.1:8000/login_vuln.php  # "Wrong password"
curl -s -d 'username=bob&password=x'     http://127.0.0.1:8000/login_vuln.php  # "No such user"

SQL injection auth bypass. The seed above stores a hash, so plaintext compare never matches — but injection makes the query itself return whatever we want. Because the code compares password_hash === $pass, we inject a row whose hash column equals a value we control:

curl -s -d "username=alice' UNION SELECT 1,'alice','pwned',0,0-- -&password=pwned" \
     http://127.0.0.1:8000/login_vuln.php
# -> "Welcome alice"  (authenticated with no valid credential)

Session fixation. An attacker fixes a victim's session ID, waits for them to log in, then reuses it:

# Attacker sets a known ID, victim logs in, attacker is now authenticated as the victim.
curl -s -b 'PHPSESSID=attacker_fixed_id' -d 'username=alice&password=...' \
     http://127.0.0.1:8000/login_vuln.php

No throttling. Nothing stops thousands of guesses per second — pure online brute force.

Step 3 — Fix it

Replace with login_secure.php. Every flaw above is closed.

<?php
// login_secure.php
declare(strict_types=1);

session_start([
    'cookie_httponly' => true,
    'cookie_samesite' => 'Lax',
    'cookie_secure'   => true, // requires HTTPS in production
]);

const MAX_FAILS   = 5;
const LOCK_WINDOW = 900; // 15 minutes

$pdo = new PDO('sqlite:' . __DIR__ . '/app.db', options: [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

$error = null;

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $user = (string) ($_POST['username'] ?? '');
    $pass = (string) ($_POST['password'] ?? '');

    // Prepared statement — no injection possible.
    $stmt = $pdo->prepare('SELECT * FROM users WHERE username = ?');
    $stmt->execute([$user]);
    $row = $stmt->fetch();

    $now = time();

    // Throttle: reject while locked, regardless of correctness.
    if ($row && $row['locked_until'] > $now) {
        $error = 'Too many attempts. Try again later.';
    } else {
        // Constant-work verify. Run a dummy hash when the user is unknown
        // so response time does not leak account existence.
        $ok = $row
            ? password_verify($pass, $row['password_hash'])
            : password_verify($pass, '$2y$10$usesomesillystringforsalt0.no.match.here.padding0');

        if ($ok && $row) {
            // Kill session fixation: new ID on privilege change.
            session_regenerate_id(true);
            $_SESSION['uid'] = $row['id'];

            // Reset the failure counter.
            $pdo->prepare('UPDATE users SET fail_count = 0, locked_until = 0 WHERE id = ?')
                ->execute([$row['id']]);

            // Opportunistic rehash if the cost/algorithm has moved on.
            if (password_needs_rehash($row['password_hash'], PASSWORD_DEFAULT)) {
                $pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?')
                    ->execute([password_hash($pass, PASSWORD_DEFAULT), $row['id']]);
            }

            header('Location: /dashboard.php');
            exit;
        }

        // Failure path: increment counter and maybe lock.
        if ($row) {
            $fails = (int) $row['fail_count'] + 1;
            $lock  = $fails >= MAX_FAILS ? $now + LOCK_WINDOW : 0;
            $pdo->prepare('UPDATE users SET fail_count = ?, locked_until = ? WHERE id = ?')
                ->execute([$fails, $lock, $row['id']]);
        }
        // Generic message — same for wrong user and wrong password.
        $error = 'Invalid username or password.';
    }
}
?>
<!doctype html>
<form method="post">
  <?php if ($error !== null): ?>
    <p><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></p>
  <?php endif; ?>
  <input name="username" autocomplete="username">
  <input name="password" type="password" autocomplete="current-password">
  <button>Login</button>
</form>

Step 4 — Verify the fixes

# Injection now fails — the payload is a literal username.
curl -s -d "username=alice' UNION SELECT 1,'a','b',0,0-- -&password=pwned" \
     http://127.0.0.1:8000/login_secure.php        # -> Invalid username or password.

# Enumeration gone — identical message for bob and alice.
curl -s -d 'username=bob&password=x'   http://127.0.0.1:8000/login_secure.php
curl -s -d 'username=alice&password=x' http://127.0.0.1:8000/login_secure.php

# Sixth wrong guess locks alice for 15 minutes.
for i in $(seq 1 6); do
  curl -s -d 'username=alice&password=wrong' http://127.0.0.1:8000/login_secure.php >/dev/null
done
curl -s -d 'username=alice&password=S3cr3t-Pass!' http://127.0.0.1:8000/login_secure.php
# -> Too many attempts. Try again later.  (correct password still blocked while locked)

Exercises

  1. Prove regeneration. Log a successful login, capturing session_id() immediately before and after session_regenerate_id(true). Confirm the value changes and the old cookie is invalidated.
  2. Timing check. Time 100 requests for an existing vs. non-existing user with curl -w '%{time_total}\n'. Explain why the dummy password_verify() keeps the distributions overlapping.
  3. Rehash on upgrade. Re-seed alice with PASSWORD_BCRYPT and cost 4, then log in and confirm password_needs_rehash() upgrades her stored hash to the default.
  4. Constant-time compare. Add a "remember me" token stored server-side and compare the client cookie with hash_equals(). Explain why === would be unsafe here.

Challenge

The current per-account lockout lets an attacker deny service to a victim by deliberately failing five logins as them. Redesign the throttle so it resists both online guessing and this account-lockout denial-of-service. Combine at least two of: per-IP rate limiting, an exponential backoff delay, and a CAPTCHA / proof-of-work gate after N failures — without ever locking a legitimate user out purely because someone else attacked their username.


Solution

[!success]- Solution A robust design separates identity throttling from hard lockout. Track failures in two buckets: per source IP and per account, each with its own counter and sliding window (a small login_attempts table keyed by ip and username with timestamps, or a Redis token bucket — see Rate-Limiting). Apply an exponential backoff delay (e.g. sleep(min(2 ** $fails, 30)), or better, a stored next_allowed_at timestamp so you never hold a worker) rather than a flat block. After a threshold, require a CAPTCHA or proof-of-work instead of locking the account — this stops bots while a real user can still get in. Reserve hard account lockout for extreme cases and pair it with an out-of-band unlock (email link). Because the IP bucket absorbs distributed guessing and the account never fully locks from a third party's failures, you defeat guessing and the lockout-DoS. Always send the same generic message and comparable timing whether the block is IP- or account-driven, so the throttle itself does not become an enumeration oracle.


Security Discussion

Root cause. The vulnerable version trusted user input in SQL, compared secrets in plaintext, kept the pre-auth session ID after login, and imposed no cost on guessing. Each is a distinct control failure that composes into full account takeover.

Defence in depth. No single control is sufficient: prepared statements stop injection, password_hash() (bcrypt/Argon2 — slow and salted) makes stolen hashes expensive to crack, session_regenerate_id(true) plus HttpOnly/SameSite/Secure cookies protect the session, throttling raises the cost of online guessing, and generic messages with constant-work verification remove enumeration and timing oracles. Serve everything over HTTPS so credentials and cookies are never in cleartext.

Blue-team detection. Alert on spikes in failed logins per IP and per account, on many distinct usernames failing from one IP (spraying), and on successful logins immediately following a burst of failures. Log locked_until transitions and geovelocity anomalies. Feed failed-auth events to your SIEM and rate-limit at the edge (WAF / reverse proxy) as a second layer above the application counter.


References


Related