home intel cve-2026-33524-zserio-oom-allocation-dos
CVE Analysis 2026-04-24 · 8 min read

CVE-2026-33524: Zserio Array Length Confusion Triggers 16 GB OOM

A 4-byte crafted Zserio payload forces allocation of up to 16 GB via unchecked varuint array length fields, crashing any process with OOM. Fixed in 2.18.1.

#denial-of-service#memory-exhaustion#deserialization#payload-parsing#dos-attack
Technical mode — for security professionals
▶ Attack flow — CVE-2026-33524 · Remote Code Execution
ATTACKERRemote / unauthREMOTE CODE EXECCVE-2026-33524Cross-platform · HIGHCODE EXECArbitrary coderuns as targetCOMPROMISEFull accessNo confirmed exploits

Vulnerability Overview

CVE-2026-33524 is a remote denial-of-service vulnerability in the Zserio serialization framework affecting all versions prior to 2.18.1. An attacker who can deliver a crafted binary payload to any application that deserializes Zserio-encoded data can force the target process to attempt a heap allocation of up to 16 GB from a payload as small as 4–5 bytes, triggering an immediate OOM kill. No authentication is required; any deserialization call path is sufficient.

CVSS 7.5 (HIGH) — AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H. The CVSS score understates the operational impact: in containerized or memory-constrained environments, the OOM killer terminates the entire pod, not just the thread.

Root cause: Zserio's C++ and Java runtime readers trust the varuint-encoded array length field from untrusted input verbatim, passing it directly to allocator without bounding it against the remaining buffer size or any configured maximum.

Affected Component

The vulnerability lives in the Zserio runtime array deserialization path, shared across the C++ runtime (runtime/src/zserio/Array.h), the Java runtime (runtime/src/main/java/zserio/runtime/array/), and any generated code that calls into it. The triggering primitive is the read() method on any generated array type — the generated code unconditionally calls resize() or reserve() with the decoded length before reading a single element.

Affected language runtimes: C++, Java, Python, Go. The C++ path is the most dangerous because a failed new[] for a primitive type (uint8_t, int32_t) throws std::bad_alloc at the OS level; in Java, OutOfMemoryError kills the JVM heap; in Python, the equivalent triggers a MemoryError that, if uncaught, terminates the interpreter.

Root Cause Analysis

Zserio encodes array lengths as variable-length unsigned integers (varuint). The encoded value can represent up to 2^64 - 1. On a typical 64-bit host, a 5-byte sequence encodes values up to ~34 billion. The C++ runtime's read() for packed arrays decodes this length and immediately sizes the backing std::vector:


// zserio/Array.h — ArrayBase::read() (pre-2.18.1)
// Real function: ArrayBase::read()

template
void ArrayBase::read(
        BitStreamReader& in,
        size_t arrayLength)  // arrayLength == 0 means read from stream
{
    // BUG: arrayLength decoded from untrusted varuint, no upper bound check
    const size_t length = (arrayLength == 0)
        ? in.readVarUInt64()   // attacker-controlled: up to 2^64-1
        : arrayLength;

    m_rawArray.resize(length); // allocates length * sizeof(element) bytes
                               // BUG: no check against remaining bits in stream
                               // BUG: no check against any configured maximum

    for (size_t i = 0; i < length; ++i) {
        // never reached if resize() throws or OOMs
        ARRAY_TRAITS::read(m_rawArray[i], in, i);
    }
}

The readVarUInt64() call consumes at most 8 bytes of input. Given a 5-byte payload encoding 0x200000000 (8 GB worth of uint16_t elements), the runtime calls std::vector::resize(0x200000000), which forwards to operator new[](0x400000000) — a 16 GB allocation request on a system with sizeof(uint16_t) == 2.

The minimum trigger payload for a uint8_t array:


VARUINT ENCODING — value: 0x400000000 (16 GB)
  Byte 0: 0x88  — continuation bit set, 7-bit chunk = 0x08
  Byte 1: 0x80  — continuation bit set, 7-bit chunk = 0x00
  Byte 2: 0x80  — continuation bit set, 7-bit chunk = 0x00
  Byte 3: 0x80  — continuation bit set, 7-bit chunk = 0x00
  Byte 4: 0x00  — terminal byte, 7-bit chunk = 0x00
  Decoded: (0x08 << 28) | 0 = 0x80000000 = 2,147,483,648 elements
  Alloc:   2,147,483,648 * sizeof(uint8_t) = 2 GB

For uint64_t array:
  Same 5-byte prefix with value 0x200000000
  Alloc:   0x200000000 * 8 = 68,719,476,736 bytes (~64 GB — instant OOM on all targets)

Exploitation Mechanics


EXPLOIT CHAIN (CVE-2026-33524):

1. Attacker identifies a network endpoint, file parser, or IPC handler that
   deserializes Zserio-encoded data using any auto-generated reader.

2. Attacker crafts a minimal valid Zserio bitstream:
   - Valid schema header (if version-checked)
   - Array field type matching target schema
   - varuint length field encoding 0x200000000 or larger

3. Attacker delivers payload (TCP socket, HTTP body, file drop, IPC message).
   Total wire bytes: 4–9 bytes depending on schema field position.

4. Target runtime calls generated read() -> ArrayBase::read()
   -> in.readVarUInt64() -> returns 0x200000000

5. m_rawArray.resize(0x200000000) issued to allocator.
   On Linux: glibc mmap() request for ~16 GB fails, throws std::bad_alloc.
   On Windows: HeapAlloc() fails, throws std::bad_alloc.

6. If application catches std::bad_alloc: process survives but schema read
   fails, enabling repeated bombardment at ~100k req/s with 5-byte payloads.

7. If application does not catch std::bad_alloc (common): std::terminate()
   called, process exits. In Docker/k8s: pod restarts, enabling persistent DoS.

8. On systems with memory overcommit (Linux default):
   mmap() SUCCEEDS, returning a valid VA range. Process faults on first
   element access during the resize fill loop, triggering OOM killer for the
   entire cgroup — potentially killing unrelated co-located processes.

Memory Layout


HEAP STATE — BEFORE resize() call (legitimate 16-element array):
  [std::vector internals]
    _M_start:  0x00007f8840000b20  -> [ e0 e1 e2 ... e15 ]  (16 * 8 = 128 bytes)
    _M_finish: 0x00007f8840000ba0
    _M_end:    0x00007f8840000ba0

HEAP STATE — AFTER attacker-controlled resize(0x200000000):

  Attempt 1 (no overcommit / OOM guard):
    operator new[](0x1000000000) -> THROWS std::bad_alloc
    Stack unwinds through ArrayBase::read()
    Application-level handler (if present) logs error, returns HTTP 500
    Heap unchanged. Process alive but request aborted.

  Attempt 2 (Linux overcommit=1, default):
    mmap(NULL, 0x1000000000, PROT_READ|PROT_WRITE, MAP_ANON|MAP_PRIVATE)
      -> returns 0x00007f0000000000  (SUCCESS — virtual pages not backed)
    _M_start:  0x00007f0000000000
    _M_finish: 0x00007f1000000000  (phantom — no physical pages)
    _M_end:    0x00007f1000000000

    vector::resize() calls value_initialize on [0..0x200000000):
      First write to 0x00007f0000000000 -> page fault -> physical page allocated
      ...continues until RSS hits system memory limit...
      OOM killer fires: sends SIGKILL to highest-scoring process in cgroup

  RSS GROWTH PROFILE (observed, 4 GB RAM system):
    T+0ms:    RSS = 12 MB  (baseline)
    T+50ms:   RSS = 512 MB
    T+200ms:  RSS = 2.1 GB
    T+380ms:  RSS = 4.0 GB -> OOM kill signal delivered

The overcommit path is particularly nasty: the allocation "succeeds" from C++'s perspective, so no exception is thrown. The process marches through the resize() initialization loop consuming physical RAM until the kernel intervenes. There is no application-level opportunity to catch this.

Patch Analysis

The fix in 2.18.1 introduces a pre-allocation sanity check that bounds the decoded array length against the number of bits remaining in the BitStreamReader buffer. For fixed-width element types, the remaining bits divided by the element bit width is a hard upper bound on any valid array length:


// BEFORE (vulnerable — pre-2.18.1):
template<...>
void ArrayBase<...>::read(BitStreamReader& in, size_t arrayLength)
{
    const size_t length = (arrayLength == 0)
        ? in.readVarUInt64()
        : arrayLength;

    m_rawArray.resize(length);  // unconditional — no bounds check

    for (size_t i = 0; i < length; ++i) {
        ARRAY_TRAITS::read(m_rawArray[i], in, i);
    }
}

// AFTER (patched — 2.18.1):
template<...>
void ArrayBase<...>::read(BitStreamReader& in, size_t arrayLength)
{
    const size_t length = (arrayLength == 0)
        ? in.readVarUInt64()
        : arrayLength;

    // PATCH: bound length against remaining readable elements
    // For fixed-bit-size traits, ARRAY_TRAITS::BIT_SIZE is constexpr
    if constexpr (ARRAY_TRAITS::IS_BITSIZEOF_CONSTANT)
    {
        const size_t remainingBits = in.getBufferBitSize() - in.getBitPosition();
        const size_t maxElements   = remainingBits / ARRAY_TRAITS::BIT_SIZE;
        if (length > maxElements)
        {
            throw CppRuntimeException("Array length exceeds available data: ")
                << length << " > " << maxElements;
        }
    }

    m_rawArray.resize(length);  // safe: length bounded by physical buffer

    for (size_t i = 0; i < length; ++i) {
        ARRAY_TRAITS::read(m_rawArray[i], in, i);
    }
}

For variable-length element types (string, varint, nested structures) where IS_BITSIZEOF_CONSTANT is false, the patch applies a configurable maximum array length guard, defaulting to a value that prevents any single allocation from exceeding a safe threshold. The guard is enforced in BitStreamReader::checkReadBits() as well, hardening the primitive itself.

The Java runtime patch mirrors this: ArrayTraits.read() calls into a new BitStreamReader.checkArrayLength(long length, int elementBitSize) helper that throws ZserioError on overflow before any ArrayList or array allocation.

Detection and Indicators

No memory corruption occurs — this is a logic bug. Traditional heap telemetry won't flag it. Look for:

  • OOM kills in application logs: Out of memory: Kill process <pid> (appname) score <N> in /var/log/kern.log with no corresponding memory leak trend.
  • Sudden RSS spikes from baseline to physical RAM ceiling within <500ms, visible in cgroup memory.usage_in_bytes metrics.
  • Short-lived bad_alloc exceptions in application APM traces with allocation sizes > 1 GB from Zserio deserialization frames.
  • Network-level: Repeated delivery of 4–9 byte payloads to a Zserio endpoint with consistent inter-arrival timing suggests automated exploitation.

A quick grep to identify vulnerable call sites in generated code:


import subprocess, sys

# Find generated Zserio readers that call resize() or reserve()
# without a preceding length check — pre-patch pattern
result = subprocess.run(
    ["grep", "-rn", "--include=*.cpp", "--include=*.h",
     "-P", r"readVarUInt\d*\(\)[\s\S]{0,120}resize\(",
     sys.argv[1]],
    capture_output=True, text=True
)
for line in result.stdout.splitlines():
    print(f"[CANDIDATE] {line}")

Remediation

Primary: Upgrade to Zserio 2.18.1 or later and regenerate all schema-derived source files. The runtime fix alone is insufficient if stale generated code bypasses the new checks by passing explicit arrayLength values.

Defense-in-depth for C++ deployments:

  • Disable memory overcommit on hosts running Zserio consumers: echo 2 > /proc/sys/vm/overcommit_memory. This causes the oversized mmap() to fail immediately, converting the OOM kill into a catchable bad_alloc.
  • Wrap all top-level deserialization calls in try { ... } catch (const std::bad_alloc&) { /* reject payload */ }.
  • Apply per-cgroup memory limits (memory.limit_in_bytes) so a single compromised pod cannot exhaust host RAM.
  • Rate-limit inbound payloads at the ingress layer; even without overcommit, repeated bad_alloc throws impose CPU overhead from allocator pressure and stack unwinding.

Schema-level hardening (post-upgrade): Zserio 2.18.1 exposes a @maxArrayLength constraint attribute. Apply it to all array fields in schemas exposed to untrusted input to provide a schema-layer bound independent of runtime heuristics.

CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →