Lion Cipher Lab
Read Python bytecode with dis, and take apart a .pyc file by hand
- Foundations
- Intermediate
- ~60 min
- Free · no account required
Why it matters
Python feels like a language with no compiled form, but every time you import a module CPython turns it into bytecode and saves it as a .pyc file in a __pycache__ folder. That file is what really runs. Two beliefs about it cause real trouble. The first is 'if I ship only the .pyc, my secrets and logic are hidden'. They are not: strings, numbers and names sit in the file as plain bytes, and the standard library's own dis and marshal modules lay out the logic. The second is 'Python always runs the source I am looking at'. It does not: Python decides whether a .pyc is stale with a cheap check,…
What you'll learn
- reading CPython bytecode with dis: spotting LOAD_CONST, LOAD_FAST, COMPARE_OP and RETURN_VALUE in your own function, and finding a hard-coded licence key as a LOAD_CONST in 102 bytes of co_code
- compiling with py_compile in all three invalidation modes and decoding the 16-byte .pyc header by hand: magic number (cb0d0d0a = 3531 on Python 3.12), the PEP 552 flags word (0, 1 or 3), then source mtime + size or an 8-byte source hash
- checking a header against importlib.util.MAGIC_NUMBER and importlib.util.source_hash, and unmarshalling the code object to walk co_consts, co_names and nested code objects
- proving that compiling hides nothing: a fake canary API key and the licence key sit as plain bytes in the .pyc (offsets 425 and 738), and an XOR-hidden message is recovered statically without running any code
- measuring that -OO only strips docstrings and asserts (1644 -> 1376 bytes) while every secret string survives
- measuring stale-pyc detection: a same-size edit with the mtime put back runs OLD code under timestamp pycs; an UNCHECKED hash-based pyc runs old code after any edit until --check-hash-based-pycs always
- quantifying what checked hash-based pycs cost: reading and hashing all 630 stdlib sources took 14x to 25x longer than stat-ing them across three runs on this machine
- explaining why bytecode is version-specific (3.12 vs 3.14 magic numbers and instructions differ; 3.14 refuses a 3.12 pyc with 'bad magic number') and why marshal must never load a file you did not build
Steps in this lab
- What CPython really runs: source, bytecode, code objects, .pyc
- Set up: a terminal, Python, a folder, and a toy licensed app
- Read the bytecode with dis
- Compile to a .pyc and read the 16-byte header by hand
- Unmarshal the code object and walk it
- What survives compilation: the canary, the key, and the 'hidden' message
- Measure stale-pyc detection: when does Python notice your edit?
- Hash-based pycs: checked, unchecked, and what checking costs
- Shipping only the .pyc, and why bytecode is tied to one Python version
- Honest negatives: what this does not prove, and where it bites
- Do it better, then clean up