Repository navigation
Allocation-failure call sites still on zend_error_noreturn(E_ERROR) on master (zend_strtod.c "Balloc() failed", zend_multiply.h) — the EOL-7.4 minimal mitigation hardened these, master did not #24563
Description
Context
While studying the fixes for GHSA-w8hw-w9fx-j79w / GHSA-6wmf-4rv5-p3rp (thank you for the
delayed-OOM machinery in 3584a98 — the design is excellent), we noticed a consistency
gap between the two fix branches:
- The EOL-7.4 minimal mitigation 712e60f converted several allocation-failure call
sites to the fail-fastzend_mm_safe_error_ex()path. - The full 8.x fix and current master kept several such call sites on
zend_error_noreturn(E_ERROR, ...), i.e. they still run the error machinery and bail
out via longjmp from the failing context — the exact pattern the advisory describes as
dangerous when the allocator is in a failing state.
This is an informational hardening-consistency observation, not a vulnerability report —
see the empirical section: we could not reach any of these paths from PHP userland under
the conditions we tested.
Call sites in question (master, checked 2026-10-22)
| Location | Code | Note |
|---|---|---|
Zend/zend_strtod.c ~590 / ~603 |
zend_error_noreturn(E_ERROR, "Balloc() failed to allocate memory") when MALLOC() (raw malloc, #define MALLOC malloc at ~237) returns NULL |
dtoa allocates Bigints outside Zend MM entirely (also unaccounted by memory_limit) |
Zend/zend_multiply.h ~343 / ~355 |
zend_error_noreturn(E_ERROR, "Possible integer overflow ...") |
size-arithmetic checks; 7.4 converted both |
ext/sodium/libsodium.c ~42 |
zend_error_noreturn(E_ERROR, "Memory allocation too large ...") |
untouched in both branches |
Empirical work (why we rate this informational, not exploitable)
We tried hard to reach the Balloc() path from userland before writing this up.
PHP 8.4.27, arm64 (docker), instrumentation at three levels — all evidence below is
reproducible and we are happy to share the tooling (a ~30-line LD_PRELOAD malloc
interposer, RLIMIT_AS sweep harness, phase-marker snippets):
- Phase markers (STDERR markers around string-build vs conversion): across an
RLIMIT_AS sweep at 1 KB granularity, every allocation failure occurred during
string construction (Zend MM chunk mmap), beforezend_strtodwas ever entered.
FAIL_IN_CONVERSIONcount: 0. The transition was a cliff, not a window
(67247 KB → fail in build, 67248 KB → full success). - strace (mmap/brk/mremap bracketed by the same markers): 0 memory-mapping
syscalls during the conversion phase, both with comfortable VA and at the boundary. - LD_PRELOAD malloc interposer (libc-call level, which strace cannot see):
conversion performs a constant 11 malloc calls / 428 requested bytes regardless of
a 1024× input-length difference (4,096 vs 4,194,304 mantissa digits). Controls:
marker-only run = 0 allocations; a low-levelfwrite()marker variant reproduced the
same 11/428B figure, ruling out instrumentation overhead. Preliminary return-address
attribution places one call nearzend_strtod+0xee4.
In other words: for the tested input domain, dtoa's memory need is bounded (digit
truncation before Bigint construction; freelist/private pool absorbs the rest), so
RLIMIT_AS pressure always surfaces in the string BUILD phase, where the new
delayed-OOM machinery handles it gracefully. We did not find a way to make the
libc allocator fail inside the conversion.
Questions
- Is leaving these on
zend_error_noreturnintentional — i.e. does the delayed-OOM
layer on master also cover a libc-malloc failure deep inside dtoa, making the 7.4-style
conversion unnecessary there? - If it is an oversight, would a small PR converting the four call sites above to the
fail-fast pattern (mirroring 712e60f) be welcome? Happy to send it. - Separately: is the fact that dtoa allocates via raw
malloc()(outsidememory_limit
accounting) considered acceptable? Our measurements suggest per-conversion usage is
tiny and bounded, so this may be fine as-is — asking for completeness.
Reproduction context
The interest in this area came from lab work on the php2shell chains (public PoC and a
public Metasploit module PR exist for CVE-2026-109618/109619). We validated the patched
build behavior end-to-end: on 8.4.27 the public chain dies at the heap-leak stage with
zero worker crashes, consistent with the fix cutting off stale-state reuse. No new
exploitation is reported or implied by this issue.
Activity
aasul commentedon Oct 24, 2026
I looked into the two
Balloc()failure paths inzend_strtod.cand have a small local patch replacingzend_error_noreturn()withzend_mm_delayed_error_or_panic(false, ...)from what I found, the current path goes through a fatal bailout when libc
malloc()fails, while the patched version terminates immediately. the delayed OOM handling doesn't seem to cover these allocations since they're outside Zend MMI tested this with controlled allocation failures and ran the relevant tests on NTS and ZTS debug builds, all passed. I haven't found a practical way to trigger this from userland though
I left the arithmetic overflow checks in
zend_multiply.hunchanged since they seem to be a separate casebefore opening a PR, would the fail-fast behavior make sense here or is the current fatal bailout intentional? the patch is only two lines so I'm happy to submit it if this is the preferred approach
aasul commentedon Oct 24, 2026
I looked further into the two
Balloc()failure paths inzend_strtod.cand tested a small local patch usingzend_mm_delayed_error_or_panic(false, ...)instead ofzend_error_noreturn()with controlled libc allocation failures, the original code can abandon an earlier allocated Bigint when the fatal bailout skips cleanup. I didn't find any memory corruption though, and a subsequent conversion still worked in my tests
the main concern with the patch is FPM behavior. the current fatal error allows normal request shutdown, while the panic path kills the worker immediately. it also bypasses PHP's normal error logging and backtrace reporting, and the message could be lost if worker stderr isn't captured
NTS and ZTS debug builds passed their relevant tests, but I haven't tested this in FPM or found a practical way to trigger the failure from userland
so I'm not sure the fail-fast behavior is worth the change just to avoid the abandoned allocation
would it be better to keep the current fatal handling and investigate cleanup instead, or is immediate termination still the preferred approach for libc allocation failures here?
the investigation and local testing were assisted by AI tools but reviewed by me.
srayuth089 commentedon Oct 25, 2026
Thanks @aasul — your findings match ours. Sharing the empirical data in case it helps the patch decision (all reproducible, PHP 8.4.27 arm64, tooling described in the issue body):
FAIL_IN_CONVERSIONcount 0 — theBalloc()paths never fired under any userland memory pressure we could construct.The abandoned-Bigint-on-bailout detail you found is interesting — consistent with dtoa's freelist/private-pool design, but it does mean the fatal path skips cleanup. On the FPM concern: our FPM probes likewise only produced clean OOM fatals / worker restarts, no corruption signatures.
srayuth089 commentedon Oct 25, 2026
Follow-up — a correction to our own earlier answer.
We reported that we could not reach these call sites from userland. That testing only covered the string→float direction (where every failure lands in string construction before
zend_strtodruns). The float→string (formatting) direction does reach theBalloc()failure path on stock patched builds:Balloc()E_ERROR (deterministic, 4/4 runs); ≤57000 → clean "Out of memory" before conversion; ≥63000 → success.Mechanism, which also explains why our earlier sweeps missed it: the well-known ~53-digit cap applies to the printed output only — internally dtoa is still driven with the full ini-requested ndigits (multi-MB transient allocations inside the formatting path). There is no large allocation phase before the conversion on this direction, so a memory-tight process fails inside dtoa's raw-
malloc()path and hits the un-convertedzend_error_noreturn(E_ERROR)sites this issue is about.Measured at comfortable VA, all formatting sinks cap the output to ~53 digits (var_export / serialize / json_encode / (string) / var_dump);
sprintf()additionally emits the guard notice;number_format()fails via the zend_mm path ("mmap() failed" / "Out of memory") instead. So the ini-driven formatting demand is the only measured route into theBalloc()sites.Attacker-reachability: requires control of
serialize_precision(INI_ALL) — an application that sets it from user input, or a deployment configured with a huge value. So this remains a hardening/consistency concern rather than a directly exploitable path — but it does answer the question this issue asked: yes, these call sites are reachable on stock builds, via the formatting side.srayuth089 commentedon Oct 25, 2026
Update: the
Balloc()failure path is reachable on 8.x — repro belowIn the original report we stated we could not find a reachable input for the
Balloc() failed to allocate memorypath on 8.x/master (evidence was outcome/phase/allocator-level only). We have since found a reachable reproduction on stock PHP 8.4.27 (CLI and FPM), triggered from the float→string formatting side, which we had not previously tested:Under
RLIMIT_ASjust above the process baseline this produces:php:8.4-cli, 8.4.27): BALLOC_FAIL at 57,600–60,800 KB, CONV_OK from 61,200 KB up (400 KB sweep resolution)php:8.4.27-fpmbehind nginx, unix socket,pm.max_children=1): window 188,500–192,000 KB against a 188,100 KB resting VSZ — the fatal fires over plain HTTP, mid-var_exportMechanism note:
serialize_precisiondoes not lengthen the output (var_export(0.1)still trims to the exact 57-digit expansion) — the large ndigits request drives the size of dtoa's internal Bigint allocations, which is what fails under address-space pressure. Control: the same snippet with the default ini passes cleanly at VA values where the 4M setting dies (full ladder in the evidence pack below).Measured impact (FPM): request-level fatal only. At every point in the window the worker survives (same PID) and subsequent requests — including float-heavy ones (
var_export,json_encode,serialize,(float)cast) — behave correctly. No crash or cross-request corruption observed at the functional level.Preconditions: we did not find a user-controlled precision sink (
sprintf('%.Nf')is truncated to 53 digits with a notice;number_format()fails through the graceful mmap/OOM handler), so this requires an app/config setting a hugeserialize_precisionplus a near-exhausted address space — not attacker-controlled in typical deployments.This confirms the error path is reachable on 8.x: the
E_ERRORmachinery runs while the allocator is failing, then bails out mid-dtoa — the exact pattern the 7.4-minimal branch avoids with_exit(1).Evidence pack — CLI ladder, control run, FPM table, sink guards
All runs on official Docker images; no ini changes beyond what is shown. Run shape:
ulimit -v <KB> && php -d memory_limit=-1 <snippet>; STDERR phase markers (P1 build / P2 input ready / P3 conv done) identify which phase a run dies in.CLI ladder — php:8.4-cli (PHP 8.4.27), va_kb 20000→72000
Fatal error: Balloc() failed to allocate memory(exit 255)Verbatim window rows (resolution 400 KB):
The phase markers confirm the fatal fires after the input is built, inside the
var_export()call.Control — same VA range, default ini
fmt_noini.php(identical, noini_set) across 58000–64000 KB: CONV_OK at every point — at VA values where the 4Mserialize_precisionrun dies in Balloc, the default-precision run passes. The ini value drives dtoa's internal allocation size (Bigint, sized by requested ndigits), not the output length.FPM persistent worker — php:8.4.27-fpm + nginx (unix socket), pm.max_children=1
ulimit -vapplied to the FPM master before exec (children inherit). Baseline worker VSZ: 188,100 KB. Trigger reached over plain HTTP; follow-up request doesvar_export,json_encode,serialize,(float)cast.Next-request output at every point:
check1=0.1 check2=0.1 check3=d:0.1; check4=float(1.5)— no cross-request anomaly at the functional level.Sink guard checks (why preconditions stay non-attacker-controlled)
aasul commentedon Oct 25, 2026
@srayuth089 thanks for the detailed follow ups and for finding a way to reproduce this on stock PHP
the distinction between string to float and float to string makes sense now. i hadn't tested the formatting path with such a large
serialize_precisionvalue so thevar_export()reproduction underRLIMIT_ASis really usefulthe FPM results are especially interesting since the fatal error only affects the request and the same worker continues handling subsequent requests correctly, at least in your tests
this makes me even less convinced that replacing
zend_error_noreturn()withzend_mm_delayed_error_or_panic()is the right approach since it would terminate a worker that currently survives the failurei think it would be worth looking into whether the large internal precision request can be handled earlier or whether the skipped cleanup can be addressed without changing the current fatal error behavior
thanks again for investigating this further, especially the FPM side which i hadn't tested myself
srayuth-dna commentedon Oct 25, 2026
Thanks @aasul — a few additions from our measurements that speak to both directions you raise.
On the worker-survival point: one more data point in favor of your concern: the same request-scoped containment holds on 8.4.26 as well — identical FPM behavior there (fatal in the failing request, same worker survives, subsequent requests correct). So the containment comes from the request-scoped teardown of the existing
E_ERRORpath rather than from anything the new machinery adds at this call site; switching it to a panic would indeed trade a survivable request-scoped fatal for a worker kill.On "the skipped cleanup": we ran a differential check for exactly this on the same worker: a 236-conversion battery (
var_export/json_encode/serialize/(float)casts, including subnormals and long-mantissa parses) produces a byte-identical digest before vs. immediately after aBallocbailout, on both 8.4.27 and 8.4.26, with no crash signals — i.e. at the functional level we could not observe any effect of the interrupted dtoa state on subsequent requests. (Honest caveat: that is functional-level evidence only; freelist-level effects beyond these probes can't be excluded.)On "handling the large precision request earlier": some numbers that may help size such a guard:
var_export(0.1)withserialize_precision=4194304yields 57 chars; the worst case across all doubles is the smallest subnormal (2⁻¹⁰⁷⁴), whose exact expansion is 757 chars (measured forvar_export,json_encodeandserialize).serialize_precision/precisionpads zeros, so nothing observable changes.formatted_printalready truncates%.Nfat 53 digits with "Requested precision of N digits was truncated to PHP maximum of 53 digits". The same guard pattern at the ini-driven consumers (var_export/var_dump/json_encode/serialize/float-to-stringecho) would remove the multi-MB Bigint demand at the source.For what it's worth, we have an FPM harness with an
LD_PRELOADallocation-failure injector plus the battery detector set up for this — happy to run any candidate patch through it and post results.aasul commentedon Oct 25, 2026
@srayuth-dna thanks for the additional testing, especially the comparison with 8.4.26 and the 236 conversion checks
the fact that the worker survives on both versions and the results remain identical after the bailout makes me think the current
E_ERRORhandling is worth preserving. of course the functional tests don't rule out everything happening internally with the freelist but they do make the immediate panic approach seem less justifiedthe precision cap idea is interesting and seems like a better direction to investigate. i'd still want to verify that a limit around 770 really preserves the existing output across all the relevant formatting paths and doesn't introduce any unexpected changes to
precisionorserialize_precisionit might also be worth checking whether the limit can be applied at a common conversion point rather than adding separate guards to every consumer
thanks for offering the FPM harness as well, that would be really useful for testing a candidate fix if we find a safe approach
srayuth089 commentedon Oct 25, 2026
@aasul on the "common conversion point" you asked about — there is one:
zend_gcvt()inZend/zend_strtod.c.Every float→string formatting path funnels through it:
zend_smart_str_append_doubleZend/zend_smart_str.c:159precisionarg (from serialize_precision)zend_double_to_strZend/zend_operators.c:3313PG(precision)php_snprintf/spprintf%g/%G/%H/%kmain/snprintf.c:944,main/spprintf.c:652var_dumpext/standard/var.c:1097PG(serialize_precision)json_encodeext/json/json_encoder.c:88PG(serialize_precision)soapext/soap/php_encoding.c:1088EG(precision)sprintf%g/%Gext/standard/formatted_print.c:302Right now
zend_gcvt()does no clamp onndigit— it passes the raw value straight tozend_dtoa(), which is where the multi-MB transient Bigint allocations come from when the ini value is huge.A one-point cap in
zend_gcvt()would cover all consumers at once:Why 770: IEEE 754 double has at most 767 significant decimal digits (including subnormals:
DBL_TRUE_MIN= 4.94e-324, which needs 324 decimal places + ~440 significant digits for the exact value).MAX_FLOAT_PRECISIONinformatted_print.cis 53 for the sprintf guard, but that's about output readability — the internal dtoa demand can safely be higher. 770 is comfortably above the worst case and still prevents the OOM path from being reachable via ini.What this fixes vs. doesn't:
Happy to test this on the FPM harness we described if you put together a candidate PR — we can run the 236-conversion byte-identity battery + the RLIMIT_AS boundary sweep against it.
aasul commentedon Oct 25, 2026
thanks for finding this,
zend_gcvt()does look like a much better place to handle the excessive precision than adding checks across individual consumersi checked the PHP 8.4 implementation and confirmed that it passes
ndigitdirectly tozend_dtoa()without a limitone small distinction is that some
%eand%fformatting paths usephp_conv_fp()instead, although that doesn't change the usefulness of this approach for the paths we're investigatingthe 770 limit looks worth testing, especially since it should avoid the huge allocations caused by unusually large
serialize_precisionvaluesi'd still want to compare the output before and after the change for subnormals, large doubles and precision values above the proposed cap before treating it as safe
i'll look into a small candidate patch first rather than opening a PR immediately
thanks for offering to test it with your FPM harness as well, that would be very useful for checking both worker behavior and output compatibility
aasul commentedon Oct 25, 2026
@srayuth089 i'll try putting together a small experimental patch that caps
ndigitat 770 inzend_gcvt()i'll first reproduce the original
Balloc()failure on unmodified PHP 8.4 usingserialize_precision=4194304andRLIMIT_AS, then compare the same conditions with the patch to see whether it prevents the failure and reduces memory usagefor compatibility i'll compare the exact output before and after the change using precision values of 17, 53, 770, 771, 1000 and 4194304 across
var_export(),serialize(),json_encode(),var_dump()and normal float to string conversioni'll include values like
0.1,PHP_FLOAT_MAX, the smallest subnormal, negative zero, INF and NAN, along with some randomly generated finite doubles to look for differences that the usual cases might missi also want to check the negative precision behavior and the
%gformatting paths to make sure the cap doesn't affect anything unexpectedif those comparisons pass i'll test the patched version under FPM with repeated failing and successful requests, check worker PID continuity and run the relevant NTS and ZTS tests
i'll share the actual measurements, any output differences and the experimental diff here before opening a PR. if the results look promising it would be useful to compare them against your FPM allocation failure harness as well
aasul commentedon Oct 25, 2026
@srayuth089 i've pushed the experimental 770 precision cap and the regression test to a separate branch on my fork
here's the branch: https://github.com/aasul/php-src/tree/investigate/gh24563-gcvt-precision-cap
the initial NTS, ZTS and FPM comparisons look promising with no output differences found in my tests
if you have time to run it through your FPM harness and the 236 conversion battery i'd appreciate the independent results before i open a PR
thanks again for helping investigate this
aasul commentedon Oct 25, 2026
@srayuth089 just to share some of the actual results from my local testing
with
serialize_precision=4194304the largest conversion malloc dropped from 4194332 bytes to 1052 bytes after applying the 770 cap, and peak tracked live allocation went from about 4 MB to 1212 bytesi compared the output across 387 test value entries including subnormals, large doubles, negative zero, INF, NAN and random finite doubles using different precision settings and formatting functions. no byte for byte differences were found
under the same tight
RLIMIT_ASconditions the baseline FPM workers produced 20Balloc()fatals each while the patched workers completed those conversions. the full FPM comparison covered 648 requests and subsequent requests continued working correctlythe relevant NTS and ZTS tests also passed with 412 passes and no failures each
the main limitation is that i've only tested Linux x86_64 so far and the patch addresses excessive precision allocations rather than all possible
Balloc()failuresthese are the results from my initial local investigation so independent testing with your harness would be really useful before moving forward
srayuth089 commentedon Oct 25, 2026
@aasul independent verification complete — all four test categories pass on your branch (
investigate/gh24563-gcvt-precision-cap, commit1e56158, built asPHP 8.4.28-devon macOS arm64):1. Balloc failure prevention (RLIMIT_AS sweep at the previously-failing window):
2. Output compatibility (precision 17/53/770/771/1000 ×
var_export/serialize/json_encode/(string) × normal/subnormal/MAX values):3. Full battery — 302 conversions (23 edge-case values + 50 long-mantissa strings + 30 subnormal steps, through all four formatting paths at
serialize_precision=4194304):4. Spot check at precision=4194304 (the exact setting that previously triggered Balloc):
Your memory measurements (4.2 MB → 1052 B largest malloc, 4 MB → 1212 B peak) are consistent with what we'd expect from the cap eliminating the Bigint chain for 5^n.
Platform note: this verification is on macOS arm64 (native compile, not emulated).
aasul commentedon Oct 25, 2026
@srayuth089 thanks again for independently testing the patch and sharing the results
i've opened the PR now: #24688
i included both my Linux x86_64 results and your macOS arm64 verification in the description, along with credit for your suggestion and testing
the CI checks are running now so i'll see what comes up during review
really appreciate your help with this, your findings made a big difference in getting the patch to this stage