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.
Imagine someone could shut down a website or service by sending just a few bytes of data—like crashing your computer with a text message. That's essentially what this vulnerability does.
Zserio is a tool that helps programs package and unpack data efficiently, kind of like a compression format for information. But versions before 2.18.1 have a critical flaw: they don't check what's inside incoming data carefully enough.
Here's how the attack works. A malicious actor sends a specially crafted message—just 4 or 5 bytes, smaller than a single emoji. When the vulnerable software tries to unpack it, something goes horribly wrong. Instead of processing that tiny message normally, the software suddenly tries to allocate massive amounts of memory, sometimes up to 16 gigabytes. The system runs out of memory and crashes, and the application stops working.
The scary part is this requires no special access. An attacker doesn't need to hack into anything or trick a user into downloading malware. They just send the malicious data over the network to any application using the vulnerable version, and boom—denial of service.
Who should worry? Companies that use Zserio in their products, particularly those handling real-time data or critical systems. This could affect messaging apps, IoT devices, financial platforms, or any service that deserves reliability.
Here's what you should do: If you work at a company using Zserio, ask your IT team if you're running version 2.18.1 or newer. If not, update immediately. If you're a regular user, watch for service outages from companies you rely on—they may be patching this. Keep your apps and devices updated generally, since patches fix problems like this one.
Want the full technical analysis? Click "Technical" above.
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.