Reset Vault

Points: 300 points · Provided: reset-vault-src.zip

The Vault is a note-taking app for people who don't trust their memory — or, judging by the changelog, their password. Register an account, jot down a few notes, encrypt the ones that matter. The admin has one note they'd rather nobody else read.

Hint 1

Follow lib/tokenGen.js to every place it's used. What is the seed, and how random is it really?

Hint 2

A MongoDB ObjectId is a timestamp, a per-process value and a counter. Create a note and read its _id: what does it tell you about every other ID the server makes?

Application map

The downloadable archive contains a small Node.js/Express application backed by MongoDB. A
productive source review starts by tracing security-sensitive values from creation to use
instead of reading every route linearly.

Component Relevant behavior Security consequence
lib/tokenGen.js Seeds seedrandom with ObjectId.toString() Output is deterministic for a guessable seed
Authentication routes Generate session tokens from fresh ObjectIds Sessions share the same weak generator
Password-reset routes Generate a 20-hex reset token with a 45-second lifetime A nearby ObjectId predicts an admin reset token
Notes routes Derive a 32-byte AES key from the note's own _id Anyone who guesses the ID can derive the encryption key
GET /notes/:id Returns encrypted notes without authentication Candidate IDs can be tested remotely and ciphertext is exposed

The individual decisions look plausible in isolation: ObjectIds look random, AES-256-GCM is a
modern authenticated cipher, and reset tokens expire quickly. The vulnerability appears when
the same deterministic helper connects all three features.

What it targets

A structured identifier is not a source of randomness. The whole app hangs off one
helper, lib/tokenGen.js, that seeds a PRNG with a MongoDB ObjectId and reuses it for
three security-sensitive values: session cookies, password-reset tokens, and per-note
AES-256 keys. An ObjectId is only 12 bytes and mostly predictable, so every one of those
"random" values is recoverable by an attacker who can observe a single ObjectId the server
produced.

Skill Why it matters outside the CTF
Decomposing a MongoDB ObjectId (timestamp / per-process random / counter) Recognising a whole class of ID-guessing bugs
Spotting a non-crypto PRNG standing in for crypto.randomBytes The real-world root cause here
Reimplementing seedrandom to reproduce server output Turns "looks random" into "fully predictable"
Reasoning about an entropy budget (bounded brute-force) Knowing when a search is feasible vs hopeless

No SQL/NoSQL injection is involved. The lesson: "it came out of a random-looking function"
says nothing about whether it's guessable.


Background: why a MongoDB ObjectId is not random

A 12-byte ObjectId (24 hex chars) is:

Bytes Field Property
0–3 Unix timestamp (seconds) Known to ~the second (leaks in HTTP Date, createdAt, etc.)
4–8 Per-process "random" value Generated once at driver startup, then constant for the whole server process
9–11 Counter Starts at a random 24-bit value, increments by 1 for every ObjectId() the process creates

The killer property is bytes 4–8: they are the same for every ObjectId the running
server makes. So if the attacker can see one real ObjectId, they know those 5 bytes for
all the others. Only the timestamp (constrained to a known second) and the counter
(constrained to a small monotonic range) vary — a tiny search space.

The bug (conceptual)

// lib/tokenGen.js  — one helper, reused everywhere
function generate(seed, byteLength) {
  const rng = seedrandom(seed.toString());          // seed = an ObjectId's 24-hex string
  let out = '';
  for (let i = 0; i < byteLength * 2; i++) out += Math.floor(rng() * 16).toString(16);
  return out;                                        // deterministic given the seed
}

Used as:

require("seedrandom") defaults to the ARC4-based generator (not alea). The solver ports
that exact algorithm to Python so it reproduces the server's output byte-for-byte.


The leak vector

GET /notes returns the logged-in user's own notes including their _ids. So any player
can, from their own account, read a genuine ObjectId minted by the server process. Decoding
it hands over the fixed random bytes (4–8) plus a live (timestamp, counter) anchor. That is
all the calibration needed to predict other ObjectIds — and therefore other tokens/keys.

Suppose a note ID is 6a9b45b404e4878d0ec37b9f. Splitting it produces:

timestamp = 6a9b45b4
process   = 04e4878d0e
counter   = 0ec37b9f

The process component is now known exactly. The timestamp is constrained by the server's HTTP
Date header or a visible createdAt value, and the counter changes monotonically. With a
±60-second timestamp window and counters from 50 behind to 20 ahead, the search is only
121 × 71 = 8,591 candidates—not remotely close to the apparent 96-bit ObjectId space.

Candidate generation is straightforward:

def candidates(anchor_ts, process_hex, anchor_counter):
    for delta_t in range(-60, 61):
        for delta_c in range(-50, 21):
            timestamp = anchor_ts + delta_t
            counter = (anchor_counter + delta_c) & 0xFFFFFF
            yield f"{timestamp:08x}{process_hex}{counter:06x}"

The final requirement is reproducing the JavaScript generator precisely. The default
seedrandom algorithm is ARC4-based; substituting Python's random module or a different
JavaScript PRNG will never produce matching tokens. A correct port must reproduce its key
scheduling, RC4-drop behavior, floating-point output, and per-nibble Math.floor(rng() * 16).

Here is a Python port of seedrandom's default generator plus the app's generate() helper.
It was checked against the npm seedrandom package: for the seed
6a9b45b404e4878d0ec37b9f, both return a79207020b394b19eee8 for generate(seed, 10).

WIDTH, CHUNKS, MASK = 256, 6, 255
STARTDENOM, SIGNIFICANCE, OVERFLOW = 2**48, 2**52, 2**53

class ARC4:
    def __init__(self, key):
        if not key:
            key = [0]
        s, j = list(range(WIDTH)), 0
        for i in range(WIDTH):
            j = MASK & (j + key[i % len(key)] + s[i])
            s[i], s[j] = s[j], s[i]
        self.s, self.i, self.j = s, 0, 0
        self.g(WIDTH)                           # RC4-drop[256]

    def g(self, count):
        s, i, j, r = self.s, self.i, self.j, 0
        for _ in range(count):
            i = MASK & (i + 1)
            t = s[i]
            j = MASK & (j + t)
            s[i], s[j] = s[j], t
            r = r * WIDTH + s[MASK & (s[i] + s[j])]
        self.i, self.j = i, j
        return r

def seedrandom(seed):
    key, smear = [0] * WIDTH, 0
    for j, ch in enumerate(seed):               # seedrandom's mixkey()
        smear ^= key[MASK & j] * 19
        key[MASK & j] = MASK & (smear + ord(ch))
    arc4 = ARC4(key[:min(len(seed), WIDTH)])

    def prng():
        n, d, x = arc4.g(CHUNKS), STARTDENOM, 0
        while n < SIGNIFICANCE:
            n, d, x = (n + x) * WIDTH, d * WIDTH, arc4.g(1)
        while n >= OVERFLOW:
            n, d, x = n // 2, d // 2, x >> 1
        return (n + x) / d
    return prng

def generate(seed, byte_length):                # lib/tokenGen.js
    rng = seedrandom(seed)
    return "".join("%x" % int(rng() * 16) for _ in range(byte_length * 2))

Walkthrough

Two routes reach the flag from the same insight. Both start with the same calibration.

0. Calibrate off your own account

register → login → POST /notes (create any note) → GET /notes

Read your note's _id, e.g. 6a9b45b404e4878d0ec37b9f, and split it:

6a9b45b4  04e4878d0e  0ec37b9f
--------  ----------  --------
timestamp  random(5)   counter      ← random(5) is fixed for the whole server process

Path A — predict the admin's reset token (account takeover)

  1. POST /forgot-password {username:"admin"}. The server makes seed = new ObjectId() and
    stores generate(seed,10) with a 45-second expiry. The HTTP Date response header
    gives the server's timestamp to the second.
  2. Reconstruct that seed: timestamp = Date header (±1–2 s for rounding), random bytes =
    the ones you leaked, counter = a small window around your own note's counter (the reset
    ObjectId was created moments after yours, so its counter is close).
  3. For each candidate ObjectId, compute generate(candidate,10) locally and POST it to
    /reset-password for admin — within the 45-second window, so it must be scripted.
  4. One candidate matches → admin password is reset → log in as admin → read the flag note
    (the server decrypts owned notes on GET /notes).

An automated implementation can distribute candidate reset attempts across a small worker
pool. The time limit changes the engineering requirement, but it does not add entropy to the
token.

Path B — decrypt the flag note directly (no login, no reset)

  1. The flag lives in an encrypted note owned by admin, created at server boot. Encrypted
    notes are readable by anyone via GET /notes/:id (it returns ciphertext/iv/authTag
    with no auth — the app's flawed reasoning being "it's already encrypted").
  2. Guess the flag note's _id. It was one of the first ObjectIds the process created, so
    its counter is a little below your own note's counter (the counter starts at a random
    value, not at 0, and only counts up), and its timestamp is around when the server started.
    The process bytes are the ones you already know. Sweep that (timestamp × counter) space
    against GET /notes/:id until a real encrypted note comes back. The server's uptime decides
    how far back the timestamp sweep has to reach.
  3. That note's _id is its AES key seed: key = generate(_id, 32). Derive it locally and
    AES-256-GCM-decrypt the returned ciphertext → flag. No admin session ever touched.

Once a candidate endpoint returns an encrypted note, derive 32 bytes from that exact ID and
perform AES-GCM decryption using the returned IV and authentication tag. A successful tag check
also confirms that both the candidate ID and derived key are correct.

Both paths can be scripted from the same calibration account with the candidates()
generator and the generate() port above. Path A demonstrates account
takeover; Path B is cleaner because it proves that authorization and key management are both
broken without changing the administrator's password.


Why the two pieces matter together

Root cause (for the fix)

Use crypto.randomBytes() for anything security-sensitive (session tokens, reset tokens,
encryption keys). Never seed a PRNG with an ObjectId (or any timestamp/counter-derived
value), and never store an encryption key's seed next to its ciphertext.

A complete remediation also separates the three trust domains:

  1. Generate session and reset tokens independently with a cryptographically secure random
    source, store only hashed reset tokens, and make sessions httpOnly, secure, and bounded
    by server-side expiry.
  2. Enforce ownership before returning either plaintext or ciphertext. Encryption does not
    replace authorization.
  3. Generate note-encryption keys independently of document identifiers and protect them with a
    server-side key-encryption key or a dedicated secrets-management service.
  4. Rate-limit reset and object-lookup endpoints and avoid responses that act as high-speed
    candidate oracles.
  5. Add tests that verify tokens differ across identical application inputs and that anonymous
    users cannot distinguish valid note IDs.

Further reading

Key lessons

Show flag

Securinets{0bj3ct1d_1s_n0t_4_r4nd0m_s33d}