Skip to content

fix(security): close every valid finding of the 2026-08-03 audit - #678

Merged
nahime0 merged 68 commits into
mainfrom
fix/security-round-0804
Aug 8, 2026
Merged

fix(security): close every valid finding of the 2026-08-03 audit#678
nahime0 merged 68 commits into
mainfrom
fix/security-round-0804

Conversation

@nahime0

@nahime0 nahime0 commented Aug 6, 2026

Copy link
Copy Markdown
Member

Summary

Resolves every valid finding of the internal audit dated 2026-08-03, across its A, B, C,
D, E, F, H, I and J sections, plus the defects found while fixing them. 59 commits.

The audit's two halves are memory safety (sections H/I/J) and PHP semantic parity
(A/B/C/D/E/F). Several of the parity findings turned out to sit on top of memory-safety
bugs that the audit had not reached — those are called out below, because they are the
reason this branch is larger than the audit.

Each finding was reproduced before being fixed and re-verified afterwards against
LC_ALL=C php 8.4 on the same fixture. Findings that turned out not to be valid, or not
implementable without a wrong answer, are listed under Deliberate limitations rather
than silently dropped.


Issues closed

Each was verified by running the issue's own reproducer against this branch and diffing
against PHP.

Issues addressed but NOT closed

Issues that main resolved while this branch was open

Issue whose failure mode this branch changes

  • array_fill() in a method with a property-derived count receives a garbage count (heap exhausted) #502still broken, new symptom, and narrower than its title. The trigger is not
    "a property-derived count": array_fill(0, $this->n, 0) is correct. It is arithmetic on
    property reads inside a method
    :

    $n = $this->w * $this->h;  array_fill(0, $n, 0);   // ValueError
    $n = $this->w + $this->h;  array_fill(0, $n, 0);   // ValueError — real count is 62

    Commit 79c1f12ae added PHP's ValueError for genuinely oversized counts, so the bug
    now surfaces as:

    before: Fatal error: heap memory exhausted
    after:  Fatal error: Uncaught ValueError: array_fill(): Argument #2 ($count) is too large
    

    Not a practical regression — the program dies either way — but the message is actively
    misleading (a count of 62 is reported as "too large"), and ValueError is catchable,
    so a try/catch would swallow a compiler bug. The same $n is correct in every other
    consumer in the same method (echo → 880, var_dumpint(880), $n + 1 → 881,
    str_repeat("x", $n) → 880), so this is specific to array_fill()'s count operand.
    Full reduction posted on the issue.

Issue re-verified and found fixed

Verified after all

  • empty(NAN) returns true on Linux x86_64 but false on ARM64/PHP #627 (empty(NAN) diverges on Linux x86_64) — fixed, including the boxed-Mixed
    audit the issue asked for. The x86_64 arm of emit_float_result_zero_bool now emits
    sete + setnp + and, so an unordered compare no longer materializes true;
    confirmed by cross-emitting the issue's repro with --target linux-x86_64 --emit-asm.
    __rt_mixed_is_empty and the eval-bridge comparator carry the same parity fixup, and
    tests/nan_bool_coercion_tests.rs pins empty(NAN)bool(false) end-to-end on both
    paths. Not executed on x86_64 from this ARM64 host — cross-linking fails at the runtime
    object, so the linux-x86_64 CI shard is the confirmation.

What is in the branch

Memory safety (audit H, I, J)

  • sprintf rebuilt. The formatter parsed the user's format string and handed program
    bytes to libc. %n was reachable from PHP source — an arbitrary write. The specifier is
    now parsed into numeric frame slots and no program byte reaches the C format string.
    This also removes an out-of-bounds read and an unbounded stack write.
  • Concat scratch bounded. Every writer into the 64 KiB _concat_buf now reserves
    through __rt_concat_reserve / __rt_concat_publish / __rt_concat_grow. This covers
    the string builtins and, in a second pass, the . operator itself, which segfaulted
    past 64 KiB.
  • Allocation size overflow. buffer_new<T> and the array_new family computed
    len * stride wrapped, allocating a tiny block with a huge header — bounds checks then
    passed against the pre-overflow length. Sizes that overflow a machine word are rejected.
  • Symbol and label collisions. Composite symbols and internal labels are now injective:
    a compact prefix_a_b when no fragment contains _, otherwise a ___ separator, which
    is provably impossible inside a mangled fragment (max underscore run there is 2).
  • Assembler operand escaping. A real .file directive breakout allowed defining an
    arbitrary symbol from source; operands are escaped and ELF output hardened.
  • Stack exhaustion. Unbounded recursion crashed. A guard now reports a controlled
    fatal. Leaf functions are not skipped — destructor and __toString re-entry make that
    unsound — so the cost is 5 instructions on AArch64 in every function.
  • Negative-length memory unsafety in array_slice, array_splice and array_pad.
  • fread / stream_get_contents unbounded writes.

Found while fixing the above, not in the audit

  • A use-after-free reading dynamic properties.
  • usort($obj->prop, …) silently did nothing — 16 of 32 receiver/builtin combinations
    were wrong.
  • An array_unshift heap buffer overflow that segfaulted.
  • A segfault on callable-dispatched shape-changing array builtins.
  • array_splice() on an indexed array<string> leaked a raw pointer into a PHP value
    ([1, 4362860248] where PHP gives [b, c]), because the helpers used 8-byte slots while
    string arrays use 16-byte {ptr,len} pairs. Pre-existing — reproduced on a build of the
    parent commit before fixing.
  • A by-reference receiver got no write-back at all, because source_load_local_slot only
    recognized LoadLocal. Every shape-changing builtin on a by-ref parameter produced a
    wrong answer, and array_unshift printed nothing (it grows, so it read storage
    __rt_array_grow had already freed).
  • A nullable-int property with a non-null default read garbage, and reading a ?int
    through a boxed receiver segfaulted (the payload was loaded into the register still
    carrying the object pointer).
  • base64_decode returned wrong bytes in three ordinary non-strict cases.

PHP semantics (audit A, B, C, F)

Catchable ValueError for builtin validation instead of memory corruption or an infinite
loop; division by zero, shifts and float-to-int conversion; float printing, numeric
strings, fmod and integer exponentiation; PHP_INT_MIN % -1 on x86_64; constant folding
made to match PHP; object and enum case rendering in var_dump/print_r/var_export;
loose equality for objects and arrays; unset() on declared and dynamic object
properties; ksort/krsort/asort/arsort, whose helpers were literally one ret each;
declare(strict_types=1) taking effect; generator key numbering, generators from methods,
keyed yield from, and the foreach object leak.

Syntax previously rejected (audit D)

Alternative control-flow syntax, <>, $this->n++, foreach destructuring, string
increment/decrement, string callables and PHP's scalar parameter coercion, func_num_args
/ func_get_args / func_get_arg, dynamic new with spread arguments.

Builtins (audit E)

New: join, substr_count, strtr, count_chars, str_word_count, stripos,
strripos, quoted_printable_encode, base conversion, the array internal pointer family,
PHP_ROUND_HALF_* and COUNT_RECURSIVE.

Widened to PHP's real parameter lists: array_slice(..., $preserve_keys),
array_chunk(..., $preserve_keys), array_splice(..., $replacement),
file_get_contents(..., $offset, $length), file($p, $flags), range($a, $b, $step),
array_reverse, array_search/in_array $strict, intval($v, $base),
ucwords($s, $separators), base64_decode($s, $strict), count($a, COUNT_RECURSIVE),
min/max over any element type and over a single array.


Deliberate limitations

Documented rather than implemented, because every representation tried produced a wrong
answer:

  • unset() on an untyped declared property. Three representations were tried. The
    blocker is the read, not the unset: PHP yields null afterwards, and an Int-typed
    fixed slot has no encoding for that.
  • [&$x] — a by-reference binding inside an array literal would dangle.
  • goto — 32 passes assume the statement tree describes the CFG.

Each raises an explicit compile error naming the reason, not a silent miscompile. The same
rule was applied to the new array_splice work: a type-changing replacement through a
by-ref receiver, and a Str-slot receiver with a replacement, refuse rather than publish
boxed cells through a slot the caller still reads as array<int>.


Follow-ups opened

Everything known-broken and left out of this branch has an issue, each with a reproducer
that was actually run:


Verification

Both targets throughout — every runtime helper landed with an ARM64 and an x86_64
implementation in the same change, and the x86_64 runs were executed under Docker rather
than asserted on assembly text. That paid for itself: they caught register-ABI bugs ARM64
could not see (__rt_heap_free and __rt_mixed_unbox take their argument in rax, not
rdi).

codegen_tests (full suite)                7831 passed; 0 failed; 105 ignored
lib                                       1233 passed; 0 failed
error_tests                               1288 passed; 0 failed
parser_tests / lexer_tests                 217 / 393 passed; 0 failed
builtin_parity_tests                         8 passed; 0 failed
eval_builtin_parity                         24 passed; 0 failed
cargo build / cargo build --tests            0 warnings
git diff --check                             clean

Ownership was validated with --heap-debug on the paths that allocate; the string-splice
and insertion fixtures report leak summary: clean.

Differential regression corpus. Every PHP fixture written while working through the
audit — 2030 of them, one per behaviour each fix was validated against — is replayed
against the pre-merge and post-merge compilers after each main merge, and any behavioural
difference is treated as lost work until explained. The last run was 2014/2030 identical;
of the 16 differences, 5 are fixtures that print closure handles as integers (ASLR, proven
non-deterministic under the same binary), 10 are shapes main newly supports, and 1 is a
pre-existing main defect (new $c(...$assoc) with a dynamic class name fails to compile
there too — verified against pristine main in a separate worktree).

eval/AOT parity is enforced, not bypassed. No entries were added to
STATIC_ONLY_REGISTRY_BUILTINS or any other allowlist; two pre-existing entries that were
suppressing the gate for array_count_values and constant were removed and the
missing eval-interpreter implementations written.

Notes for reviewers

  • This branch has been merged with main three times as it moved underneath (the builtin
    file split, parse_url, then the PDO parity work of feat: expand PDO parity across maintained PHP drivers #559). Conflicts that chose between
    two mechanisms rather than combining them are called out in the merge commit messages —
    notably the .comm directive (a new main common symbol arrived in the Mach-O-only
    spelling and had to be routed through the target-aware helper), ReceiverPlace vs
    source_load_local_slot, and the GuardTarget narrowing refactor.
  • The generated builtin documentation churn is isolated in its own commits — it is pure
    line-number movement, kept out of the substantive commits so they stay readable. After
    each main merge the pages are regenerated with gen_builtins +
    extract_builtins.py rather than hand-merged, with audit_builtins.py and
    validate_site_compat.py green.
  • CHANGELOG.md is maintained by hand in dedicated commits.

@github-actions github-actions Bot added area:builtins Touches PHP builtin declarations or emitters. area:magician Touches eval, include execution, or elephc-magician. area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. scope:multi-area Touches more compiler areas than the automatic area-label cap. size:xl Very large pull request that needs deliberate review planning. target:linux-x86_64 Contains behavior specific to the Linux x86_64 target. type:fix Corrects broken or incompatible behavior. labels Aug 6, 2026
nahime0 added 23 commits August 6, 2026 10:39
String and stream builtins appended their result at _concat_buf + _concat_off
with no bound, so any result over 64 KiB ran off the end and silently
overwrote the BSS globals that follow it: _concat_off itself, the stream
handle table, exception and fiber state, GC counters and the heap.

Adds a shared, target-aware reservation helper (__rt_concat_reserve /
__rt_concat_publish / __rt_concat_grow) that sizes the result up front,
rejects a size computation that wraps, and falls back to owned heap storage
when the scratch cannot hold the result, so large results are now produced
correctly instead of corrupting runtime state. Converted: the `.` operator
(which previously segfaulted past 64 KiB, and whose `.=` accumulation now
allocates and frees exactly one block per append), str_repeat, str_pad,
bin2hex, hex2bin, base64_encode/decode, urlencode, rawurlencode, urldecode,
addslashes, stripslashes, htmlspecialchars, html_entity_decode, nl2br,
wordwrap, str_replace, str_ireplace, substr_replace, number_format, fread,
fgets, stream_get_contents and stream_get_line.

str_repeat's `$length * $times` product is now overflow-checked instead of
wrapping past the scratch check, and the ARM64 heap bump check compares
unsigned so a size above 2^63 can no longer slip through.
…ites

__rt_sprintf copied each conversion specifier byte-for-byte into a 32-byte
stack buffer with no bound, so a long enough specifier reached the saved
frame pointer and return address (~290 characters), and the binaries carry
no stack canary. It then used snprintf's return value — the number of bytes
that *would* have been written — as the copy length out of a 128-byte stack
buffer, so any conversion wider than 128 bytes copied live stack memory into
the resulting PHP string, leaking pointers.

The specifier is now parsed into numeric frame slots; no program byte ever
reaches the C format string, which makes the overflow structurally
impossible. Padding is emitted by the helper, libc only renders the unpadded
numeric body into a bounded scratch, and the copy out is clamped. Widths
outside PHP's accepted range and results larger than the string buffer are
controlled errors instead of crashes.

Also closes a reachable %n: an unrecognized conversion character used to be
concatenated into the libc format, handing PHP source an arbitrary-write
primitive. Conversion characters are now a whitelist. Fixes %s truncating at
127 bytes, too-few-arguments reading past the pushed argument records, and a
format string with a long positional run sizing a Vec at compile time.

Adds the missing PHP conversions and fixes the divergent ones: %b, %F, %E,
positional %1$s, custom padding %'x, PHP's non-zero-padded %e exponent, and
%f of -0.0.
buffer_new<T> multiplied length by stride with no check and handed the
wrapped product to the allocator while storing the pre-overflow length in
the header, so buffer_new<int>(0x2000000000000002) allocated 32 bytes and
declared a length of 2^61. The bounds checks compare the index against that
header, so both reads and writes far outside the allocation passed them —
verified reading ~8 MB past the block with no error. A negative length
passed too, via the unsigned comparison.

The array path had the same flaw: __rt_array_new and __rt_hash_new sized
capacity * elem_size without a check, reachable from array_fill, range,
array_pad and SplFixedArray. range() could also overflow computing
end - start + 1. The intended guard message existed in the runtime data
section but nothing ever referenced it.

Every size computation is now overflow-checked on both targets before it
reaches the allocator: buffers reject negative and overflowing lengths
outright, arrays and hashes clamp a negative count to an empty payload and
report a controlled fatal on overflow. Clean heap exhaustion for legitimate
large-but-unaffordable allocations is unchanged.

Also fixes an x86_64 hash entry-zeroing loop that tested for equality
instead of a signed comparison, so a negative capacity looped ~2^64 times
writing out of bounds.
mangle_fqn escapes each name injectively, but the joins that build composite
symbols used a bare underscore separator, and mangled fragments contain
underscores. So class `a` with property `$u_b` and class `a_u` with property
`$b` both produced _static_prop_a_u_u_b, and the duplicate .comm directives
merged silently: two unrelated classes shared one storage cell (1,2 became
2,2). The same ambiguity hit methods, static methods and enum cases, where
it instead surfaced as an assembler duplicate-symbol error on valid PHP.

Static locals had two further problems: the initialization flag was derived
by suffixing the storage symbol with _init, so a PHP variable literally named
$x_init aliased the init flag of $x and defeated one-time initialization;
and the per-function fragment mapped every non-ASCII byte to an underscore,
so two distinct functions could share one static cell. Internal labels used
the same lossy fragment, in nine copied implementations, and could emit
duplicate labels that fail to assemble.

Composite symbols now go through one shared joiner: fragments that contain no
underscore keep the existing compact spelling, so the vast majority of
symbols are byte-identical; anything else is joined with a separator that
provably cannot occur inside a mangled fragment. Labels no longer depend on
the readable part at all — the counter is module-wide, and the nine
label_fragment copies are now one shared, documented decoration helper.

Also separates the static-local namespace from static methods, which both
used the _static_ prefix and could collide a .comm with a code label.
--debug-info spliced the source path into `.file` and `.asciz` directives
escaping only the double quote, and emitted the subprogram entry symbol as a
bare unquoted operand. A path containing a backslash broke the build
outright, and a crafted path escaped the string literal entirely: the pair
`\"` rendered as an escaped backslash followed by a real closing quote, so
the rest of the path was assembled as directives. Confirmed end to end by
feeding the emitted line to GNU as, which defined the injected symbol.

Every quoted operand now goes through the escaper that already backs the
runtime data section, and the unquoted symbol operand is validated instead
of spliced.

Linux executables and shared libraries are now linked with -z noexecstack,
-z relro and -z now. elephc assembles its objects with `as`, which emits no
.note.GNU-stack section, so GNU ld was inferring an executable stack (and
warning about it); nothing elephc produces needs one. macOS is unchanged:
ld64 rejects -z, and its binaries are already PIE with platform NX.

-static-pie is deliberately not adopted: it requires a libc built with
static-PIE support that several distributions do not ship, and a missing
rcrt1.o is a hard link failure. Documented, along with the fact that whether
a static Linux executable is position-independent is decided by the system
toolchain.
var_dump() printed an enum case as an ordinary object
(object(Status)#1 { ["name"]=> ... }) instead of PHP's enum(Status::Active),
and for backed enums it listed the value before the name. print_r() of an
object was not a wrong rendering but a hard compile error — "unsupported EIR
backend feature: print_r for PHP type Object" — and a nested object rendered
as nothing. var_export() of any object returned the empty string, which its
own documentation described as out of scope.

All three now match PHP byte for byte, including pure/int-backed/string-backed
enums, arbitrary nesting of objects and arrays at every indentation level,
print_r's visibility annotations ([p:protected], [p:Cls:private]) and
*RECURSION* marker, print_r's return mode, and var_export's three object
forms: (object) array( ... ) for stdClass, \Cls::__set_state(array( ... ))
otherwise, and \Enum::Case for enum cases.

var_export is an injected elephc-PHP prelude with no way to enumerate an
object's properties, so this adds four internal builtins for that; the value
accessor returns a freshly boxed cell so the caller can never free object
storage.

Also fixes a pre-existing leak in every program that var_exports a string:
the escape helper took `mixed`, so passing a string boxed it into a cell
nothing released.

Documents the one remaining divergence: all three renderers list an object's
declared properties only, so dynamic properties are not shown.
Division and modulo by zero returned 0 and INF instead of raising PHP 8's
catchable DivisionByZeroError, so a `catch (DivisionByZeroError $e)` block
never ran — the integer modulo path explicitly fell back to "return zero for
modulo by zero", and float division had no guard at all. On x86_64,
PHP_INT_MIN % -1 additionally raised SIGFPE, because the branch checked only
for a zero divisor before running idiv; ARM64 and PHP both give 0. The guard
already existed for intdiv() and is now shared by `%`, `/` and their
compound-assignment forms on every supported target.

Shift amounts were masked to 6 bits, which is raw hardware behavior on both
targets, so `1 << 64` was 1 and `1 << 100` was 68719476736 where PHP gives 0,
and a negative shift count silently produced PHP_INT_MIN instead of raising
ArithmeticError. Both operators now saturate like PHP (0 for `<<`, sign-fill
for `>>`) and reject negative counts.

abs(PHP_INT_MIN) wrapped to a negative integer; it now promotes to float the
way PHP does.

Float-to-int conversion diverged from PHP on both targets and from each other:
ARM64 fcvtzs saturates to INT64_MAX while x86_64 cvttsd2si yields INT64_MIN,
where PHP's zend_dval_to_lval gives 0 for NaN and out-of-range values. A
single shared helper now implements PHP's rules with integer instructions
only, so the two targets are bit-identical, and every conversion site is
routed through it — including float array keys, which previously let
$a[NAN] and $a[INF] exhaust the heap.

The IR effect model is updated to match: these operators can now throw, so
they are marked accordingly (otherwise dead-code elimination removed the very
catch blocks under test), with the effect refined away again when a literal
operand rules the error out, keeping them optimizable.
…es and namespace aliases

Four idiomatic PHP shapes were rejected at compile time.

The classic singleton failed with "return type expects Object(S), got
Union([Object(S), Void])": assigning to a nullable static property inside a
null-check branch did not narrow it, so the merge at the return still carried
the null half. Static properties are now narrowable places, isset() and !==
join === as guards, and a guarded branch that writes its guarded place joins
its exit fact with the guard complement. Return validation became
flow-sensitive in the process, which also closed a pre-existing hole where a
narrowing established late in a body was applied to earlier returns.
Genuinely unsound shapes stay rejected, including an intervening call that
could reassign the property, and static:: under late static binding.

isset($neverDefined) was an "Undefined variable" error, which defeats the
entire purpose of isset(). The spine of isset/empty/unset/??/??= may now
bottom out in an undeclared name, while index and property-name
subexpressions still report, matching PHP's own warning. Acceptance is
deferred to the end of the top-level pass, because a probed name is only
representable while it stays null for the whole scope.

Untyped closure parameters passed to array builtins were left Mixed and
checked against a fallback type, so usort with strlen() was rejected. Callback
positions that a builtin types contextually are now declared in one place and
skipped by the eager pre-inference passes, so array_filter, array_map,
array_walk and array_reduce get the same treatment usort already had.
uksort now types its comparator from the key type, array_walk passes the key
as a second parameter, and array_filter's USE_KEY/USE_BOTH slot uses the real
key type.

A namespace alias was not expanded in a qualified name, so `use App\Math as M;
M\double(5)` resolved to App\Main\M\double. The first segment of a qualified
name now expands through the import table for classes, functions and
constants alike, while a leading backslash suppresses expansion.
Several folds computed a different answer than PHP would, silently, at
compile time.

Integer comparisons went through f64, so `9223372036854775806 <
9223372036854775807` folded to false and `<=>` to 0; numeric-string
comparison had the same problem, making `9223372036854775806 ==
"9223372036854775807"` true. Comparison is now a port of PHP 8's
zend_compare and its numeric-string classification, with the overflow marker
that makes same-side-overflowed integer strings fall back to byte
comparison.

`switch` folded `case true` as the integer 1, so `switch (2) { case true: }`
took the default branch. It now uses the same loose comparison PHP does.

`PHP_INT_MIN % -1` panicked the compiler outright ("attempt to calculate the
remainder with overflow"), which is a debug-build crash and undefined
behavior in release. Every arithmetic fold that can overflow now either
produces PHP's exact result or declines to fold, which also fixed `6 / 3` and
`2 ** 3` folding to float, `1 << 64` folding to 1, and `-PHP_INT_MIN` wrapping
negative.

Folding an array-literal access compared raw key variants, ignoring PHP's key
normalization, so `[0 => "a", false => "b"][0]` folded to "a" instead of "b".

Constant propagation merged 0.0 with -0.0 because IEEE equality considers
them equal, so a branch selecting -0.0 printed 0. Identity now compares bit
patterns, in the propagator and in the two places that merge constants across
control flow. The dead-code guard evaluator had the mirror-image bug — it
compared float guards bit-wise, where PHP's === says 0.0 === -0.0 — so a
guarded branch could be pruned wrongly; it now uses IEEE equality. The audit
could not build a repro for that one; it needs the literal on the left.

`(float)"INF"` and `(float)"nan"` folded to INF and NAN because Rust's parser
accepts those spellings and PHP's numeric-string grammar does not. String
casts now follow PHP's leading-numeric-prefix rules.

Folding literal string comparisons is deliberately kept even though the
checker still rejects the runtime form (issue #507): the fold is
PHP-correct, so the literal case is now right and the inconsistency is
narrowed rather than widened. Documented under known incompatibilities.
…declared property

Three shapes of ordinary PHP failed to compile with "unsupported EIR backend
feature": `==` between two objects, `==` between an array and a scalar or
null, and `unset($obj->prop)`.

Adds three runtime routines implementing PHP 8's `==` table: a dispatcher
that applies the coercion rules in PHP's own order, a structural array
comparison that is count-checked and order-independent and probes for key
existence before reading a value (so a missing key never matches a stored
null), and an object comparison that is identity-first — which is also what
gives enum cases PHP's compare-by-identity — then class-equal, then a loose
per-property walk over the same descriptor the value renderers use. All three
carry an explicit depth limit and are reentrant.

`unset()` on an accessible declared property now clears the slot to exactly
the state a typed property without a default starts in, so the surrounding
behavior falls out of existing machinery: isset() is false, print_r and
var_export omit it, var_dump shows it uninitialized, reading it raises PHP's
"must not be accessed before initialization" Error, and assigning restores
it. __unset on inaccessible properties is unchanged.

Untyped properties and stdClass are deliberately still rejected, with a
diagnostic that now names the supported shapes: elephc's fixed per-class
slots have no "removed" state for an untyped slot, and every representable
encoding produced an observably wrong read or isset(). A loud compile error
is better than a wrong value; documented in docs/php/classes.md.

A cyclic object graph compared with `==` returns false past 256 levels
instead of PHP's "Nesting level too deep" fatal — the program terminates
normally rather than overflowing the stack. Documented.
…ntiation

Float output diverged from PHP under two separate rules. Echo and string
conversion use PHP's precision=14 formatting, which keeps a fraction in the
mantissa of exponential form and does not zero-pad the exponent, so PHP
prints 1.0E+300 and 1.0E-7 where elephc printed 1E+300 and 1E-07. var_dump
and var_export instead use serialize_precision=-1 — the shortest decimal
string that round-trips — and prefer plain notation over a much wider range,
so PHP prints float(1000000000000000) and float(9.223372036854776E+18) where
elephc printed float(1E+15) and a 14-digit approximation. Both rules are now
implemented, and the shortest-round-trip formatter had a bug of its own: when
the decimal exponent exceeded the significant digits it printed the double's
exact expansion instead of the round-trip digits zero-padded, so
json_encode, serialize and var_dump could each print a different number than
PHP for the same value.

String-to-number conversion accepted Rust and C spellings PHP's numeric-string
grammar does not, so (float)"INF" was INF, (float)"0x1A" was 26.0 and
(int)"1e400" saturated — and since the constant folder was already corrected,
a literal and a runtime value disagreed. A shared scanner now implements
PHP's grammar once, and is_numeric() consumes the same scanner instead of its
own, so the predicate and the casts can no longer disagree; is_numeric(" 42 ")
is now true as PHP says.

fmod() computed x - trunc(x/y)*y on ARM64, which loses the sign of a negative
zero result; it now calls libc fmod like the x86_64 path.

`2 ** 3` produced a float at runtime. PHP keeps an integer when both operands
are integers, the exponent is non-negative and the result fits, promoting to
float exactly at the overflowing multiply; that algorithm is now reproduced,
so the runtime agrees with the constant folder.

pow() the function still returns float, and `**` on a runtime numeric string
still promotes — both documented under known incompatibilities.
… looping

Several builtins skipped the argument validation PHP performs, and the
failure modes were memory corruption or hangs rather than errors.

str_pad() with an empty pad string read uninitialized memory into the result.
str_split() with a chunk length of 0 looped until the heap was exhausted, and
with a negative length crashed. random_int() with min above max returned a
garbage integer. array_fill() with a negative count built an array whose
header length was negative, so count() answered -1. str_repeat() with a
negative count was an uncatchable fatal — and worse, the call was eliminated
entirely when its result was unused, so a try/catch around it printed
nothing. number_format() with negative decimals produced garbage instead of
rounding to fewer significant digits, and its 48-byte formatting buffer was
overrun for large magnitudes, assembling the result from adjacent stack bytes.

All of these now raise the catchable ValueError PHP raises, with PHP's own
message text, emitted while the argument is still in its ABI register so one
guard covers every runtime variant on all supported targets. The builtins are
marked as able to throw, which is what makes catching them work.

Found and fixed in the same pass: explode("") and array_chunk($a, 0) each
looped until the heap was exhausted; str_pad() accepted an invalid pad type;
wordwrap() silently returned its input for two argument combinations PHP
rejects; mt_rand() with inverted bounds returned garbage while rand() should
swap them.

explode() gains its missing third parameter with PHP's full $limit semantics,
including negative limits, and the STR_PAD_LEFT/RIGHT/BOTH constants now
exist, so the new pad-type check is usable.

Not fixed here, and worth its own change: array_slice() and array_splice()
with a negative $length are memory-unsafe — the runtime uses -1 as a
"slice to end" sentinel, which collides with PHP's -1 meaning "drop the last
element", and other negative lengths are never clamped.
Completes 452f2e0, which added the Pow variant to the boxed numeric
operation enum but left the dispatch arm that maps it to its runtime helper
out of the commit, so that commit alone did not compile. Also brings in the
fmod negative-zero regression test that belonged with it.
…of crashing

Unbounded recursion in a compiled program was a raw SIGSEGV with the stack
pointer outside its mapping. PHP 8.3+ detects the same condition and reports
"Maximum call stack size reached", so elephc now does too, with the same
non-zero exit status.

Every function prologue compares the stack pointer against a low-water limit
and branches to a fatal helper — five instructions on AArch64, two on
x86_64, clobbering at most one scratch register and writing no memory. The
helper is entered by a branch rather than a call, so it needs no usable
stack. Leaf functions are deliberately NOT skipped: ops that look scalar can
re-enter user PHP through destructors, __toString and output handlers, so
"makes no call" does not imply "cannot recurse", and a wrong classification
would silently reinstate the crash.

The limit is computed once at process entry from getrlimit(RLIMIT_STACK) and
the entry stack pointer, minus the same 32 KiB reserve PHP uses. A failed
getrlimit, an implausible bound, or a wrapping subtraction publishes zero,
which disables the guard — an unknown bound degrades to the old behavior
rather than to false positives.

Fibers and generators run on separately allocated 256 KiB stacks at
unrelated addresses, where a main-thread floor would fire on the first frame,
so the fiber switch swaps the limit alongside the exception and cleanup chain
heads it already swaps, and each coroutine carries its own floor. The
existing PROT_NONE guard page remains as a backstop.

Measured on macOS arm64: with an 8 MiB stack, 52000 frames succeed and 54000
report the fatal; with a 1 MiB stack, 6000 succeed and 8000 report it, so the
floor tracks RLIMIT_STACK.

Two known limits: a cdylib has no process entry to run the initializer, so
the guard stays inert there, and eval'd PHP runs in the interpreter on the
Rust stack, outside the prologue guard. Like PHP's, this fatal is not
catchable.
…length

The runtime helpers used -1 as the "no length given" sentinel, which is
exactly PHP's most common negative length — "stop one element before the
end" — so `array_slice([1,2,3,4], 2, -1)` returned two elements instead of
one. Other negative lengths were never clamped, so the result carried a
negative header length: `count(array_slice([1,2,3,4], 0, -10))` answered -10,
`array_splice($a, 0, -10)` returned 32770 elements of out-of-bounds garbage
and emptied the source array, and a negative offset on the ARM64 splice path
crashed outright.

No 64-bit value can serve as the sentinel, because every one of them is a
legal PHP length and PHP_INT_MIN is observably different from an omitted
argument. "Length present" is therefore carried in its own argument register:
an immediate for a statically known operand, and derived from the boxed tag
when only the runtime knows whether the value is null — which also fixes a
runtime null length producing an empty result instead of the whole array.

The window arithmetic now lives in one shared emitter used by all four
helpers, so the offset and length clamps cannot drift apart again, and
neither addition can overflow because each mixes a non-negative operand with
a negative one.

Found in the same code and not fixed here: array_pad() with a large negative
size reports a negative count and crashes for PHP_INT_MIN.
…ed arguments

A generator's implicit keys restarted from 0 after an explicit key, so
`yield 5 => "five"; yield "six";` produced keys 5 then 0 where PHP produces 5
then 6. PHP tracks the auto-key counter the way an array does: a non-negative
integer key pushes it to key+1, while negative, string, float and bool keys
leave it alone, and PHP_INT_MAX does not wrap. `yield from` forwards inner
keys verbatim without advancing the outer counter — reproducing PHP's
duplicate keys — so delegated yields now take a variant of the suspend helper
that skips the bookkeeping.

Argument unpacking after a named argument is a compile-time fatal in PHP;
elephc compiled and ran it. The check now lives in the shared call-argument
planner, so every call surface inherits it rather than each emitter checking
locally, and the legal neighbours stay legal: a spread before a named
argument, several spreads, a string-keyed spread on its own, and a spread
after a positional argument.
…entable

__rt_array_pad negated its length argument unchecked. PHP_INT_MIN negates to
itself, so it stayed negative, the "already long enough" early-out fired and
the call silently returned an unpadded copy; a large negative length such as
-2000000000 instead passed the size check and asked for a 16 GiB payload
while publishing a header claiming two billion elements.

The length is now validated at the lowering site, before any allocation and
while the argument is still in its ABI register, so one guard covers both
helpers and every element type on every target. The check is on the signed
value, never on abs(), so PHP_INT_MIN fails instead of wrapping. PHP's bound
for this argument is 2^30 and is checked before it looks at the array, so
that is the bound elephc enforces, raising PHP's own catchable ValueError
with PHP's message text.

Adjacent error-class gaps left for a follow-up, all memory-safe today:
array_fill() and range() report heap exhaustion where PHP raises a ValueError
for a count above the maximum array size.
Completes 7d7ca2fc2, which left out the shared signed-magnitude guard the
lowering calls and the effect flag that makes the new error catchable, so
that commit did not compile on its own. Marking array_pad as able to throw
is required, not cosmetic: without it the optimizer hoisted the call out of
its enclosing try block and the ValueError could never be caught.

Also corrects the bound: PHP's limit for this argument is 2^30, checked
before it looks at the array, not the maximum array size.
…n/max

Four shapes of valid PHP were rejected by the parser or the checker.

`<>`, PHP's alias for `!=`, was not a token at all. It now shares the
not-equal operator, so folding, effects, inference and lowering follow
automatically, and it binds exactly like `!=`.

`$this->n++`, `++$this->n` and `$this->arr[0]++` failed to parse while the
same forms on any other object worked. `$this` was parsed by a dedicated path
that returned before the postfix/property loop, so property access on `$this`
was only reachable through the assignment-statement parser. `$this` now flows
through the ordinary primary-expression path, which also fixes `$this->n--`,
`$this->arr[$i]++`, `$this->obj->n++` and chained index increments.

`foreach ($rows as ["name" => $n])` and `foreach ($m as [$a, $b])` were
rejected although `list()`/`[]` destructuring worked outside foreach. Nested
patterns, keyed patterns, skipped elements and `$k => [$a, $b]` are covered.

`min()`/`max()` accept a single array, as PHP does, and raise PHP's ValueError
for an empty one. This is deliberately limited to indexed arrays whose
elements share one scalar type: strings, associative arrays and heterogeneous
arrays such as `min([1, 2.5])` are still rejected, because selecting the
smallest boxed element needs PHP's full comparison table. The limitation is
documented rather than silently wrong.

Increment and decrement of floats now work. Strings remain rejected, with a
diagnostic that explains why: `"9"++` is `int(10)` in PHP, so the local's type
widens, which a statically typed slot cannot express today.
Completes 37556dd90, whose documentation page was left out of the commit.
The operator page claimed mixed values increment like PHP. They do not: a
boxed mixed holding a string increments numerically instead of performing
PHP's perl-style alphanumeric carry, and no path implements the carry today.
Says so plainly, so nobody relies on `++` to advance a string.
`if: … endif;`, `foreach: … endforeach;`, `while:`, `for:` and `switch:`,
including `elseif:`/`else:` chains, were rejected with "Unexpected token at
statement position: Colon"; only `declare(...): … enddeclare;` worked. They
desugar into the same AST as the brace forms, so nothing downstream of the
parser changed.

`goto` and its labels now report that they are not supported, naming the
construct, instead of surfacing as "Unexpected token" followed by a cascade
of "Expected ';'". Implementing it for real would mean arbitrary intra-function
jumps, which the statement-shaped lowering passes (termination analysis,
flow-sensitive narrowing, loop pruning, constant propagation) are not built
for; rejecting it clearly is the honest outcome for now.

Reference elements in array literals (`[&$x]`) get the same treatment: a
diagnostic that names the construct and suggests `$b =& $a[0]`, instead of a
bare "Unexpected token: Ampersand". elephc's arrays are copy-on-write values
with nothing to pin a fresh literal's storage, so an alias into one could
dangle; a compile error is better than that.
`.comm`'s third operand means different things to the two assemblers:
Mach-O reads it as a power-of-two exponent, ELF as a byte count. elephc
emitted the Mach-O spelling on every target, so `.comm sym, 8, 3` asked
for 8-byte alignment on macOS and 3-byte alignment on Linux.

Under-aligned common storage assembles without complaint and only fails
at link time, once per program rather than once per symbol:

  relocation truncated to fit: R_AARCH64_LDST64_ABS_LO12_NC
    against symbol `_stack_limit' defined in COMMON section

That relocation encodes its displacement pre-shifted by 3, so it cannot
name an address that is not 8-byte aligned.

The defect predates this branch but was unreachable: no common symbol was
read through an alignment-sensitive relocation. The stack-exhaustion
guard is the first to do so — it emits `ldr x9, [x9, :lo12:_stack_limit]`
in every function prologue — which turned a latent misdeclaration into a
total linux-aarch64 link failure, all 16 CI shards.

All 172 emission sites now go through one target-aware
`comm_directive()`, and `DataSection::emit()`, `emit_runtime_data_user()`
and `emit_builtin_callable_data()` take the target that decides it.
`_empty_str` and `_url_stat_matched`, two hand-written byte-sized
symbols, are included: their own operand was equally ambiguous and
harmless only by accident, and leaving exceptions is how this class of
bug returns.

Verified by a sweep over the whole fixed runtime data section rather than
a spot check on `_stack_limit`, because the failure is invisible per
symbol — the sweep is what found those last two. The CI-failing test
links and passes on linux-x86_64 under Docker.
nahime0 added 5 commits August 6, 2026 15:14
`test_division_by_zero_inf` asserted that `1.0 / 0.0` prints `INF`. That is
pre-PHP-8 behaviour: PHP 5/7 warned and yielded INF, PHP 8 made every `/`
by zero throw, floats included.

  $ php -r 'echo 1.0 / 0.0;'
  PHP Fatal error:  Uncaught DivisionByZeroError: Division by zero

Commit 0790af3 made elephc match PHP here, so the test has been failing
on this branch since then. It went unnoticed because CI had never run on
the branch and the local runs were filtered to the areas under change.

`fdiv(1.0, 0.0)` still returns `INF` in both elephc and PHP — verified —
and it is the reason the old expectation looked plausible.
…'s span

`call_user_func($variable, ...)` on a statically resolvable builtin
produced a wrong result layout, and for a bool- or int-returning builtin
it segfaulted:

  $f = "count";    call_user_func($f, [1,2,3]);   // SIGSEGV
  $f = "in_array"; call_user_func($f, 2, [1,2,3]); // SIGSEGV
  $g = "array_reverse"; call_user_func($g, [1,2,3]); // bool(true)
  $s = "array_slice";   call_user_func($s, $a, 1, 2); // compile abort

The checker cannot resolve a variable callback, so it types the call
runtime-opaque `mixed` and records that under the *outer*
`call_user_func` span. Constant propagation runs afterwards and folds the
variable to a literal, so lowering does resolve the callee — and
`static_callable_builtin_result_type` then read the checked type back
from `expr.span`, which belongs to the dispatching expression, not to the
builtin. A raw bool return got labelled a boxed Mixed cell and
`is_truthy` dereferenced it as a heap pointer.

The checked-span type is now passed explicitly and is `None` on the
callable-dispatch path: a result type recorded at a span is authoritative
only for the builtin the checker actually examined there, and a builtin
reached through a callable binding derives its type from its own
descriptor and lowered operands.

Introduced by 5789c93, whose own subject was the `fallback_result_type`
layout; the span lookup leaked in with it. Surfaced by
codegen::lfc::lfc_mixed_strict_project_keeps_per_file_extensions_and_defines,
which passes on main.

There is no user-function-vs-builtin precedence bug: resolution is
source-mode dependent and already matches the direct-call rule — in
strict-PHP source the user function wins, in .lfc source the extension
builtin does, and shadowing a PHP-visible builtin is already rejected.
tests/codegen/strict_php.rs now pins that rule independently of the LFC
fixture.

All three targets. The four regression tests in expr_calls.rs were
confirmed to fail without the fix.
Eight codegen tests passed on macos-aarch64 and failed on linux-x86_64.
All three causes are the same shape: an x86_64 emitter calling a helper
with the AArch64 register assignment, or with a documented one that was
wrong.

str_word_count returned shifted, out-of-bounds words while the count
stayed correct:

  expected  6|Hello,friend,you're,looking,good,today
  x86_64    6| frie,, you', looki, good t, tod,!Fata

__rt_array_push_str takes the string pointer in rsi; the emitter passed
it in rax and left rsi holding the live scan cursor, which at that point
is the END of the word. Every word was therefore read from
word_start + word_len for word_len bytes, and the last ones ran past the
subject into the rodata that holds the fatal-error text.

ucwords died on a signal: it called __rt_strcopy with its own SysV
arguments untouched, but strcopy takes the source in rax and the length
in rdx — and rdx still held the separator POINTER, a multi-billion byte
count, so the copy left _concat_buf immediately.

Loose equality died on a signal for every array-versus-scalar case.
__rt_mixed_cast_bool and __rt_mixed_cast_float take their boxed cell in
rax, not rdi, because they forward it untouched to __rt_mixed_unbox. The
dispatcher loaded rdi only, so unbox dereferenced whatever was in rax —
the runtime tag left by the previous unbox, i.e. address 0x3, 0x4 or 0x8.

The docblocks of both cast helpers stated rdi, which is exactly what the
dispatcher followed, so the wrong documentation caused the bug. They now
state rax and say why. That is a doc-only change; no shared code needed
a behaviour change, and the AArch64 paths were correct throughout.

Verified in Docker on linux-x86_64 (20 passed, 0 failed, including object
loose equality, which shares mixed_loose_eq) and on macos-aarch64
(608 passed across strings, operators and objects).

Audited for the same bug class and found correct: __rt_obj_loose_eq and
__rt_mixed_array_loose_eq. Left alone but worth knowing: __rt_strtolower
takes its string in rax/rdx while __rt_ucwords takes SysV rdi/rsi — two
sibling string helpers with different input conventions.
`array_map_result_element_type_is_the_callback_return_type` proves that
array_map()'s checker result element type follows the CALLBACK's return
type rather than the input array's. It did so by mapping an int[] through
a bool-returning callback and passing the result into a `string`
parameter, expecting a rejection that names Bool.

That probe stopped erroring once coercive parameter binding landed
(ad22f63), and correctly so: bool -> string is a legal coercive
binding. Reference PHP 8.4 accepts it —

  $ php -r 'function f(string $s){return $s;} var_dump(f(true));'
  string(1) "1"

— and elephc now prints the same. With no error left, there was nothing
for the test to read the element type out of.

The probe now declares strict_types=1, where PHP does throw
`TypeError: ... must be of type string, bool given` and elephc reports
`expects Str, got Bool`. The assertion is unchanged, so the test still
proves exactly what it was written to prove.

The sibling `..._follows_a_string_callback` needs no directive: its "n1"
is a non-numeric string, which PHP rejects for an int parameter in both
modes.
Main split 397 new files out of the oversized modules while this branch was
open: ir_lower/expr/mod.rs went from 15050 lines to 318, lower_inst.rs from
7776 to 346, builtins/io.rs from 9222 to 170. Fifteen files conflicted, and
32 of this branch's 64 commits touch the split ones.

Conflicts were resolved by taking main's module structure and porting this
branch's net changes into whichever submodule now hosts the code. The
mechanical part of that is unreliable — a hunk header names the function
containing its FIRST line, not the one the added code belongs to, so
mapping targets by header sends every hunk to the wrong file — and a
partial application is silent. What follows was found by building and
testing, not by any conflict marker:

  - lower_generator_yield kept main's extra __rt_gen_suspend call, so every
    generator emitted a NULL after each yielded value.
  - materialize_mixed_slice_args lost the two lines that pass the
    length-present flag, so array_slice() through an untyped parameter
    segfaulted.
  - emit_sort_int/emit_sort_str take an ascending/descending flag and emit
    DIFFERENT symbols per call; deduplicating them left __rt_rsort_int
    undefined and broke every assoc-array program.
  - The `??` null-probe arm and the dynamic-constructor spread-after-named
    guard were lost to a directory-wide checkout.
  - yield from over an assoc array reverted to main's narrower rule.
  - `++`/`--` on a string or float reverted to a compile error.
  - file_get_contents, range and array_search kept main's narrow arity
    checks, rejecting the widened signatures this branch added.

Verified: the full codegen suite (6416 passed, 0 failed, codegen::eval left
to CI), error_tests 1282, parser_tests 393, ir_backend_smoke_test 255, the
magician lib 1144, every other test binary, and both eval/AOT parity gates.
Ten differential probes against php 8.4 cover the branch's own fixes —
string-array splice, splice replacement, by-ref unshift, ksort, the
file_get_contents window, DivisionByZeroError, sprintf specifiers, string
and float increment, generators, and array_slice through an untyped
parameter — all byte-identical.

Zero build warnings. Generated builtin docs regenerated against the merged
registry.
Resolves the five code conflicts by keeping both sides:

- ir/runtime_fn.rs: parse_url joins the MAY_THROW list rather than
  replacing it, so wordwrap() keeps the throwing effect this branch gave
  it while parse_url() keeps main's.
- ir_lower/stmt/typed_foreach.rs: main's lower_foreach_source composes
  with this branch's retain_object_foreach_source. The two are
  orthogonal — a borrowed hash element is never an object, and the
  object retain runs before IterStart while the element pin runs after.
- codegen/lower_inst/hashes.rs, magician string mod, CHANGELOG: additive.

parse_url's lowering moved a module deeper, so its arity check now
resolves through super::super:: like its siblings.

The 153 conflicting builtin pages are generated: regenerated with
gen_builtins + extract_builtins.py instead of hand-merged, both doc
gates green.
@nahime0
nahime0 marked this pull request as ready for review August 8, 2026 13:09
@nahime0
nahime0 requested a review from Guikingone August 8, 2026 13:09
@greptile-apps

greptile-apps Bot commented Aug 8, 2026

Copy link
Copy Markdown

Too many files changed for review (1625 files, 100 file limit).

nahime0 added 2 commits August 8, 2026 16:36
Aligns onto PR #559 (PDO parity). 11 code conflicts; the docs conflicts
are generated pages, regenerated rather than hand-merged.

Both sides kept where additive: web heap-guard flag alongside the stack
limit init, array_strict_eq alongside array_splice_str, main's
ScopedConstantAccess arm alongside the closure array-literal return
typing, and all 24 operator tests.

Three resolutions chose between mechanisms:

- codegen_support/runtime/data/fixed.rs: main's new _web_heap_guard_enabled
  arrived as a hardcoded `.comm ..., 8, 3`. Routed through comm_directive
  instead — the literal form is the Mach-O spelling and breaks the ELF
  link.
- codegen/lower_inst/hashes.rs: dropped main's source_load_local_slot.
  ReceiverPlace::resolve already covers LoadLocal and LoadRefCell and also
  decides where to write back, so the helper was dead. Verified against
  main's own repro (41-key hash written through a reference).
- types/checker/stmt_check/narrowing.rs: took main's GuardTarget refactor
  (is_array/is_callable) and re-grafted isset(), static-property receivers
  and php_symbol_key normalization on top. `negated` already folds the
  self-negating flag, so the old XOR at the use site collapses.

ir_lower/stmt/mod.rs: main moved the terminated-block guard up into
lower_stmt, above the new representation fixpoint; lower_stmt_once keeps
the array-pointer cursor predeclaration.

Dynamic `new` now falls back to main's runtime argument container for
call shapes that survive static spread expansion and planned dispatch.
Both broke the `codegen_tests` build, so every CI job that archives test
binaries failed before running anything.

- tests/codegen/operators.rs: the merge conflict cut through the middle
  of a test, so keeping both sides left an orphan function tail. Rebuilt
  the file as the real union — main's 77 tests plus the 22 only this
  branch has. The 69 shared ones are byte-identical on both sides, so
  taking main's copy loses nothing.
- tests/codegen/support/compiler.rs: auto-merged without a conflict but
  into an inconsistent shape. main threads `php_version` into the PDO
  prelude injection, and this branch had extracted that pipeline into
  `try_compile_source_to_asm_with_defines_repr` to return a Result;
  the parameter now goes through the extraction.
@nahime0
nahime0 merged commit e47a8e9 into main Aug 8, 2026
133 checks passed
@nahime0
nahime0 deleted the fix/security-round-0804 branch August 8, 2026 19:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:builtins Touches PHP builtin declarations or emitters. area:magician Touches eval, include execution, or elephc-magician. area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. scope:multi-area Touches more compiler areas than the automatic area-label cap. size:xl Very large pull request that needs deliberate review planning. target:linux-x86_64 Contains behavior specific to the Linux x86_64 target. type:fix Corrects broken or incompatible behavior.

Projects

None yet

2 participants