EnigmaConsultant's picture
Upload folder using huggingface_hub
8582f06 verified
|
Raw
History Blame Contribute Delete
10.1 kB
# Uncontrolled memory pre-allocation (CWE-789) in msgpack-python's C-extension Unpacker
**Status:** Independently verified via actual execution against the real, unmodified, compiled
`msgpack._cmsgpack` C extension. This is a PRIVATE, gated staging repo for a huntr submission β€” not a
public advisory.
## Target
- Project: [msgpack-python](https://github.com/msgpack/msgpack-python) (PyPI package `msgpack`)
- Version confirmed vulnerable: **1.2.1** (`msgpack/__init__.py`: `version = (1, 2, 1)`)
- Commit used for line citations below: `c6b4a481ed574b8b785c4487d7ef9b592c8b4f2b`
(`git clone https://github.com/msgpack/msgpack-python.git`)
- Not to be confused with:
- `msgpack-numpy`'s `pickle.loads()` RCE (already filed separately by this researcher β€”
`EnigmaConsultant/huntr-poc-msgpack-numpy-rllib-pickle-rce`).
- The sibling **msgpack-c** C library, which has an architecturally-related but distinct,
separately-tracked (unfiled) finding in its own vendored `unpack_template.h` /
`unpack_container_header.h` / `unpack_define.h`. This report is specifically about the
**msgpack-python** package's own C extension, confirmed against the real compiled `.so`.
## Root cause
In `msgpack/unpack.h`, `unpack_callback_array()` (and `unpack_callback_map()` when
`object_pairs_hook` is used) does:
```c
static inline int unpack_callback_array(unpack_user* u, unsigned int n, msgpack_unpack_object* o)
{
if (n > u->max_array_len) {
PyErr_Format(PyExc_ValueError, "%u exceeds max_array_len(%zd)", n, u->max_array_len);
return -1;
}
PyObject *p = u->use_list ? PyList_New(n) : PyTuple_New(n);
...
```
(`msgpack/unpack.h` lines 126-132, confirmed against commit `c6b4a481`.)
This allocation fires **immediately upon parsing the array/map header** β€” i.e. as soon as the
attacker-controlled element count `n` has been read from the wire format (e.g. an `array32` header:
1 tag byte + 4-byte big-endian count) β€” and **before a single one of the `n` claimed elements has
actually been received or validated**.
The only gate is `if (n > u->max_array_len)`. For the streaming `Unpacker` class β€” the documented,
recommended API for "streaming deserialize from socket" β€” `max_array_len` **defaults to
`max_buffer_size`** (default 100 MiB; see `msgpack/_unpacker.pyx` lines 364-365:
`if max_array_len == -1: max_array_len = max_buffer_size`). That silently sets an
**element-count limit equal to a byte-count budget**. Since each pre-allocated list/tuple slot costs
8 bytes (a `PyObject*` pointer) regardless of how many actual input bytes encode that element, this
gives an inherent **~8x memory amplification per container level** versus the developer's
configured/default budget.
This compounds across nesting: `unpack_template.h`'s `start_container` machinery fires this same
allocation at **every nesting level independently**, bounded only by `MSGPACK_EMBED_STACK_SIZE` = 32
β€” which correctly bounds nesting *depth* but does nothing to bound the *per-level element count
claimed*. So 32 nested array headers (~160 bytes of input) each independently pass the same
`n <= max_array_len` check and each trigger their own `PyList_New(n)`, **stacking the 8x
amplification 32-fold**.
Critically, this defeats the library's own documented security guidance. The docstring for
`max_buffer_size` explicitly states:
> "You should set this parameter when unpacking data from untrusted source."
Yet a developer who follows that exact advice (e.g. explicitly setting `max_buffer_size=10*1024*1024`)
still sees ~1.9-2.7 GB of virtual memory reserved from a **160-byte** crafted payload β€” an **180x+
bypass** of their configured budget β€” because `max_array_len` silently inherited the byte-budget as
an element-count budget, and per-level compounding is not accounted for at all.
## Negative control (confirms the one-shot `unpackb()` path is NOT vulnerable)
`msgpack/_unpacker.pyx` lines 181-182: for `unpackb()`, when `max_array_len == -1` it defaults to
`buf_len` β€” the length of the *actual buffer supplied*, not a configurable budget β€” so a header
claiming more elements than bytes present is correctly rejected:
```
$ ./venv/bin/python poc1.py
payload len: 5
Exception: <class 'ValueError'> 100000000 exceeds max_array_len(5)
```
This is a useful control: it isolates the bug to the streaming `Unpacker` class specifically (the
class the docs recommend for untrusted/socket input), not to the msgpack wire-format parser in
general.
## Proof of Concept
Built a venv, installed real `msgpack==1.2.1` from PyPI (unmodified, official wheel β€” real compiled
`msgpack._cmsgpack` C extension). Four escalating PoCs, each printing `/proc/self/status`
`VmPeak`/`VmSize`/`VmRSS` before and after. All scripts included verbatim in this repo.
### PoC 2 β€” single streaming header, no payload following (`poc4.py`)
A **5-byte** `array32` header (tag `0xdd` + count = 100,000,000) fed to a plain `Unpacker()`
(`max_buffer_size` default 100 MiB β†’ `max_array_len` defaults to the same 100 MiB, so `n` is
"legal"):
```
$ ./venv/bin/python poc4.py
[start] VmPeak: 17728 kB | VmSize: 17628 kB | VmRSS: 10544 kB
header bytes: 5 claimed elements: 100000000 -> naive PyList_New(n) would need 800.0 MB just for pointer array
[after feed (before iterate)] VmPeak: 18656 kB | VmSize: 18656 kB | VmRSS: 10816 kB
[after iterate attempt] VmPeak: 799908 kB | VmSize: 799908 kB | VmRSS: 10956 kB
got_exc: None
```
VmPeak jumps from ~18 MB to ~800 MB β€” matching the predicted `100,000,000 * 8 bytes/pointer` exactly
β€” from **5 bytes of input**, with **zero exception raised**. VmRSS stays ~11 MB, confirming this is a
virtual/reserved-not-committed allocation (demand-paged mmap/calloc), the same pattern documented in
the related msgpack-c finding.
### PoC 3 β€” nested-container compounding (`poc6_capped.py`)
4 nested `array32` headers (20 bytes total), each claiming `n=40,000,000`.
`RLIMIT_AS` capped at 1.5 GB purely as a **test-harness safety net** for the shared host β€” not part
of the bug:
```
$ ./venv/bin/python poc6_capped.py
[start (RLIMIT_AS capped at 1.5GB for safety)] VmPeak=17736 kB VmSize=17660 kB VmRSS=10552 kB
total payload bytes: 20 -> naive 4-level total: 1.28 GB
elapsed: 0.0 s
[after 20-byte (4x5) nested-header feed] VmPeak=1268704 kB VmSize=1268704 kB VmRSS=11216 kB
```
VmPeak lands at exactly the predicted `4 * 40,000,000 * 8 bytes = 1.269 GB` β€” demonstrating clean
per-nesting-level multiplication from **20 bytes of input**.
### PoC 4 β€” killer scenario: developer follows the library's own documented security advice (`poc7_explicit_limit_bypass.py`)
Developer explicitly sets `max_buffer_size=10*1024*1024` (10 MiB) β€” precisely per the docstring's
own advice to "set this parameter when unpacking data from untrusted source". A **160-byte** payload
of 32 nested `array32` headers (`MSGPACK_EMBED_STACK_SIZE`), each claiming `n=10,485,760` (the
inherited `max_array_len`), drives memory reservation to 1.9 GB before hitting our own 2 GB
test-harness `RLIMIT_AS` safety cap (predicted uncapped total: 2.68 GB):
```
$ ./venv/bin/python poc7_explicit_limit_bypass.py
Developer explicitly sets max_buffer_size=10485760 (10 MiB) per the library's own documented advice.
[start] VmPeak=18696 kB VmSize=18696 kB VmRSS=10684 kB
attacker payload: 160 bytes (32 nested array32 headers, each claiming n=10485760)
developer's configured budget: 10.5 MB total
naive worst case reservation: 32 x 10485760 x 8 bytes/ptr = 2.68 GB
Exception: <class 'MemoryError'>
[after feeding 160-byte payload] VmPeak=1902948 kB VmSize=18696 kB VmRSS=11196 kB
```
**180x+ bypass** of the developer's explicitly-configured 10 MB security budget, from **160 bytes**
of attacker input, using only the library's own documented "safe" configuration pattern. The
`MemoryError` here is an artifact of our own 2 GB test-harness `RLIMIT_AS` safety cap β€” an attacker
without that cap in place (or against a host with more available memory / overcommit enabled) drives
this well past 2.6 GB uncapped.
## Impact
Any application that uses `msgpack.Unpacker()` (the documented streaming/socket-input API) to
deserialize attacker-controlled msgpack data β€” even one that follows the library's own documented
advice and sets an explicit `max_buffer_size` β€” can be driven to reserve gigabytes of virtual memory
from a payload of a few hundred bytes, before a single byte of actual element data has been
transmitted or validated. This is a denial-of-service primitive (memory exhaustion / OOM-kill) that
silently bypasses the library's own stated defense-in-depth mechanism.
## Suggested fix
- Do not eagerly pre-allocate `PyList_New(n)` / `PyTuple_New(n)` for the full claimed count `n`
before any elements have been received. Either grow the container incrementally as elements
arrive, or defer full allocation until enough buffered input exists to plausibly encode `n`
elements (e.g. requiring at least `n` bytes remaining in the buffer, as `unpackb()` already does
via `buf_len`).
- Decouple `max_array_len` / `max_map_len` from `max_buffer_size` by default, and/or explicitly
divide by the per-element pointer overhead (8 bytes) and by `MSGPACK_EMBED_STACK_SIZE` when
deriving a default, so the documented byte-budget is actually a byte-budget in the worst case
across nesting.
## Files in this repo
- `poc1.py` β€” negative control: `unpackb()` one-shot path correctly rejects an oversized header.
- `poc2.py`, `poc3.py` β€” earlier iterations kept for provenance.
- `poc4.py` β€” single 5-byte streaming header -> ~800 MB VmPeak.
- `poc5_nested.py` β€” early nested-header iteration.
- `poc6_capped.py` β€” 4-level nesting compounding, 1.5 GB safety-capped.
- `poc7_explicit_limit_bypass.py` β€” 32-level nesting against an explicit developer-configured
`max_buffer_size`, 2 GB safety-capped.
All PoCs were run against the real, unmodified, PyPI-installed `msgpack==1.2.1` package (compiled
`msgpack._cmsgpack` C extension) β€” not a modified or hypothetical build.