GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — igbinary_unserialize() leaks the partially-built value when unserialization fails mid-array (confirms #226) · Issue #425 · igbinary/igbinary

igbinary_unserialize() leaks the partially-built value when unserialization fails mid-array (confirms #226) #425

New issue
Copy link
New issue
Copy link
Open
Open
igbinary_unserialize() leaks the partially-built value when unserialization fails mid-array (confirms #226)#425
Copy link

Description

@tsushanth
tsushanth
opened on Oct 22, 2026
Issue body actions

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:

  1. 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).

  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      You are visitor 01402503 — GeezerVison does not store the pages you look up.
      Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.