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 — Self-referencing arrays can lose COW-violation protection again - regression of #308 · Issue #426 · igbinary/igbinary

Self-referencing arrays can lose COW-violation protection again - regression of #308 #426

New issue
Copy link
New issue
Copy link
Open
Open
Self-referencing arrays can lose COW-violation protection again - regression of #308#426
Copy link

Description

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

This is a regression of #308 ("PHP 8.0 debug nts builds fail when
unserializing circular array reference such as GLOBALS"), closed in
December 2021 by commit 307e80e ("Fix debug warning for unserializing
circular array reference"). That commit added exactly the
HT_ALLOW_COW_VIOLATION(h) call this report is about — right after
array_init_size(z_deref, n) in igbinary_unserialize_array(). It fixed
the case that was tested at the time (a small, simple circular
$GLOBALS-shaped array). It does not fix the general case: for an array
large/shaped enough that a real element gets inserted before the flag
would otherwise matter, the flag set by that fix is silently discarded on
the very first insert, for the structural reason explained below. The
assertion #308 was filed against is back, just with a different-shaped
trigger.

Summary

igbinary_unserialize_array() intentionally allows building a
self-referencing array (one whose own HashTable has refcount > 1 while
it's still being populated) by calling HT_ALLOW_COW_VIOLATION() right
after creating it. This is the same pattern PHP core's own
var_unserializer.c uses for the same reason.

The difference — and the bug — is in how the array is created. Core's
unserializer forces the array's internal storage to be really
initialized immediately (array_finit_size_mixed() →
zend_hash_real_finit()), specifically to avoid a known trap: PHP's
array_init_size() only reserves a size hint and leaves the
HashTable lazily uninitialized; actual storage (and a flags reset) is
deferred to the first insert. If HT_ALLOW_COW_VIOLATION() is called
before that first insert, the flag gets set on the placeholder — and is
then silently wiped the moment the table is actually realized.

igbinary calls plain array_init_size(), then HT_ALLOW_COW_VIOLATION()
— exactly the ordering core's unserializer avoids. As a result, for a
self-referencing array, the COW-violation allowance set by igbinary does
not survive past the array's first element insert. A later insert into
the same (still rc>1) array then trips Zend's COW-safety assertion:

Zend/zend_hash.c:1279: Assertion
`(zend_gc_refcount(&(ht)->gc) == 1) || ((ht)->u.flags & (1<<6))' failed.

Why this matters beyond a debug-build abort

In a debug/assertion-enabled build (ZEND_DEBUG/--enable-debug-assertions),
this is "just" a crash (SIGABRT) — a denial-of-service on malformed
input. But the assertion exists because, with assertions compiled out
(any ordinary production PHP build), the code it guards would proceed
without complaint: _zend_hash_index_add_or_update_i() would mutate a
HashTable that has refcount > 1 and no longer carries the
"this specific violation is sanctioned" flag. That's precisely the
condition PHP's copy-on-write model exists to prevent — mutating a
hashtable while something else may still be relying on its old
contents/structure. I have not constructed a concrete exploit from this
(see "What I have not verified" below); I'm reporting the confirmed
mechanism and its implication, not a demonstrated memory-corruption PoC.

Root cause (verified by reading the Zend sources, not just inferred from the crash)

  1. ext/igbinary/src/php7/igbinary.c:2607-2613:

    array_init_size(z_deref, n);
    h = Z_ARRVAL_P(z_deref);
    #if PHP_VERSION_ID >= 70200
        /* The array may contain references to itself, in which case we'll be modifying an
         * rc>1 array. This is okay, since the array is, ostensibly, only visible to
         * unserialize (in practice unserialization handlers also see it). */
        HT_ALLOW_COW_VIOLATION(h);
    #endif
  2. array_init_size() (Zend/zend_API.h:507) is just
    ZVAL_ARR(arg, zend_new_array(size)) → _zend_new_array() →
    _zend_hash_init_int() → _zend_hash_finit_int()
    (Zend/zend_hash.c), which unconditionally does:

    HT_FLAGS(ht) = HASH_FLAG_UNINITIALIZED;

    regardless of the nSize hint — the actual bucket storage is always
    allocated lazily, on first insert.

  3. HT_ALLOW_COW_VIOLATION(h) (Zend/zend_hash.h:72) does
    HT_FLAGS(ht) |= HASH_FLAG_ALLOW_COW_VIOLATION. At this point
    HT_FLAGS(h) is HASH_FLAG_UNINITIALIZED | HASH_FLAG_ALLOW_COW_VIOLATION.
    The table still has no real storage.

  4. On the array's first actual insert — the first call to
    igbinary_zend_hash_add_or_find() / igbinary_zend_hash_index_add_or_find()
    inside the element loop — Zend sees HASH_FLAG_UNINITIALIZED set and
    runs the real (lazy) initialization path:
    zend_hash_real_init_mixed_ex() / zend_hash_real_init_packed_ex()
    (Zend/zend_hash.c:~250,298), both of which do a flat
    assignment
    , not an OR:

    HT_FLAGS(ht) = HASH_FLAG_STATIC_KEYS;   // or HASH_FLAG_UNINITIALIZED cleared similarly

    This discards whatever flags were previously set — including the
    HASH_FLAG_ALLOW_COW_VIOLATION bit from step 3. From this point on,
    the array has no COW-violation allowance, even though it is still
    mid-construction and may still have refcount > 1.

  5. If the array's own refcount is later found to be > 1 (because
    something earlier in the same structure created a reference back to
    this array) at the time of a subsequent insert, Zend's assertion in
    _zend_hash_index_add_or_update_i() (Zend/zend_hash.c:1279) now has
    both of its OR'd conditions false, and fires.

How PHP core avoids this in the equivalent code

ext/standard/var_unserializer.c:1862-1874 (same self-referencing-array
scenario, same HT_ALLOW_COW_VIOLATION call) uses
array_finit_size_mixed(rval, elements) instead of array_init_size().
That helper (Zend/zend_API.h:571 → _array_finit_ex → Zend/zend_API.h:535)
explicitly calls zend_hash_real_finit(ht, packed, fallible)
immediately, forcing the table out of the lazy/uninitialized state
before HT_ALLOW_COW_VIOLATION() runs. The comment right above the
array-type branch even flags the hazard this avoids:

/* we can't convert from packed to hash during unserialization, because
   reference to some zvals might be kept in var_hash (to support references) */

igbinary's call site has the same hazard but doesn't use the same
precaution.

Proof of concept

Minimized by libFuzzer to 2007 bytes (available on request / in the
artifact set); it's a large string-keyed array (derived from a seed of
2000 "key_N" => N pairs) with a mutated element that creates a
duplicate reference back to the array itself, followed by enough further
inserts to hit the already-realized, now-unflagged table.

php-fuzz-igbinary: Zend/zend_hash.c:1279: zval *_zend_hash_index_add_or_update_i(HashTable *, zend_ulong, zval *, uint32_t): Assertion `(zend_gc_refcount(&(ht)->gc) == 1) || ((ht)->u.flags & (1<<6))' failed.

Symbolized backtrace (gdb):

#6  _zend_hash_index_add_or_update_i (ht=..., h=..., pData=..., flag=...)
      at Zend/zend_hash.c:1279
#7  igbinary_zend_hash_index_add_or_find (ht=..., key=8)
      at ext/igbinary/src/php7/igbinary_zend_hash.h:51
#8  igbinary_unserialize_array (...)
      at ext/igbinary/src/php7/igbinary.c:2713
#9  igbinary_unserialize_zval_inner (...)
      at ext/igbinary/src/php7/igbinary.c:3626
#10 igbinary_unserialize_zval (...)  at igbinary.c:3535
#11 igbinary_unserialize (...)       at igbinary.c:788

I have not yet reduced this to a hand-decodable minimal byte sequence the
way I did for the leak in the companion report — the smallest input
libFuzzer found still needs enough elements to force the array through a
state where it's both self-referenced and still being inserted into. I
can keep minimizing / hand-construct a cleaner reproducer if useful.

Environment

Same as the companion leak report: igbinary HEAD 2b1f5d6
(3.2.17RC1 + a few commits), php-src master d0fc9664 (reports as PHP
8.7.0-dev), Linux aarch64, --enable-fuzzer --enable-address-sanitizer --enable-debug-assertions --enable-igbinary --enable-session.

Suggested fix

In igbinary_unserialize_array(), replace the array_init_size() +
HT_ALLOW_COW_VIOLATION() sequence with the same pattern core's
var_unserializer.c uses: force real (non-lazy) initialization before
setting the flag, e.g. via array_finit_size_mixed() /
array_finit_size_packed() (Zend/zend_API.h) instead of
array_init_size(), matching whichever packed/mixed mode is appropriate
for how igbinary is about to populate the table. This closes the window
entirely rather than racing it.

What I have not verified

  • I have not constructed a PoC that demonstrates concrete memory
    corruption, a crash in a non-assertion (release) build, or any
    attacker-observable effect beyond the debug-build abort. The severity
    claim here rests on the Zend assertion's own stated purpose (preventing
    unsafe mutation of a shared HashTable), not on a demonstrated exploit.
  • I have not checked whether ext/standard/var_unserializer.c's own
    handling has some other call site with the same ordering mistake — it
    appears correct at the one call site I checked, but I did not audit the
    rest of that file or zend_gc.c's use of the same macro
    (Zend/zend_gc.c:2155).
  • I tested against php-src master (pre-8.7), not a released/stable PHP
    version. I have not confirmed this reproduces against, say, PHP 8.3 or
    8.4 — though nothing in the mechanism above looks version-specific; the
    lazy-init behavior of array_init_size() appears to be long-standing.

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.