Downloads · 30 days
0
EnigmaConsultant/huntr-poc-msgpack-numpy-rllib-pickle-rce
huntr-poc-msgpack-numpy-rllib-pickle-rce is a machine learning model from EnigmaConsultant. Use it for the machine learning task on the model card, and read the license before you ship it in a product.
Date: 2026-07-08 Scope: MessagePack (.msgpack) as used for ML model/tensor/checkpoint serialization (flax.serialization, msgpacknumpy, and consumers of msgpacknumpy such as Ray RLlib).
Downloads · 30 days
0
Access
Public
Updated Jul 30, 2026
Repo size
99 B
Likes
0
Public
Click a slice to open those files.
.py46.5 KB · 79%
From the Hugging Face model README
Date: 2026-07-08 Scope: MessagePack (.msgpack) as used for ML model/tensor/checkpoint serialization (flax.serialization, msgpack_numpy, and consumers of msgpack_numpy such as Ray RLlib).
File: msgpack_numpy.py (package msgpack-numpy 0.4.8, installed at
/home/kali/hunt-workspace/msgpack-model-audit/venv/lib/python3.13/site-packages/msgpack_numpy.py)
Function decode(), lines 84-115. Relevant lines:
84 def decode(obj, chain=None):
...
89 try:
90 if b'nd' in obj:
91 if obj[b'nd'] is True:
96 if b'kind' in obj and obj[b'kind'] == b'V':
97 descr = ...
99 elif b'kind' in obj and obj[b'kind'] == b'O':
100 return pickle.loads(obj[b'data']) # <-- unconditional pickle.loads on attacker bytes
101 else:
102 descr = obj[b'type']
decode() is installed as the msgpack object_hook for every map value encountered during
unpacking (see Unpacker.__init__ around line 237, and unpack()/unpackb() wrappers at
lines 271-287, all of which wrap object_hook with functools.partial(decode, chain=object_hook)).
Any msgpack map that merely contains the keys nd: True, kind: 'O', and data: <bytes> — a
structure meant to represent a numpy object-dtype array — causes the library to call
pickle.loads() directly on the attacker-supplied data bytes, with no validation, no
allow-list, and no way to opt out. This reintroduces full pickle-deserialization RCE inside a
format (.msgpack) that downstream ML tooling treats as a safe, non-code-executing
alternative to pickle.
msgpack_numpy.patch() (lines 294-308) is the standard integration path: it monkey-patches
msgpack.Packer/Unpacker/pack/unpack/packb/unpackb so that any code calling the
ordinary msgpack module after patch() silently inherits this behavior.
ray/rllib/utils/checkpoints.py (from ray-project/ray, master branch, fetched fresh):
try_import_msgpack() (line ~1050-1073) does exactly import msgpack_numpy; msgpack_numpy.patch(); return msgpack.msgpack = try_import_msgpack(error=True); msgpack.dump(state, f).if filename.with_suffix(".msgpack").is_file(): msgpack = try_import_msgpack(error=True); state = msgpack.load(f, strict_map_key=False).This is RLlib's .msgpack/.msgpck "algorithm_state" / policy checkpoint format
(convert_to_msgpack_checkpoint, convert_to_msgpack_policy_checkpoint, and the default
save/restore path when use_msgpack=True). Loading such a checkpoint calls exactly the
vulnerable decode() path.
Two PoC scripts, both executed successfully:
poc/repro.py — calls msgpack_numpy.unpackb() directly on a crafted 110-byte blob.poc/repro_rllib_path.py — mirrors RLlib's exact call sequence
(msgpack_numpy.patch() then msgpack.dump(state, f) / msgpack.load(f, strict_map_key=False))
against a file named like a real RLlib checkpoint (algorithm_state.msgpack).Payload construction:
class Evil:
def __reduce__(self):
return (os.system, ('id > /tmp/rllib_pwned.txt',))
payload = pickle.dumps(Evil())
malicious_state = {"worker": {b'nd': True, b'type': [], b'kind': b'O',
b'shape': (1,), b'data': payload}}
Captured output of repro_rllib_path.py:
Loaded state: {'worker': 0}
pwned file exists: True
uid=1000(kali) gid=1000(kali) groups=1000(kali),4(adm),20(dialout),24(cdrom),25(floppy),
27(sudo),29(audio),30(dip),44(video),46(plugdev),100(users),101(netdev),102(scanner),
118(wireshark),119(kaboxer),982(bluetooth),999(lpadmin)
i.e. simply loading a 100-ish-byte crafted .msgpack checkpoint file executed
os.system('id > /tmp/rllib_pwned.txt') on the victim machine — full arbitrary code
execution at model/checkpoint load time, no other interaction required.
Environment: Python 3.13.12, msgpack 1.2.1, msgpack-numpy 0.4.8, numpy 2.5.1
(installed fresh in /home/kali/hunt-workspace/msgpack-model-audit/venv).
flax/serialization.py (flax 0.12.7) implements its own ext-type hook
(_msgpack_ext_pack / _msgpack_ext_unpack, lines 278-314) restricted to exactly three
IntEnum codes (ndarray=1, native_complex=2, npscalar=3), all of which route only through
np.frombuffer / np.dtype(name) — no pickle, no arbitrary ext dispatch, unrecognized
ext codes just come back as an inert msgpack.ExtType(code, data) object. No reproducible bug
found here (dtype-name arg to np.dtype() was tested with garbage/huge strings; it only ever
raises TypeError/ValueError, no OOB read).
msgpack-numpy → 0 vulns; flax → 0 vulns;
msgpack → 1 unrelated advisory, GHSA-6v7p-g79w-8964/CVE-2026-57585 (Unpacker-reuse
use-after-error SEGV/DoS, fixed in 1.2.1, already patched in installed version, unrelated to
this pickle-fallback issue).msgpack-numpy: explicitly "No direct vulnerabilities found."lebedov/msgpack-numpy (repo's Security tab has no
advisories) or for ray-project/ray covering this specific RLlib msgpack-checkpoint path.msgpack_numpy is a widely-depended-on serialization shim (used directly by Ray
RLlib for .msgpack policy/algorithm checkpoints, and packaged/recommended in various ML
tutorials/repos as "the safe way to msgpack-serialize numpy arrays"). Any project that
pip install msgpack-numpy and calls .patch() (RLlib's documented pattern) inherits this.poc/repro.py — minimal direct msgpack_numpy.unpackb() RCE repro.poc/repro_rllib_path.py — RLlib-call-path-faithful RCE repro (patch() + msgpack.dump/msgpack.load).rllib_checkpoints.py — fetched copy of ray/rllib/utils/checkpoints.py (master) used for line citations.Re-run from a clean checkout by a second party, fresh venv, current PyPI releases
(msgpack 1.2.1, msgpack-numpy 0.4.8, numpy 2.5.1, python 3.13). poc/repro_rllib_path.py
— which mirrors RLlib's exact call sequence (msgpack_numpy.patch(), then
msgpack.load(f, strict_map_key=False) on a file named algorithm_state.msgpack) —
reproduced real code execution verbatim:
Loaded state: {'worker': 0}
pwned file exists: True
uid=1000(kali) gid=1000(kali) groups=1000(kali),4(adm),...
pickle.loads() call is documented, intended behaviour of msgpack-numpy. Its own
package README states that numpy arrays with dtype 'O' are serialized/deserialized using
pickle. So this should NOT be read as an unintended bug in msgpack-numpy; the maintainer
made a deliberate design choice..msgpack file is widely treated (including by Ray RLlib, which offers msgpack checkpoints as
the portable/less-pickle-dependent option) as a data-only, non-code-executing serialization
format, yet a msgpack map carrying {nd: True, kind: 'O', data: <bytes>} yields full pickle
RCE on load, with no allow-list and no opt-out once msgpack_numpy.patch() has been called.
The defect is the combination: RLlib globally patches msgpack and then loads checkpoint
files that a user may have obtained from a third party.msgpack-numpy. Ray has
22 advisories; the two in the same class are GHSA-hhrp-gw25-jr43 (ACE via
ray.data.read_webdataset default decoder pickle.loads) and GHSA-mw35-8rx3-xf9r (RCE via
Parquet Arrow extension type deserialization). Neither covers the RLlib
checkpoints.py msgpack path, so this is not a duplicate — but it is the same recurring
pickle-on-data-path pattern in Ray, and a triager may reasonably group it with those.