This lesson is different from all the previous ones: you won't learn a single new primitive, because you already know everything you need. What you're going to train is the reviewer's eye: the ability to open a file that isn't yours (or one of your own from two years ago) and have the cryptographic mistakes jump out at you before they ever reach production. We'll walk through an error gallery organized by family — each with its symptom in the code, why it's exploitable, its fix, and the lesson where you learned it — we'll finally gather every _BAD from the course into an index table (as promised in 05-03), we'll build MediNube's reviewer checklist, and you'll finish by playing auditor with three fragments of legacy code that combine several sins at once. Today's exercises are the audit.
Contents
- How to read code with a reviewer's eyes
- Family 1: inventing your own cryptography
- Family 2: broken randomness
- Family 3: misused symmetric encryption
- Family 4: hashes and passwords
- Family 5: misapplied asymmetric cryptography
- Family 6: TLS and certificates
- Family 7: secrets and tokens
- The index table: every
_BADin the course - MediNube's reviewer checklist
- Shared vocabulary: OWASP and CWE
How to read code with a reviewer's eyes
Three habits before the gallery. First: cryptographic mistakes almost never show symptoms — broken code encrypts, decrypts, and passes its tests; it only fails against an adversary, and the adversary isn't in CI. Second: mistakes come in clusters — where there's an md5, there's usually a random and a == nearby; finding one is the signal to stop and read the whole module. Third: check against module 1's nine golden rules — almost every mistake in this gallery is a broken rule, and citing them gives your review a criterion that doesn't sound like personal opinion.
Family 1: inventing your own cryptography
Rule broken: 1 (don't invent your own cryptography) and 2 (the enemy knows the system).
- Symptom in the code: loops that XOR data against a repeating key; "encryption" built from adding offsets; functions named
obfuscate/encodeused as if they protected something; comments like "nobody will figure out this is Base64 backwards."
def homemade_encrypt(data: bytes, key: bytes) -> bytes:
return bytes(d ^ key[i % len(key)] for i, d in enumerate(data)) # repeating-key XOR- Why it's exploitable: XOR with a repeating key is the Vigenère cipher in different clothes: frequency analysis and crib dragging break it with pen and paper. Obfuscation falls to a
stringscommand or a decompiler — Kerckhoffs (rule 1): assume the attacker has the code. - Fix: AEAD from pyca/cryptography (AES-256-GCM or ChaCha20-Poly1305, module 2). No exceptions, no "it's just for internal data."
- Where it was taught: all of module 1; the temptation reappeared in 02-01.
Family 2: broken randomness
Rule broken: 3 (all randomness comes from the CSPRNG) and 5 (entropy is king).
- Symptom:
import randomnear the words "token," "key," "nonce," or "reset";random.seed(...)with the time or user data; UUIDs (uuid1/uuid4) used as secrets; "randomness" derived fromtime.time()or the PID. - Why it's exploitable:
random's Mersenne Twister is predictable — 624 outputs reconstruct its entire internal state, and with a time-based seed you don't even need that: the attacker just tries the last minute's worth of seeds. We saw it withrecovery_token_BAD(01-03): MalloryClinic could regenerateana.perez's recovery token knowing roughly when the request happened. UUIDs don't promise unpredictability (uuid1 carries a MAC and a clock; uuid4 depends on the implementation): they're identifiers, not secrets. - Fix:
secrets.token_urlsafe(32)/secrets.token_bytes(32)/os.urandomfor anything that must be secret or unpredictable; 128 bits minimum, 256 standard. - Where it was taught: 01-03.
Family 3: misused symmetric encryption
Rule broken: 7 (identify the goal before the tool) — almost always by picking a mode without thinking about integrity.
| Symptom in the code | Why it's exploitable | Fix | Lesson |
|---|---|---|---|
modes.ECB(...) |
Equal blocks → equal ciphertext: 02-02's penguin; visible structure and repetition | AEAD (GCM/ChaCha20-Poly1305) | 02-02 |
Fixed nonce/IV (b"\x00"*12), a reset counter, or a nonce derived from the data itself |
Reusing a nonce with the same key in GCM/CTR breaks confidentiality for both messages and allows tag forgery | A random 12-byte nonce per operation, stored alongside the ciphertext (02-03's v1 format) |
02-02/02-03 |
| CBC (or CTR) "bare," with no MAC — encrypting without authenticating | Malleability: the attacker flips bits with a controlled effect; CBC padding oracles decrypt with no key at all by watching the server's errors | AEAD always; if something isn't AEAD, HMAC in encrypt-then-MAC — but by 2026, just use AEAD | 02-02/02-03 |
A key derived from a password with a plain hash (sha256(password)) |
Offline brute force at GPU speed | A cost-parameterized KDF: scrypt/Argon2 (02-04); for deriving subkeys from a strong key, HKDF | 02-04 |
| Empty AAD when there's context that should be pinned | A patient's ciphertext can be "transplanted" onto another (context confusion) | AAD with the context: paciente=...;formato=v1 |
02-03 |
The mental pattern for this family: confidentiality without integrity is a trap. If you see encryption with no authentication tag, you already have your finding.
Family 4: hashes and passwords
Rule broken: 6 (compare secrets in constant time) and 7.
- Symptoms and their reasons:
hashlib.md5/sha1for anything security-related: practical collisions for years now (03-01); in signatures and certificates, forgeable.- A fast hash for passwords (
sha256(password), salted or not): a GPU tries billions per second;save_password_BAD(03-03) fell in hours against a dictionary. - No salt (or a global salt): the same password produces the same hash → rainbow tables, and every user attacked at once.
- Non-constant-time comparison (
==,startswith) of hashes, tags, or API keys: response time leaks how many bytes matched —authenticate_BAD(01-04) andlogin_BAD(03-03). sha256(key + message)as a MAC: length extension, thanks to the Merkle–Damgård construction —tag_BAD(03-02): the attacker extends the message and computes a valid tag without ever knowing the key.
- Fixes: SHA-256/SHA-3/BLAKE2 for integrity; Argon2id with
PasswordHasher(plus an HMAC pepper from the manager, 06-01) for passwords;secrets.compare_digest(or the library'sverify) for comparisons; HMAC for message authentication. - Where it was taught: all of module 3.
Family 5: misapplied asymmetric cryptography
Rule broken: 1 and 7.
- RSA with no padding (textbook) or PKCS#1 v1.5 where it doesn't belong: plain RSA is deterministic —
guess_result_BAD(04-01) showed how the attacker encrypts the candidate answers ("POSITIVE"/"NEGATIVE") with the public key and compares; v1.5 in encryption drags along Bleichenbacher's oracles. Fix: OAEP for encryption, PSS for signing (04-01/04-03). - Short keys or outdated curves: RSA-1024, P-192 (
generate_doctor_keypair_BAD, 04-02). Fix: RSA-3072 minimum, or better yet Ed25519/X25519. - Unauthenticated DH/ECDH: an X25519 exchange with no verification of who you're exchanging with = an open invitation for MalloryClinic's MITM (04-04). Fix: authenticate the endpoints (signatures or certificates, the way TLS does).
- Signing without canonicalizing:
sign_prescription_BAD(04-03) signedjson.dumps(prescription)with no fixed key order or separators: the same content produces different bytes depending on the dictionary, and verification becomes a lottery (or worse: two "valid" representations of the same document). Fix: canonicalize before signing (sort_keys=True, fixed separators — thereceta-v1format). - Encrypting large data "directly with RSA": besides being impossible at that size, it's the symptom of not knowing the hybrid envelope. Fix: KEM/envelope — encrypt a symmetric key, not the data (04-05).
Family 6: TLS and certificates
Rule broken: 9 (cryptography doesn't replace the rest of security) — TLS is useless once you strip out the part that authenticates.
verify=False(orCURLOPT_SSL_VERIFYPEER = 0, or an emptyTrustManagerin Java): the classic of classics, ourcall_api_BAD/get_prescription_status_BAD(05-02). The channel stays encrypted with whoever — a trivial MITM. It's usually born as a temporary "fix" for anSSLErrorand fossilizes from there. Fix: fix the root cause (internal CA viaverify="/etc/medinube/pki/ca.crt", a full chain on the server), never turn off verification.- Ignoring expiration and revocation: certificates expiring on a Friday night, CRL/OCSP never checked. Fix: automatic renewal (ACME) + monitoring (
medinube/monitor_certs.py,DAYS_THRESHOLD=30) — 05-03. - Old versions and suites: TLS 1.0/1.1, RC4, 3DES, suites with no PFS. Fix: TLS 1.3, 1.2 at a minimum and only with ECDHE+AEAD (05-02).
- Broken pinning: pinning the leaf certificate "for extra security" and locking out your own users on the first rotation (worse with ACME's short lifetimes). If you do pin, pin your own CA's key, with a rotation plan; for almost everyone, CT + standard verification is enough.
Family 7: secrets and tokens
Rule broken: 2 and 8.
- Hardcoding secrets: 01-04's
API_KEYSand 05-03's committedprivkey.pem. Fix: secrets manager, gitleaks in CI, rotation on any leak (06-01). - Logging secrets:
logger.info(f"token={token}"), passwords in request logs, the full JWT in access logs. Logs live for years, get replicated, and lots of people read them. Fix: redact in the logger; logjti/sub, never the token. - JWT without validating
alg/exp/aud/iss: 06-03'sverify_BAD;alg: noneand the RS256→HS256 confusion get in through here. Fix: a closedalgorithms=[...],audience,issuer,require(06-03). - Tokens with eternal lifespans, or refresh tokens stored in the clear in the DB. Fix: short TTL + a rotating refresh token stored as
sha256(token)(06-03, the 03-03 pattern).
The index table: every _BAD in the course
The promise from 05-03, kept: the complete gallery of MediNube's legacy code, gathered together. Use it as a review index — if any "sin" isn't instantly obvious, that lesson is worth a reread:
| Function | Lesson | Sin | Fix |
|---|---|---|---|
create_session_BAD / read_session_BAD |
01-02 (settled in 06-03) | Base64 as if it were protection: an editable token | EdDSA JWT with claims and strict verification |
recovery_token_BAD |
01-03 | random with a time-based seed for a reset token |
secrets.token_urlsafe(32) + storing sha256(token) |
authenticate_BAD |
01-04 | == comparison of API keys (timing) + hardcoded API_KEYS |
secrets.compare_digest + secrets manager (06-01) |
send_webhook_BAD |
03-02 | "Integrity" via sha256(body) with no key: anyone can recompute it |
HMAC-SHA256 with a per-clinic key |
tag_BAD |
03-02 | sha256(key + message) as a MAC: length extension |
HMAC |
verify_BAD (webhooks) |
03-02 | The above + == comparison + no anti-replay timestamp |
HMAC + compare_digest + a 300s window |
save_password_BAD / login_BAD |
03-03 | Fast hash for passwords, non-constant-time comparison | Argon2id (PasswordHasher) + pepper (06-01) |
guess_result_BAD |
04-01 | RSA with no padding: deterministic ciphertext, guessable by dictionary | RSA-OAEP |
generate_doctor_keypair_BAD |
04-02 | P-192 curve: effective security below the minimum | Ed25519 (or P-256 if the ecosystem requires it) |
sign_prescription_BAD |
04-03 | Signing JSON without canonicalizing | receta-v1 canonicalization + Ed25519 |
call_api_BAD / get_prescription_status_BAD |
05-02 | verify=False: TLS with no authentication = MITM served on a plate |
Verification with the correct CA; fix the root cause |
verify_BAD (JWT) |
06-03 | decode with no closed algorithms, no aud/iss/exp |
Closed list + audience + issuer + require |
MediNube's reviewer checklist
The list MediNube attaches to every code review touching security. Grouped so it can be handed out piece by piece; every item is a yes/no question about the diff:
Randomness and secrets
- Does everything secret/unpredictable come from
secrets/os.urandom(neverrandom,uuid, the clock)? - Do new secrets come from the manager (nothing hardcoded, nothing in a committed
.env)? - Can no secret end up in logs, error messages, or URLs?
- Are one-time tokens stored hashed, and do they expire?
Encryption
- Is all symmetric encryption AEAD (GCM/ChaCha20-Poly1305) — no bare ECB/CBC/CTR?
- Is the nonce random, 12 bytes, unique per operation, and stored alongside the ciphertext?
- Does the AAD pin the context (patient id, format version)?
- Does the format carry a version byte (crypto-agility), and is there a key-rotation plan?
- If passwords feed key derivation: is there a cost-parameterized KDF (scrypt/Argon2), not a plain hash?
Hashes, MACs, and passwords
- No MD5/SHA-1 in security contexts?
- Passwords only through Argon2id (with a pepper from the manager)?
check_needs_rehashat login? - Message authentication via HMAC (never
sha256(key+msg)), compared withcompare_digest? - Do webhooks carry a timestamp, and get rejected outside the anti-replay window?
Asymmetric crypto and signatures
- RSA only with OAEP (encryption) / PSS (signatures), sizes ≥ 3072; or Ed25519/X25519 directly?
- Is everything that's signed canonicalized first? Are key exchanges authenticated?
TLS and certificates
- Zero
verify=False(or equivalents) anywhere in the diff, tests included? - Internal connections (DB included) with full verification (
sslmode=verify-full+ internal CA)? - Do new certificates get added to automatic renewal and the expiration monitor?
Tokens and sessions
- Does every
jwt.decodefixalgorithms(closed list),audience,issuer, andrequire? Shortexp? - Does the session token go in an
HttpOnly; Secure; SameSitecookie (notlocalStorage)?
Shared vocabulary: OWASP and CWE
When an external auditor reviews MediNube, they won't say "family 3": they'll say OWASP Top 10 A02 – Cryptographic Failures (the category that groups almost this entire lesson) and cite specific CWEs: CWE-327 (broken or risky algorithm), CWE-328 (weak hash), CWE-330 (insufficient randomness), CWE-259/798 (hardcoded credentials), CWE-295 (improper certificate validation), CWE-347 (improper signature verification). It's worth knowing these codes as shared vocabulary — mapping your findings to A02/CWE makes your reports legible to security and compliance teams. We won't go further here: the portal has a dedicated OWASP course where this framework gets worked through in depth.
Common Mistakes and Tips
- Mistake (the reviewer's): looking only for the specific bug. Cryptographic mistakes cluster; when you find one, review the whole module and that file's git history.
- Mistake: accepting "it's temporary" or "it's just internal."
call_api_BAD'sverify=Falsewas born as a temporary fix in 2023. Crypto patches fossilize: either they go in right, or they don't go in. - Mistake: flagging without a fix. A finding with no proposal ("use HMAC with the manager's key, see 03-02") tends to get closed as "won't fix." The index table gives you the canonical fix for every sin.
- Tip: automate what can be automated.
banditand linters catchmd5,randomin security contexts, andverify=False; gitleaks catches secrets. The human checklist is for what tools can't see (missing AAD, canonicalization, design). - Tip: review the tests too. That's where
verify=False, "test" keys that end up in production, and mocks that disable signature verification hide.
Exercises
The following three fragments are real "legacy code" from MediNube, rescued from old branches. Each one combines several mistakes from different families. Audit them: list each error, why it's exploitable, and its fix (cite the lesson or the checklist item). Count before checking the solutions: there are 4 to 6 per fragment.
- Exporting records for a clinic:
import random, hashlib
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
def export_records(records: bytes, password: str) -> bytes:
key = hashlib.md5(password.encode()).digest() # 16 bytes, "good enough"
iv = bytes([random.randint(0, 255) for _ in range(16)])
encryptor = Cipher(algorithms.AES(key), modes.CBC(iv)).encryptor()
padding = 16 - len(records) % 16
ct = encryptor.update(records + bytes([padding]) * padding) + encryptor.finalize()
return iv + ct- Lab-results webhook receiver:
import hashlib, logging
WEBHOOK_KEY = "shared-key-2023" # the same for every clinic
def receive_webhook(body: bytes, headers: dict) -> bool:
expected = hashlib.sha256(WEBHOOK_KEY.encode() + body).hexdigest()
logging.info(f"Webhook received, signature={headers['X-MediNube-Firma']}")
if headers["X-MediNube-Firma"] == expected:
process(body)
return True
return False- Robles Pharmacy's prescriptions-service client:
import requests, jwt
def get_prescription(prescription_id: str, token: str) -> dict:
data = jwt.decode(token, options={"verify_signature": False}) # "it's already from MediNube"
if data["rol"] != "farmacia":
raise PermissionError
r = requests.get(f"https://api.medinube.example/recetas/{prescription_id}",
headers={"Authorization": f"Bearer {token}"},
verify=False, timeout=5)
return r.json()Solutions
-
Five errors. (a)
md5(password)as a KDF: a broken hash and no cost parameter — offline brute force at GPU speed; fix: scrypt/Argon2 with a salt (02-04, checklist 9/10). (b)random.randintfor the IV: predictable (family 2); fix:os.urandom(16)— though fix (d) makes this one moot. (c) AES-128 by accident (the key is 16 bytes because MD5 produces 16): a size decision made by a hash, not by design. (d) CBC with manual padding and no authentication: malleable, and a padding-oracle candidate in whichever endpoint reports the error (family 3); fix: AES-256-GCM with AAD (clinica=...;formato=v1). (e) No format versioning: unrotatable (checklist 8). Correct version: scrypt(password, salt) → AESGCM, formatversion || salt || nonce || ct+tag— exactly 02-04's backup. Bonus: for an external recipient, the 04-05 hybrid envelope is better, with no shared password at all. -
Five errors. (a) A hardcoded key in the code (family 7 → the manager, 06-01). (b) The same key for every clinic: Clínica Sol can forge webhooks "from" Luna Medical Center; fix: a per-clinic key (03-02). (c)
sha256(key + body): length extension (family 4,tag_BAD); fix: HMAC-SHA256. (d)==comparison: timing (checklist 12); fix:hmac.compare_digest. (e) NoX-MediNube-Timestampand no anti-replay window: a captured webhook can be replayed indefinitely (03-02, checklist 13). And thelogging.infowith the full signature edges into family 7 too: valid signatures sitting in logs make replay easier if (e) is missing. -
Four errors (and a half). (a)
verify_signature: False: the token isn't verified at all — anyone can forge{"rol": "farmacia"}create_session_BAD-style and pass the check; fix: verify with MediNube's public key,algorithms=["EdDSA"],audience,issuer,require(06-03). (b) Even if it verified: with no check onexporaud, tokens would be eternal and reusable across services. (c)verify=Falseinrequests: a MITM over prescription data — and it also hands the Bearer token straight to the impersonator, giving away the credential (05-02 + family 7); fix: standard TLS verification (it's a public domain with a public CA; there isn't even an internal-CA excuse here). (d) Authorization based on a claim (rol) from an unverified token = a security decision made on client-supplied data, 01-02's original sin. The "half":r.json()with no status-code check — not cryptographic, but a thorough reviewer notes it too.
Conclusion
You now have what no manual hands you on its own: judgment. You know cryptographic mistakes don't announce themselves, that they cluster, and you have three tools to hunt them down: the seven families (with the exploitability reasoning behind each one), the index table of every _BAD MediNube has fixed throughout the course, and a twenty-item checklist ready to use on your next review. The gallery is closed: from 01-02's editable Base64 to 2023's verify=False, every piece of legacy code now has a name, a diagnosis, and a fix.
One last look remains, and it's not backward but forward: there's a reserved byte in MediNube's hybrid envelope (0x03, since 04-05) waiting for the cryptography that will withstand quantum computers. Exactly what a quantum computer threatens, what survives from our arsenal, what ML-KEM and ML-DSA are, and what MediNube must do today — that's the course's final lesson. See you in the future.
Applied Cryptography Course
Module 1: Cryptography Fundamentals
- What Cryptography Is and What It's For
- Encoding, Obfuscation, and Encryption
- Randomness and Entropy
- Kerckhoffs's Principle and the Golden Rules
Module 2: Symmetric Cryptography
- Symmetric Encryption: AES and ChaCha20
- Modes of Operation
- Authenticated Encryption (AEAD)
- Key Derivation (KDF)
Module 3: Hashes, MACs, and Passwords
Module 4: Asymmetric Cryptography
- Public-Key Fundamentals and RSA
- Elliptic Curve Cryptography
- Digital Signatures
- Key Exchange: Diffie-Hellman
- Hybrid Encryption
