Skip to content

UAF / heap corruption in ext-decimal 2.0.1 on PHP 8.5 when result Decimals from div()->mul() chains are held across subsequent allocations #98

Description

@vlydev

Summary

Long-lived Decimal objects produced by ->div()->mul() chains crash with
zend_mm_heap corrupted / Aborted (or Segmentation fault with
USE_ZEND_ALLOC=0) after many subsequent Decimal allocations.

The crash typically fires at the destruction of the long-lived Decimal —
the next variable reassignment, the next iteration of an outer loop, or a
deliberate unset(). At that point php_decimal_free_object (or
zend_objects_store_del on its slot) operates on memory that another path
already freed → double free.

var_dump() on the affected Decimal succeeds right before the crash
(the object's structure prints correctly), which strongly suggests the
underlying mpd buffer / object-store slot is freed while the object zval
still references it.

Distinct from #93 / PR #96 (do_operation result-zval uninitialized for
unsupported opcodes): we reproduce without any unsupported opcode. We
also applied PR #96 (f8812dee) locally — valgrind output is byte-for-byte
identical except for line-number shifts.

Environment

PHP:         8.5.5 (cli, NTS, x86_64)
ext-decimal: 2.0.1 (also tested with master @ f8812dee — PR #96 merge)
libmpdec:    4.0.1
OS:          Alpine Linux 3.x (musl libc)
Container:   docker, php:8.5-alpine base image
Opcache:     loaded but enable_cli=0 (no CLI hooks active)
JIT:         disabled

php -m (relevant):

bcmath, decimal, opcache (CLI inert), pdo_pgsql, redis, intl, sockets,
zip, gd, imagick, grpc, rdkafka, sodium, memcached, opentelemetry,
xdebug, pcov, xhprof, simdjson

The bug reproduces with all of xdebug/pcov/xhprof/opentelemetry/imagick/grpc/rdkafka disabled — it's not a hook interaction.

Minimal reproducer

<?php
declare(strict_types=1);

use Decimal\Decimal;

// Mirrors the production shape: a class method that builds a result via a
// `div()->mul()` chain and returns it.
final class Calc {
    public function compute(Decimal $price, Decimal $vol, Decimal $mul): Decimal {
        $divided = $price->div($vol);
        $result  = $divided->mul($mul);
        unset($divided);
        return $result;
    }

    public function isOOB(Decimal $price): bool {
        return 0 > $price->compareTo(Decimal::valueOf('0.1'));
    }
}

$svc = new Calc();
$vol = Decimal::valueOf('1.02');
$mul = Decimal::valueOf('0.45');

for ($outer = 1; $outer <= 200; $outer++) {
    // Two chained-result Decimals; the first is held across the second's
    // internal allocations and across the third one below.
    $a = $svc->compute(Decimal::valueOf('15.99'), $vol, $mul);
    $b = $svc->compute(Decimal::valueOf('14.86'), $vol, $mul);

    if ($svc->isOOB($a) || $b->equals($a)) {
        $a = null;
    }

    $c = $svc->compute(Decimal::valueOf('15.50'), $vol, $mul);                                                                                      
    if ($svc->isOOB($c)) {
        $c = null;
    }

    // Cast all three at the end — this is the moment things explode in our                                                                         
    // production workload after ~9 outer iterations.
    $sa = $a !== null ? (string) $a : null;
    $sb = (string) $b;
    $sc = $c !== null ? (string) $c : null;
}
echo "DONE\n";

Run:

USE_ZEND_ALLOC=0 php repro.php

Standalone, this loop sometimes runs clean and sometimes crashes — the
behaviour is heisenbug-grade and depends on heap layout. Wrapping the loop
inside any larger script with more allocation pressure consistently crashes
within a handful of outer iterations.

valgrind output (representative cascade)

==NNN== Invalid read of size 8
==NNN==    at zend_objects_store_put
==NNN==    by zend_objects_new
==NNN==    by object_init_ex
==NNN==    by execute_ex
==NNN==    by zend_execute
==NNN==  Address 0x...  is N bytes inside a block of size 112 free'd
==NNN==    at free
==NNN==    by zend_objects_store_del
==NNN==  Block was alloc'd at
==NNN==    at malloc
==NNN==    by __zend_malloc
==NNN==    by _ecalloc
==NNN==    by php_decimal_alloc                (decimal.c:67)
==NNN==    by php_decimal_with_prec             (decimal.c:85)
==NNN==    by php_decimal_get_result_store      (decimal.c:402)
==NNN==    by zim_Decimal_mul / zim_Decimal_div (decimal.c:512 / :523)

Later in the cascade libmpdec is dereferenced from Decimal::toString():

==NNN== Invalid read of size 1 at mpd_isspecial   (libmpdec.so.4.0.1)
==NNN==    by _mpd_to_string                       (libmpdec.so.4.0.1)
==NNN==    by mpd_qformat_spec / mpd_qformat / mpd_format
==NNN==    by php_decimal_mpd_to_string             (convert.c:387)
==NNN==    by zim_Decimal_toString                  (decimal.c:883)
==NNN==  Address 0x... is N bytes inside a block of size 112 free'd
==NNN==    at free
==NNN==    by zend_objects_store_del
==NNN==  Block was alloc'd at
==NNN==    by php_decimal_with_prec / get_result_store / zim_Decimal_(div|mul)

Process eventually terminates with signal 11 (SIGSEGV) in zend_objects_store_put after ~270 valgrind errors across the same family of contexts.

The valgrind output without PR #96 vs with PR #96 differs only in
decimal.c line numbers (:401:402, :522:523 — exactly the +1
shift from the inserted ZVAL_UNDEF(result)). Same allocation site, same
free site, same use-after-free path, same SIGSEGV target.

Observations

  1. (string) cast and ->toString() both behave as if they return a
    zend_string whose val buffer aliases the Decimal's internal mpd
    storage
    , not a fresh independent copy. As soon as the source Decimal
    is destroyed, any later operation on the returned string (destructor on
    reassignment, second read, GC pass) operates on freed memory.

    • $s = (string) $d; $d = null; then touching $s reliably crashes
      under valgrind even though both var_dump($s) and the cast itself
      succeeded.
    • $s = ((string) $d) . ''; does not help — PHP 8.5 optimises
      concat-with-empty to a no-op, returning the original string with
      addref rather than allocating.
    • $s = substr((string) $d, 0); does work — substr allocates a
      new zend_string and copies bytes. This is the workaround we shipped.
  2. php_decimal_get_result_store() chain (line 402 in PR Fix segfault in do_operation handler (ext-decimal 2.0.x) #96 build) is
    the allocation site that consistently appears in the alloc backtrace.

    Both the refcount-1 reuse branch (returns $this) and the new-Decimal
    branch (php_decimal()) lead into the same UAF in the cron's workload.
    Replacing the function body with an unconditional return php_decimal();
    does not change the symptom.

  3. The aliasing seems to leak into the Decimal itself. Once we've cast
    a Decimal once, even reassigning that variable to a literal string
    triggers the destructor of the old Decimal and crashes there — i.e. the
    Decimal's mpd buffer was freed mid-flight, not just the returned PHP
    string.

  4. PR Fix segfault in do_operation handler (ext-decimal 2.0.x) #96 does not address this path. Its fix targets uninitialised
    result zvals after do_operation() returns SUCCESS for unsupported
    opcodes (ZEND_CONCAT, ZEND_BW_OR, ...). Our trigger fires without
    any unsupported opcode reaching the handler.

What we ruled out

  • ✅ PR Fix segfault in do_operation handler (ext-decimal 2.0.x) #96 applied (build from f8812dee): UAF signature unchanged.
  • ✅ All hook-style PHP extensions disabled in turn
    (xdebug / pcov / xhprof / opentelemetry / imagick / grpc /
    rdkafka): crash persists with bare decimal + opcache (inert) +
    pdo_pgsql + bcmath.
  • ✅ Opcache: enable_cli=0 (default in CLI), accel_startup bails early,
    no compile_file hook in this process.
  • ✅ Disabling the refcount-1 reuse optimisation in
    php_decimal_get_result_store() (forcing branch-1 unconditionally) —
    doesn't change the symptom.
  • ✅ Various string-copy tricks: (string) $d, $d->toString(),
    ((string) $d) . '', sprintf('%s', $d). The only one that produces a
    truly independent zend_string and avoids the UAF is substr(..., 0).

Workaround

Inside the producing function, never return the Decimal directly. Convert
to a string immediately (forcing a real allocation) and release the
Decimal locally:

// pseudocode-PHP
public function compute(...): string {
    $result = $a->div($b)->mul($c);                // intermediate Decimal
    return substr($result->toString(), 0);         // force independent zend_string
    // $result destructor runs here on a fresh mpd buffer — safe.
}

Replace any downstream comparisons that used Decimal::equals() /
compareTo() on the returned values with bccomp($a, $b, $scale). Don't
keep Decimals alive across the producer/consumer boundary.

Hypothesis

Z_OBJ_HANDLER(cast_object) / zim_Decimal_toString build a
zend_string whose val pointer references memory owned by the Decimal's
mpd (or shares an interned slot in the object store), rather than
allocating a standalone PHP string. When the Decimal object's refcount
drops, the underlying buffer is freed; the previously-returned zend_string
becomes a dangling reference. The destructor of that zend_string (or
PHP's automatic GC pass) then double-frees the slot, which is detected by
either Zend MM's inline checks (zend_mm_heap corrupted) or, with
USE_ZEND_ALLOC=0, by libc/musl directly (SIGSEGV).

A real PR-grade fix likely needs to ensure that string conversions allocate
a fresh zend_string (zend_string_init with byte-copy) instead of aliasing
the object's internal state — and that the object-store-slot bookkeeping
for Decimals correctly accounts for refcounts of those derived strings.

Happy to provide more focused reproducers, additional valgrind logs with
--track-origins=yes, or to test patches against this workload.

repro-decimal-uaf.php
vg-repro.log

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