Related to #226, which asked exactly this question in 2019
("Look into whether igbinary_unserialize(igbinary_serialize('str') . 'invalid padding') is a memory leak") but left it unconfirmed and
unlocated. This report confirms it, with a concrete 11-byte reproducer
and the exact line responsible.
Summary
igbinary_unserialize() can leak memory when given malformed or truncated
input. If a value (array, in the minimal reproducer below) has already been
partially constructed and attached to the output zval when a deeper
recursive call fails, that partially-built value is never freed. Confirmed
with a 11-byte input under AddressSanitizer + LeakSanitizer.
This is a memory leak, not memory corruption. Severity is low on its own,
but it is attacker-triggerable with a single malformed payload and is 100%
reproducible, so a long-lived process that repeatedly unserializes
untrusted igbinary data (a cache layer, a session handler backed by
igbinary, a persistent worker) will leak memory at a rate proportional to
how much of the value tree got built before the error — an easy
memory-exhaustion DoS vector.
Environment
- igbinary: HEAD at the time of testing,
2b1f5d6 (3.2.17RC1 + a few commits)
- PHP: built from php-src master, commit
d0fc9664, reports as
PHP 8.7.0-dev (cli) (NTS DEBUG), Zend Engine v4.7.0-dev
- Platform: Linux aarch64 (Ubuntu 24.04 container), clang 18
- Build: standalone
sapi/fuzzer SAPI (--enable-fuzzer --enable-address-sanitizer --enable-debug-assertions --enable-igbinary --enable-session), with a custom LLVMFuzzerTestOneInput harness that
calls igbinary_unserialize(Data, Size, &result) directly, mirroring
php-src's existing sapi/fuzzer/fuzzer-unserialize.c.
I have not checked whether this reproduces against a released igbinary
version paired with a stable PHP release; it was found while fuzzing
against current master of both projects.
Proof of concept
11 bytes, found and minimized by libFuzzer:
00 00 00 02 14 05 06 14 02 00 00
Decoded:
00 00 00 02 — igbinary header, version 2
14 05 — igbinary_type_array8, declared length 5
06 14 — first key: igbinary_type_long8p, value 0x14 (20)
02 00 00 — first value: igbinary_type_ref16, referencing reference
group id 0 — which does not exist, since no igbinary_type_ref byte
appears anywhere in the input
The array declares 5 elements but the buffer ends after the first
key/value pair, so the second iteration's "read next key byte" check
fails with IGB_NEEDS_MORE_DATA.
int main() {
zval result;
ZVAL_UNDEF(&result);
uint8_t data[] = {0x00,0x00,0x00,0x02,0x14,0x05,0x06,0x14,0x02,0x00,0x00};
igbinary_unserialize(data, sizeof(data), &result);
zval_ptr_dtor(&result);
return 0;
}
Run under LeakSanitizer:
==ERROR: LeakSanitizer: detected memory leaks
Indirect leak of 56 byte(s) in 1 object(s) allocated from:
#0 malloc
#1 __zend_malloc Zend/zend_alloc.c:4068
#2 _zend_new_array Zend/zend_hash.c:352
#3 array_init_size Zend/zend_API.h:509
#4 igbinary_unserialize_array ext/igbinary/src/php7/igbinary.c:2607
#5 igbinary_unserialize_zval_inner ext/igbinary/src/php7/igbinary.c:3626
#6 igbinary_unserialize_zval ext/igbinary/src/php7/igbinary.c:3535
#7 igbinary_unserialize ext/igbinary/src/php7/igbinary.c:788
SUMMARY: AddressSanitizer: 376 byte(s) leaked in 2 allocation(s).
(The two allocations are the zend_array struct and its bucket table,
i.e. the entire top-level array built by array_init_size at line 2607 —
not just a small scalar.)
Root cause
Two things compound to produce the leak:
-
igbinary_unserialize_array's per-element loop doesn't always go
through its own cleanup path. Most error exits inside the loop use
goto cleanup;, which runs:
cleanup:
zval_ptr_dtor_nogc(z);
ZVAL_NULL(z);
return 1;
(ext/igbinary/src/php7/igbinary.c:2637), which does destroy the array
built so far. But the error check right after unserializing the
value of a key/value pair does not go through this label:
// ext/igbinary/src/php7/igbinary.c:2726-2728
if (UNEXPECTED(igbinary_unserialize_zval(igsd, vp, WANT_CLEAR))) {
return 1;
}
This bare return 1; propagates the error without destroying z (the
array already allocated by array_init_size and holding this
function's partially-built HashTable).
-
igbinary_unserialize()'s own top-level cleanup doesn't destroy z
either. When the recursive call fails, the entry point does:
// ext/igbinary/src/php7/igbinary.c:786-813 (abbreviated)
if (UNEXPECTED(igbinary_unserialize_zval(&igsd, z, WANT_CLEAR))) {
ret = 1;
goto cleanup;
}
...
cleanup:
igbinary_unserialize_data_deinit(&igsd);
return ret;
cleanup: here only tears down the internal igsd bookkeeping
structure (string/object dedup tables, reference tracking); it never
calls zval_ptr_dtor/zval_dtor on z itself. So even if the inner
function had cleaned up properly, any caller relying on
igbinary_unserialize()'s own error path to leave z in a safe,
empty state would still be exposed if a different call site inside the
recursive descent skips its own local cleanup, as in (1).
Either fix on its own would close this particular reproducer; fixing only
(1) is the more surgical change and is consistent with how the function
behaves on every other error path in the same loop. Fixing (2) as a
defense-in-depth measure (have the top-level entry point always leave z
as IS_UNDEF/IS_NULL on failure, regardless of what the callee left
behind) would also close off any other call site with the same shape of
bug, including ones I haven't found.
Suggested fix
Change the bare return 1; after the value-unserialize call (line 2727)
to goto cleanup;, matching every other error exit in the same function:
if (UNEXPECTED(igbinary_unserialize_zval(igsd, vp, WANT_CLEAR))) {
goto cleanup;
}
I have not audited every other function in igbinary.c for the same
pattern (a bare return 1; that skips a local cleanup label while
z/the caller's zval already has data attached) — this may not be the
only call site with the same shape of bug. A grep -n "return 1;" pass
through each unserialize helper, checked against what each one has
already attached to its output zval by that point, would be worth doing
before considering this fully closed.
How I found this
Standalone libFuzzer + AddressSanitizer/LeakSanitizer harness calling
igbinary_unserialize() directly, built the same way php-src builds its
own sapi/fuzzer targets (see sapi/fuzzer/fuzzer-unserialize.c for the
existing pattern this mirrors). Seed corpus was a mix of hand-crafted
bytes following the format description in TECH_NOTES.md and real
igbinary_serialize() output for a range of PHP values (scalars, arrays,
objects, Serializable, __serialize(), enums, references, self-aliased
structures). First found in under 300 executions.