You need to agree to share your contact information to access this model

This repository is publicly accessible, but you have to accept the conditions to access its files and content.

Log in or Sign Up to review the conditions and access this model content.

YAML Metadata Warning:empty or missing yaml metadata in repo card

Check out the documentation for more information.

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 (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:

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.

Downloads last month

-

Downloads are not tracked for this model. How to track
Inference Providers NEW
This model isn't deployed by any Inference Provider. πŸ™‹ Ask for provider support