CyberNugget

L20 · Crypto mistakes in the wild

Module 3: Cryptography Essentials · ~20 min · Lesson 20 of 71

🎯 What you'll learn

  • Five crypto mistakes behind most real failures, each tied to the lesson (L14–L19) that prevents it
  • How to spot them in Java, Python and JavaScript, and what the fix looks like
  • How Adobe's 2013 leak let strangers read millions of passwords without the key

🔥 Why this matters

Strong algorithms almost never get broken in practice. What breaks is how people use them: the wrong mode, a key in the repo, a "random" number that isn't, a nonce used twice. Sony, Debian, Zoom, Uber and Adobe all lost this way, and each mistake below could ship in a pull request this week.

1. Homemade crypto and misused primitives

Zoom, April 2020. Zoom said meetings used "AES-256". Citizen Lab found each meeting used a single AES-128 key, in ECB mode, for all participants' audio and video (CVE-2020-11500). Its report: "Move Fast & Roll Your Own Crypto".

Homemade crypto means inventing your own algorithm or protocol, or, more often, wiring good primitives together yourself. AES in ECB mode leaks patterns (L15); AES without a MAC can be tampered with (L01, L17).

The Java trap: "AES" alone silently means ECB in the default SunJCE provider.

// ❌ means "AES/ECB/PKCS5Padding": equal input, equal output
Cipher c = Cipher.getInstance("AES");

// ✅ authenticated encryption, fresh 12-byte IV each time (L15)
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
c.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv));

Better: a library that makes these choices for you, like Google Tink or the AWS Encryption SDK with KMS (L19).

2. Hard-coded keys and secrets

Uber, 2016. According to the FTC's complaint, attackers found an AWS access key that an engineer had put in a private GitHub repository, then used it to download data on about 57 million riders and drivers from S3.

A hard-coded secret is a key, password or token written into code or committed config. It's copied to every clone and laptop, and stays in git history after you delete it. In honeypot studies by Orca Security (2023), Comparitech and Truffle Security, AWS keys pushed to public GitHub were tried by attackers within roughly one to five minutes (figures vary).

# ❌ application.yml, committed
crypto:
  aes-key: "q3Jt9...base64...=="
# ✅ only a reference; the pod's IAM role (IRSA) fetches it
crypto:
  key-arn: arn:aws:kms:us-east-1:111122223333:key/demo-1234

If a secret is ever committed, rotate it first, then clean history (L19).

3. Weak randomness

Debian, 2006–2008 (CVE-2008-0166). A cleanup patch to Debian's OpenSSL removed most of the random number generator's entropy, leaving the process ID as the main seed. Linux PIDs then went up to 32,768, so SSH and TLS keys generated with it on Debian and Ubuntu for about 20 months came from a tiny, precomputable set.

Android, August 2013. Badly initialized SecureRandom let thieves take bitcoin from wallet apps whose signatures repeated a "random" value (see mistake 4).

A CSPRNG (cryptographically secure pseudo-random number generator) produces output that can't be predicted from earlier outputs. Ordinary PRNGs are built for speed, not secrecy. V8's Math.random (xorshift128+) can be predicted from a handful of outputs.

Language ❌ Never for tokens, keys, IDs ✅ Use
Java java.util.Random, Math.random() SecureRandom, UUID.randomUUID()
Python random module secrets, os.urandom
JS / Node Math.random() crypto.getRandomValues, crypto.randomUUID(), crypto.randomBytes

4. Nonce reuse

Sony PS3, 2010. At the 27C3 conference, the fail0verflow team showed that Sony signed PS3 software with ECDSA but used the same "random" value k for every signature. With two signatures sharing k, simple algebra gives the private key.

A nonce ("number used once") must never repeat under the same key. In AES-GCM, the nonce picks the keystream, so reuse cancels it out:

 same key + same nonce  ──▶  same keystream K
  C1 = P1 ⊕ K
  C2 = P2 ⊕ K
  C1 ⊕ C2 = P1 ⊕ P2       ◀── key gone; know P1, read P2
  (GCM reuse also leaks the auth key: forgeries possible)

 ECDSA: same k in two signatures
  s1, s2, two hashes ──▶ solve for k ──▶ PRIVATE KEY

Fix: a fresh random 12-byte nonce for every GCM encryption, stored next to the ciphertext (it isn't secret). For ECDSA, use deterministic nonces (RFC 6979) or Ed25519 (L16).

5. Checking it wrong: timing leaks and alg: none

Comparing a webhook HMAC (L17) with sig === expected stops at the first different byte, so response times can hint at how many bytes matched: a timing attack. Use a constant-time compare: Node crypto.timingSafeEqual, Java MessageDigest.isEqual, Python hmac.compare_digest.

A cousin: letting the attacker pick the check. Some JWT libraries once accepted tokens with "alg": "none" (no signature). Pin the algorithm server-side; L25 covers JWTs.

🏢 Case study: Adobe (2013)

The setup

Each Adobe ID record held an ID, username, email, encrypted password and a plain-text password hint. The passwords used 3DES in ECB mode, all under one key: encryption, not hashing (L14, L18). Adobe later said it was "a backup system ... designated to be decommissioned", and that its main system used salted SHA-256 (better, but still a fast hash; L18 says bcrypt or Argon2).

Timeline

 2013 mid-Aug ── intrusion begins (Adobe's later estimate)
      late Sep ─ Krebs and Alex Holden find Adobe source code
      │          on a server used by the attackers
      Oct 3  ─── Adobe discloses: ~2.9M customers' encrypted
      │          card data; Acrobat, ColdFusion source code
      late Oct ─ users.tar.gz (~3.8 GB, 150M+ records) posted
      Oct 29 ─── Adobe: ~38M ACTIVE users' passwords affected
      Nov ────── Sophos analysis; xkcd "Encryptic" crossword
      │          HIBP loads ~153M records (152,445,165)
 2016 Nov ────── $1M settlement with 15 US states

How attackers got in was never fully made public; the dump is the famous part.

Reading passwords without the key

3DES works on 8-byte blocks, each encrypted alone in ECB. Equal passwords give equal ciphertexts; passwords sharing their first 8 characters share a first block:

 password   hint (plain text!)        ciphertext (8-byte blocks)
 ?          "1 to 6"                  f9e029a3305da543
 ?          "123456"                  f9e029a3305da543
 ?          "the usual"               6b0fa8a19e7a3f01 2e24eeb8...
 ?          "password + 1"            6b0fa8a19e7a3f01 2c4fc056...
            │                         │
            ▼                         ▼
   pool every hint for one       group identical blocks,
   ciphertext ──▶ guess it        count, sort by frequency

(Ciphertexts from the Try it below, made-up key.) Sort by frequency, read the hints, and the top passwords fall out: Sophos found "123456" on roughly 1.9 million accounts, and xkcd #1286 drew it as a crossword.

What went wrong

Failure Lesson
Passwords encrypted (reversible) instead of hashed L14, L18
ECB mode: identical inputs, identical outputs L15
No per-user salt, so one guess unlocks every match L18
Plain-text hints, often containing the password L01 confidentiality
One key for 150M+ passwords; whoever holds it reads everything L19
A "to be decommissioned" system still holding data L03 attack surface

What would have stopped it

  • A slow, salted password hash (bcrypt, scrypt, Argon2): no key to steal, no matching outputs, and each guess costs real time.
  • Deleting the legacy store once the new system existed.
  • No plaintext hints, or no hints at all.

🧪 Worked example: a password-reset token service

A made-up Spring service issues reset links for example.com:

// ❌ three mistakes from this lesson
private static final String HMAC_KEY = "s3cr3t-demo";    // #2
String token = Long.toHexString(new Random().nextLong()); // #3
boolean ok = sig.equals(request.getHeader("X-Sig"));       // #5

java.util.Random has 48 bits of state; from one or two outputs an attacker can predict the rest, so requesting their own reset lets them guess someone else's.

// ✅ fixed
private final byte[] hmacKey = secrets.get("reset-hmac"); // L19
private static final SecureRandom RNG = new SecureRandom();

byte[] raw = new byte[32];                  // 256 bits
RNG.nextBytes(raw);
String token = Base64.getUrlEncoder()
    .withoutPadding().encodeToString(raw);

String header = request.getHeader("X-Sig");
boolean ok = header != null && MessageDigest.isEqual(
    sig.getBytes(UTF_8), header.getBytes(UTF_8)); // const time

In MongoDB, store only the token's hash, with an expiry.

🛠 Try it (optional, ~5 min)

1. Seeded "random" is a replay. Save as tok.py and run python3 tok.py:

import random, secrets, string
ABC = string.ascii_letters + string.digits
def weak_token(seed):
    rng = random.Random(seed)      # seeded from a timestamp
    return "".join(rng.choice(ABC) for _ in range(22))
print("weak #1 :", weak_token(1762430400))
print("weak #2 :", weak_token(1762430400))
print("secrets :", secrets.token_urlsafe(16))
print("secrets :", secrets.token_urlsafe(16))
weak #1 : nOUQJaDdQb2qUQOpDFoc4q
weak #2 : nOUQJaDdQb2qUQOpDFoc4q
secrets : Fh1v6ozSvaE_PA-bNlx5ig
secrets : 52dozpXF9U331BkS0goBlg

The weak lines match on any machine using that seed (Python 3.15 here): guess the second it was issued and you have the token. The secrets lines differ every run.

2. Be Adobe for a minute. Encrypt passwords with 3DES-ECB and a throwaway key:

KEY=0123456789abcdeffedcba98765432100123456789abcdef
for pw in 123456 password password1 123456; do
  ct=$(printf '%s' "$pw" \
    | openssl enc -des-ede3 -K "$KEY" -nosalt \
    | xxd -p | tr -d '\n' | sed 's/.\{16\}/& /g')
  printf '%-10s %s\n' "$pw" "$ct"
done
123456     f9e029a3305da543
password   6b0fa8a19e7a3f01 2e24eeb85aef49ae
password1  6b0fa8a19e7a3f01 2c4fc05661f171d9
123456     f9e029a3305da543

-des-ede3 is 3DES in ECB mode; output was identical with Homebrew OpenSSL 4.1 and macOS's /usr/bin/openssl (LibreSSL 3.3.6). Look for the repeated 123456 and the shared first block of password and password1. (8-byte password takes two blocks: padding adds a full one.)

🧠 Key takeaways

  • Algorithms rarely fail; usage does. Prefer high-level libraries and KMS to raw primitives.
  • A key in git is a leaked key: rotate first, then clean up.
  • Anything that must be unguessable comes from a CSPRNG, never Math.random.
  • Nonces never repeat under one key; in GCM and ECDSA reuse is catastrophic.
  • Passwords are hashed slowly with a salt, never encrypted. Adobe is the proof.

📖 Words to know

Term Meaning
Homemade crypto Your own algorithm or protocol, or primitives combined by hand, instead of a vetted construction
ECB mode Block cipher mode that encrypts each block independently, so equal blocks give equal ciphertext
Hard-coded secret A key, password or token written into code or committed config
PRNG / CSPRNG Pseudo-random number generator; the CS version is unpredictable even to someone who saw earlier output
Nonce A value that must be used only once per key (IVs in GCM, k in ECDSA)
Timing attack Learning a secret from how long a comparison or operation takes
Constant-time compare A comparison that takes the same time however many bytes match

⏭ Next time

Module 4, Identity and Access, starts with L21: authentication vs authorization, and MFA, from phishing-resistant passkeys to MFA fatigue attacks.

Deep dive 1

A crypto code review checklist for your team

Why a checklist

Crypto bugs look like normal code. Cipher.getInstance("AES") compiles, the tests pass, and the data round-trips. Nothing fails until an attacker shows up. A short checklist turns review from "looks fine to me" into specific questions that catch the mistakes from this lesson.

First question: should this code do crypto at all?

 need to protect data?
   │
   ├─ in transit ──────────▶ TLS / mTLS (L10, L11). Done.
   │
   ├─ passwords ───────────▶ Argon2id / bcrypt (L18).
   │                          Never encrypt them.
   ├─ at rest in AWS ──────▶ KMS-backed encryption on the
   │                          service (S3, EBS, Mongo Atlas)
   │
   ├─ prove it came from us ▶ HMAC (shared key) or a
   │                          signature (L17)
   │
   └─ app-level field ─────▶ Tink / AWS Encryption SDK with
      encryption              envelope encryption (L19)

 Writing AES/RSA calls by hand is the last resort.

Most teams on a stack like the one in these examples need no raw crypto calls at all. When review finds one, ask why a higher-level option isn't used.

The checklist

# Check Red flag in the diff Lesson
1 Vetted construction, not hand-built "AES", /ECB/, /CBC/ without a MAC, XOR loops, base64 "encryption" L15, L17
2 Modern algorithms MD5, SHA-1, DES, 3DES, RC4, RSA < 2048 L14–L16
3 No secrets in code or config String literals named key, secret, password, token; .pem files L19
4 Secure randomness for anything secret Math.random, new Random(, import random near tokens or IDs L20
5 Unique nonce per encryption IV as a constant, a field, or loaded from config L15
6 Passwords hashed slowly with salt sha256(password), encrypt(password) L18
7 Constant-time compare for MACs and tokens ==, .equals, === on signatures L17
8 Algorithm fixed by the server Reading alg from the token or request L17, L25
9 Certificate checks left on verify=False, rejectUnauthorized: false, trust-all TrustManager L11
10 Keys rotatable No key ID stored with ciphertext, so old data can't be re-keyed L19

🧪 Worked example: review comments on a PR

A made-up Python PR adds field encryption for account numbers in an Airflow task:

from Crypto.Cipher import AES        # pycryptodome
from Crypto.Util.Padding import pad
KEY = b"0123456789abcdef"                  # (a)
IV  = b"\x00" * 16                         # (b)

def enc(acct: str) -> bytes:
    c = AES.new(KEY, AES.MODE_CBC, IV)     # (c)
    return c.encrypt(pad(acct.encode(), 16))

Review comments:

  • (a) #3: key in source. Fetch a data key from KMS or Secrets Manager; rotate this one.
  • (b) #5: constant IV. With CBC, equal account numbers now encrypt identically, so the table leaks which customers share an account.
  • (c) #1: CBC with no MAC can be tampered with (L01). Use AES-GCM, or better, the AWS Encryption SDK, which handles keys, nonces and key IDs for you (#10).

Automate the easy part

Let tools catch the literal patterns so humans can focus on design:

  • Secret scanning: GitHub secret scanning with push protection, or gitleaks in CI.
  • SAST rules: Semgrep and CodeQL ship rules for ECB, weak hashes, Math.random near security code, and disabled TLS checks (L54 covers SAST).

A quick grep gets you a first pass over a repo you own:

PAT='getInstance\("AES"\)|/ECB/|MODE_ECB|Math\.random'
PAT="$PAT"'|new Random\(|verify=False|rejectUnauthorized: *false'
grep -rnE "$PAT" --include='*.java' --include='*.py' \
  --include='*.js' --include='*.ts' .
# ./src/A.java:1:Cipher c = Cipher.getInstance("AES");

Each hit isn't a bug (Math.random for UI jitter is fine), but each one deserves a look.

🛠 Try it (~5 min, your own repo)

Run the grep above on a service you own. For every hit, answer: is the value secret or security-relevant? If yes, which checklist line does it fail?

🧠 Extra takeaways

  • First ask whether the code should do crypto at all; TLS, KMS and libraries cover most needs.
  • Ten specific questions beat "LGTM" for crypto code.
  • Let secret scanning and SAST catch literal patterns; reviewers focus on design.
Term Meaning
Push protection GitHub feature that blocks a push containing a detected secret
Semgrep / CodeQL Static analysis tools that match code patterns, including crypto misuse rules
Envelope encryption Encrypting data with a data key, and the data key with a KMS master key (L19)
Deep dive 2

Randomness: where secure random numbers come from

Computers are deterministic

A CPU running the same code on the same input gives the same answer. So "random" numbers come from two parts: entropy, real unpredictability collected from the physical world, and a generator that stretches a small amount of it into as many bytes as you need.

 ENTROPY SOURCES               KERNEL                 YOUR CODE
 interrupt timing ─┐
 CPU jitter ───────┤      ┌───────────────────┐
 RDRAND/RDSEED ────┼─────▶│ entropy pool      │
 (CPU instruction) │ mix  │      │            │
 virtio-rng (VMs) ─┘      │      ▼ seed       │
                          │ CSPRNG (ChaCha20  │──▶ getrandom()
                          │  on modern Linux) │    /dev/urandom
                          └───────────────────┘    │
                                                   ▼
              SecureRandom / secrets / crypto.randomBytes

On Linux, the kernel collects entropy from hardware events and seeds a CSPRNG. Programs ask for bytes through the getrandom() system call or /dev/urandom. macOS has a similar design behind getentropy() and /dev/urandom. Your language's secure API is a thin wrapper over this.

PRNG vs CSPRNG: what "predictable" means

A normal PRNG has an internal state; each output is computed from it. If outputs reveal the state, the future is known. Python's random uses the Mersenne Twister, whose 624-word state can be rebuilt from 624 consecutive 32-bit outputs. Java's java.util.Random has just 48 bits of state, and one or two outputs are enough to recover it.

A CSPRNG is designed so that recovering its state from outputs is computationally infeasible, even after billions of outputs.

🧪 Worked example: cloning Python's random

This runs locally and predicts a generator that was seeded properly by the OS. Good seeding doesn't help: the algorithm itself leaks.

# predict_mt.py: clone Python's random from 624 outputs
import random

def untemper(y):
    y ^= y >> 18
    y ^= (y << 15) & 0xEFC60000
    for _ in range(7):
        y = (y ^ ((y << 7) & 0x9D2C5680)) & 0xFFFFFFFF
    for _ in range(3):
        y ^= y >> 11
    return y

victim = random.Random()            # seeded by the OS: fine
seen = [victim.getrandbits(32) for _ in range(624)]

state = [untemper(v) for v in seen]
clone = random.Random()
clone.setstate((3, tuple(state + [624]), None))

nxt = [victim.getrandbits(32) for _ in range(3)]
guess = [clone.getrandbits(32) for _ in range(3)]
print("victim next:", nxt)
print("clone guess:", guess)
print("match:", nxt == guess)
$ python3 predict_mt.py
victim next: [2766576349, 3258649902, 2299053241]
clone guess: [2766576349, 3258649902, 2299053241]
match: True

untemper reverses the scrambling step applied to each output. An API that hands out random-based IDs gives an attacker everything needed after a few hundred requests.

Where it goes wrong in practice

  • Bad seeding: Debian 2008 had a good generator with almost no entropy fed in.
  • Bad initialization: Android 2013. Google's advisory said apps using the Java crypto APIs might not get strong values because the PRNG wasn't initialized properly.
  • Early boot: a fresh VM or container host may have little entropy at first boot. Modern Linux getrandom() blocks until the pool is initialized, then never again.
  • Forks: a process that copies a generator's state into child processes can give each child the same "random" stream unless the library reseeds after fork.

Practical rules for a typical stack

  • Java: new SecureRandom() and reuse the instance. UUID.randomUUID() uses it too. Avoid SecureRandom.getInstanceStrong() in request paths: on Linux it can mean the blocking /dev/random source, and on older kernels it could stall.
  • Python: secrets.token_urlsafe(32), secrets.token_bytes, uuid.uuid4() (uses os.urandom).
  • Node: crypto.randomBytes, crypto.randomUUID(); browsers: crypto.getRandomValues.
  • UUIDs as secrets: a v4 UUID carries 122 random bits, fine as an unguessable ID. A v1 or v7 UUID includes a timestamp and fewer random bits: good for sorting, not for secrets.
  • Size: 128 bits (16 bytes) is plenty for tokens; 256 bits for keys.

🛠 Try it (~3 min, local)

Run predict_mt.py above, then swap random.Random() for random.SystemRandom() (an OS-backed generator). setstate now raises an error: there's no state in your process to clone.

🧠 Extra takeaways

  • Secure randomness = real entropy + a CSPRNG; the kernel provides both.
  • Ordinary PRNGs leak their state through their outputs, however well they're seeded.
  • Use your language's secure API and let it call the OS; don't seed anything yourself.
Term Meaning
Entropy Real unpredictability gathered from physical events
getrandom() Linux system call returning bytes from the kernel CSPRNG
Mersenne Twister Python's default PRNG; fast, statistically good, fully predictable
RDRAND An x86 CPU instruction that supplies hardware random numbers
UUID v4 / v7 Random UUID (122 random bits) vs time-ordered UUID (timestamp + fewer random bits)

The quiz for this lesson and the Tests section need JavaScript.