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.
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
gitleaksin CI. - SAST rules: Semgrep and CodeQL ship rules for ECB, weak hashes,
Math.randomnear 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) |
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. AvoidSecureRandom.getInstanceStrong()in request paths: on Linux it can mean the blocking/dev/randomsource, and on older kernels it could stall. - Python:
secrets.token_urlsafe(32),secrets.token_bytes,uuid.uuid4()(usesos.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.