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

  1. How to read code with a reviewer's eyes
  2. Family 1: inventing your own cryptography
  3. Family 2: broken randomness
  4. Family 3: misused symmetric encryption
  5. Family 4: hashes and passwords
  6. Family 5: misapplied asymmetric cryptography
  7. Family 6: TLS and certificates
  8. Family 7: secrets and tokens
  9. The index table: every _BAD in the course
  10. MediNube's reviewer checklist
  11. 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/encode used 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 strings command 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 random near the words "token," "key," "nonce," or "reset"; random.seed(...) with the time or user data; UUIDs (uuid1/uuid4) used as secrets; "randomness" derived from time.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 with recovery_token_BAD (01-03): MalloryClinic could regenerate ana.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.urandom for 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 / sha1 for 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) and login_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's verify) 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) signed json.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 — the receta-v1 format).
  • 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 (or CURLOPT_SSL_VERIFYPEER = 0, or an empty TrustManager in Java): the classic of classics, our call_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 an SSLError and fossilizes from there. Fix: fix the root cause (internal CA via verify="/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_KEYS and 05-03's committed privkey.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; log jti/sub, never the token.
  • JWT without validating alg/exp/aud/iss: 06-03's verify_BAD; alg: none and the RS256→HS256 confusion get in through here. Fix: a closed algorithms=[...], 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

  1. Does everything secret/unpredictable come from secrets/os.urandom (never random, uuid, the clock)?
  2. Do new secrets come from the manager (nothing hardcoded, nothing in a committed .env)?
  3. Can no secret end up in logs, error messages, or URLs?
  4. Are one-time tokens stored hashed, and do they expire?

Encryption

  1. Is all symmetric encryption AEAD (GCM/ChaCha20-Poly1305) — no bare ECB/CBC/CTR?
  2. Is the nonce random, 12 bytes, unique per operation, and stored alongside the ciphertext?
  3. Does the AAD pin the context (patient id, format version)?
  4. Does the format carry a version byte (crypto-agility), and is there a key-rotation plan?
  5. If passwords feed key derivation: is there a cost-parameterized KDF (scrypt/Argon2), not a plain hash?

Hashes, MACs, and passwords

  1. No MD5/SHA-1 in security contexts?
  2. Passwords only through Argon2id (with a pepper from the manager)? check_needs_rehash at login?
  3. Message authentication via HMAC (never sha256(key+msg)), compared with compare_digest?
  4. Do webhooks carry a timestamp, and get rejected outside the anti-replay window?

Asymmetric crypto and signatures

  1. RSA only with OAEP (encryption) / PSS (signatures), sizes ≥ 3072; or Ed25519/X25519 directly?
  2. Is everything that's signed canonicalized first? Are key exchanges authenticated?

TLS and certificates

  1. Zero verify=False (or equivalents) anywhere in the diff, tests included?
  2. Internal connections (DB included) with full verification (sslmode=verify-full + internal CA)?
  3. Do new certificates get added to automatic renewal and the expiration monitor?

Tokens and sessions

  1. Does every jwt.decode fix algorithms (closed list), audience, issuer, and require? Short exp?
  2. Does the session token go in an HttpOnly; Secure; SameSite cookie (not localStorage)?

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's verify=False was 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. bandit and linters catch md5, random in security contexts, and verify=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.

  1. 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
  1. 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
  1. 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

  1. 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.randint for 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, format version || 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.

  2. 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) No X-MediNube-Timestamp and no anti-replay window: a captured webhook can be replayed indefinitely (03-02, checklist 13). And the logging.info with the full signature edges into family 7 too: valid signatures sitting in logs make replay easier if (e) is missing.

  3. 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 on exp or aud, tokens would be eternal and reusable across services. (c) verify=False in requests: 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.

© Copyright 2026. All rights reserved