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:
- Session cookie:
generate(new ObjectId(), 16)→ 32-hex, stored insessions, set as a
cookie withhttpOnly:false(readable in the browser). - Reset token:
generate(new ObjectId(), 10)→ 20-hex, 45-second expiry. - Note AES-256 key:
generate(note._id, 32)→ 64-hex — the note's own_idis the key
seed, and that_idis stored right next to the ciphertext.
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.
Turning the leak into a bounded search
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 HTTPDate 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 only121 × 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 defaultseedrandom 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 seed6a9b45b404e4878d0ec37b9f, 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)
POST /forgot-password {username:"admin"}. The server makesseed = new ObjectId()and
storesgenerate(seed,10)with a 45-second expiry. The HTTPDateresponse header
gives the server's timestamp to the second.- Reconstruct that
seed: timestamp =Dateheader (±1–2 s for rounding), random bytes =
the ones you leaked, counter = a small window around your own note's counter (the resetObjectIdwas created moments after yours, so its counter is close). - For each candidate
ObjectId, computegenerate(candidate,10)locally and POST it to/reset-passwordforadmin— within the 45-second window, so it must be scripted. - One candidate matches → admin password is reset → log in as admin → read the flag note
(the server decrypts owned notes onGET /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)
- The flag lives in an encrypted note owned by
admin, created at server boot. Encrypted
notes are readable by anyone viaGET /notes/:id(it returnsciphertext/iv/authTag
with no auth — the app's flawed reasoning being "it's already encrypted"). - Guess the flag note's
_id. It was one of the firstObjectIds 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
againstGET /notes/:iduntil a real encrypted note comes back. The server's uptime decides
how far back the timestamp sweep has to reach. - That note's
_idis 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
- AES-256-GCM itself is not broken — the cipher is correct. The break is one level up: the
key is a deterministic function of a guessable 12-byte value that sits in plain sight next to
the ciphertext. - The
httpOnly:falsesession cookie is the self-referential tell that nudges players to
realise these tokens all come from one weak generator, before they even look at/notes. - The 45-second reset expiry is what forces Path A to be automated end-to-end rather than
hand-run with curl.
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:
- Generate session and reset tokens independently with a cryptographically secure random
source, store only hashed reset tokens, and make sessionshttpOnly,secure, and bounded
by server-side expiry. - Enforce ownership before returning either plaintext or ciphertext. Encryption does not
replace authorization. - Generate note-encryption keys independently of document identifiers and protect them with a
server-side key-encryption key or a dedicated secrets-management service. - Rate-limit reset and object-lookup endpoints and avoid responses that act as high-speed
candidate oracles. - Add tests that verify tokens differ across identical application inputs and that anonymous
users cannot distinguish valid note IDs.
Further reading
- MongoDB
ObjectIdreference seedrandomsource, including the ARC4 default- Node.js
crypto.randomBytes()
Key lessons
- Identifier structure is not secret entropy.
- Strong cryptography cannot compensate for attacker-predictable keys.
- Short expiration windows only help when tokens are genuinely unpredictable and endpoints are
rate-limited. - Security reviews should trace shared helpers across features; reuse is often where seemingly
independent weaknesses combine into a complete exploit chain.
Show flag
Securinets{0bj3ct1d_1s_n0t_4_r4nd0m_s33d}