This is a 16 KB name:

$row = 'Smith, ' . str_repeat('V ', 8188);
(new \Iliaal\NameParser\Parser())->parse($row);

On nameparser 1.5.0, the last version before the fix, that call takes 17.4 seconds of CPU on my i9-13950HX. On 1.7.0 it takes 0.064 seconds. Each V is a candidate roman-numeral suffix, and the old suffix scan walked back over every earlier token for each one, so the cost grew with the square of the input. Nothing about it is exotic. It is a plausible shape for a junk row in a CSV import, and it pins a PHP worker for as long as the row keeps coming.

I didn't find it. A security scan did, and the same scan ran across all ten of my PHP extensions as well. In the first three weeks of October each project got a full pass: one coordinating session, a set of subagents reading code and writing proof-of-concept exploits, and a separate verifier that re-ran every finding before it counted, on its own ASan and release builds for the C code. Everything below ships in releases from the last few weeks.

Two other things landed in the same window. PHP's October security releases changed what memory_limit does, which forced code changes in three extensions and a same-day round of re-releases in five more. And DuckDB 2.0 went GA; pdo_duckdb bundles it as of today.

What the scans found, and what is fixed

Every project in the table has a release that carries its fixes. The counts are findings confirmed by the verifier, fixed, and released; severities are the verifier's, not the scanner's first guess.

Project Fixed in Confirmed and fixed
phpser 0.7.1 19: 1 high, 5 medium, 13 low
mdparser 0.8.0 12: 1 high, 5 medium, 5 low, 1 informational
fastchart 1.8.0 15 reproduced, highest medium, including vendored plutovg and plutosvg; two UBSan sites in vendored code only partly addressed
php_clickhouse 0.13.0 1 high, 1 medium, plus low-severity binding fixes and 7 server-triggered crashes in clickhouse-cpp
pdo_duckdb 0.8.0 6: 1 high, 1 medium, 1 low-medium, 2 low, 1 informational
fastjson 0.10.0 4: 1 medium, 3 low, plus 1 documentation correction
php_excel 2.9.0 5: 3 low, 2 informational
statgrab 2.3.0 4: 2 medium, 2 low, plus a build bug
fast_uuid 0.8.1 5: 4 low, 1 by-design behaviour changed anyway
phonetic 0.4.4 3: 1 medium, 2 low
nameparser 1.6.0 5: 1 medium, 1 low, 3 informational

A few findings in upstream libraries are reported privately to their maintainers and aren't described here until fixes ship.

The coordinating model was Mythos 5.1, but most of the work ran on other models: on fastjson, 695 of the 702 assistant messages came from Opus 5.5 subagents doing the reading, fuzzing, and PoC writing. The phpser scan doubled as an A/B/C test of my own tooling against a bare prompt, which I wrote up on X at the time. So the table measures the process more than any one model.

fast_uuid and phonetic came back with zero memory-safety bugs, and php_excel's wrapper with nothing above low severity.

Bugs that needed no attacker

The worst php_clickhouse finding in practice wasn't the high-severity one. It was B-4, which corrupted data on ordinary queries. Decimal and DateTime64 decoding cached each column's scale in a memo keyed on the raw address of the column's type object. clickhouse-cpp allocates a fresh type object per column per block and frees the block after each callback, so the next block's first column could land at the same address and inherit the previous column's scale. Select a scale-2 column next to a scale-6 one with max_block_size=100, and a stored 12.34 came back as 0.001234, with no error. With default settings the verifier still saw 1 wrong cell in 70,000 rows.

The test suite never caught it because ASan builds hide it: the sanitizer's quarantine stops freed memory from being reused, so the address never repeats. It took a release build and a real server. 0.13.0 keys the memo correctly.

0.13.0 also fixes a behaviour you should check for before upgrading. A whole-number insert into a Decimal column, 7 into Decimal(9,2), now stores 7.00 as the server does; 0.12 stored 0.07. Code that compensated by passing pre-scaled integers (700 meaning 7.00) will now store 700.00 without complaint.

fast_uuid's main finding has the same character. uuid1() and uuid6() with a fixed node and clock sequence kept no last-tick state, so uuid1(1, 7) called 500,000 times produced 61,723 duplicates. The default arguments were fine. 0.8.1 fixes it.

Bugs a server, a file, or a document could trigger

php_clickhouse's high-severity finding was a heap overflow write. With compression enabled, a malicious or compromised server could send a compressed frame whose declared size was smaller than the 9-byte frame header, and the client wrote past its buffer with bytes the server chose. clickhouse-cpp had already fixed it on master in PR #566, but no clickhouse-cpp release contains the fix yet, and php_clickhouse's prebuilt binaries ship the library. 0.13.0 carries the patch. The same release rejects seven more server-triggered crash shapes, from FixedString(0) columns to Array offsets that run backwards, and stops a packet of a few dozen bytes from committing gigabytes of memory outside memory_limit. I sent the fixes upstream as 15 pull requests; three have merged so far and twelve are open.

fastchart's two medium findings were both resource bombs. A 44 KB SVG passed to svgTo*() reached 2,121,264 KiB of resident memory through data URIs and <use> references that slipped past the input gate, all of it outside memory_limit; a 30,000-deep <use> chain crashed the process. A 1 MB CgBI PNG with decoy dimensions decoded into a 16000 by 16000 buffer. 1.8.0 runs the gate on the parsed tree, bounds <use> depth and expansion, and caps decoded images at 4,096 pixels a side. Some heavy but legitimate SVG that used to render slowly now throws a ValueError instead, so test your largest inputs.

mdparser's high-severity finding was quadratic parsing: blank lines inside deeply nested lists took 1.7 seconds at 200 KB and 55 seconds at 1 MB. Seven of mdparser's thirteen findings had their root cause in md4c rather than in my code, and the fixes went upstream; md4c merged five of my pull requests for them on October 13. One remaining low-severity signed overflow is still waiting in md4c#374. 0.8.0 also changes generated heading IDs to start with user-content-, which closes a DOM-clobbering finding and breaks existing links to heading fragments when headingAnchors is on.

The other high and medium findings

pdo_duckdb's high finding was a use-after-free: an appender left open inside a reference cycle could outlive its PDO handle when gc_collect_cycles() or request shutdown freed the handle first. Two of the medium-range findings were the same shape. A memory_limit bailout at the wrong moment during column setup left uninitialised type pointers that were destroyed later; the PoC got the sanitizer to report a destructor running on 0x4141414141414141. 0.8.0 fixes all six.

phpser's high finding was that a nested SPL unserialize() ignored allowed_classes. Its legacy handler path ran outside the decoder's context, so a class allowlist set on the outer call never reached the inner one. 0.7.1 fixes it along with eighteen others, most of them bounds on work an untrusted payload could demand.

nameparser's quadratic case, from the top of this post, is fixed in 1.6.0. Its release notes say plainly that earlier notes describing hostile-input parsing as linear "did not hold for these shapes". phonetic's medium finding was a 14-byte input, bmpm("de akeaxhaeasz"), that caused an uncatchable fatal error. 0.4.4 turns budget overruns into a catchable ValueError and caps BMPM output at 256 times the input size, after a 1,560-byte input had produced 3.9 MB.

memory_limit stopped stopping things

PHP 8.2.35, 8.3.36, 8.4.27, and 8.5.12 shipped on October 21 with the fix for CVE-2026-109618, rated critical. Part of that fix changes what happens when an allocation crosses memory_limit. The engine used to bail out on the spot with a longjmp. Now it records the error, lets the allocation succeed, and raises the fatal at the next safe point in the VM.

For PHP code that's invisible. For an extension it isn't. A C loop that decodes a payload or builds rows never reaches a VM safe point, so it keeps allocating past memory_limit until it hits a hard limit, by default twice memory_limit below 1 GiB. At the hard limit the engine calls _exit(): no shutdown functions, no destructors, no session write, and php-fpm loses the worker instead of failing one request.

So every loop whose length comes from input has to notice the pending error and stop on its own. This is how phpser does it:

/* PHP 8.2.35, 8.3.36, 8.4.27 and 8.5.12 queue a memory_limit error instead of
 * bailing out; the allocation still succeeds until a hard limit (2x the soft
 * limit below 1G) that _exit()s the process. Loops that keep allocating must
 * stop on their own once the error is pending. */
#ifdef ZEND_HAS_OOM
# define PHPSER_HAS_OOM() ZEND_HAS_OOM()
#else
# define PHPSER_HAS_OOM() 0
#endif

A loop check cannot help when a single allocation is sized from a count the payload declares. Those go through the new fallible allocators instead (zend_hash_real_finit(..., true), safe_femalloc, fecalloc), which queue the error and return NULL rather than running into the hard limit. phpser 0.10.0 uses both. mdparser 0.8.1 and pdo_duckdb 0.9.0 do the same for their input-sized loops and presizes.

The re-release day

There is a catch. The October headers route smart_str growth through a new engine function, smart_str_ferealloc(), from inline header code. An extension compiled against 8.4.27 headers imports that symbol even if it never mentions it, and 8.4.26 doesn't export it. My release workflows built prebuilt binaries against the newest patch of each PHP minor, which had always been the safe choice.

On October 26 that meant mdparser 0.8.1, phpser 0.10.0, fastjson 0.10.1, php_clickhouse 0.13.1, and phonetic 0.4.5 all shipped Linux and macOS binaries that died with undefined symbol: smart_str_ferealloc on every PHP 8.4 before 8.4.27 and every 8.5 before 8.5.12. Source builds were fine, because they compile against the PHP they load into. All five were re-released the same day as mdparser 0.8.2, phpser 0.10.1, fastjson 0.10.2, php_clickhouse 0.13.2, and phonetic 0.4.6, and fastchart 1.8.1 picked up the same change before it could break. Their prebuilts now compile against the oldest patch of each minor (8.4.1, since 8.4.0 was never released, and 8.5.0), and each binary has to load on both the oldest and the newest patch before anything uploads.

That fix has a cost, and the phpser changelog states it: a binary built against 8.4.1 headers doesn't have the memory_limit stop. To get it, you build from source on a patched PHP.

Refusing vulnerable PHP releases

The longer-term answer is the opposite trade. pdo_duckdb 0.9.0 makes it today: its prebuilt binaries are built against the patched headers and require PHP 8.2.35, 8.3.36, 8.4.27, or 8.5.12 or newer. Load one on 8.4.26 and the process stops at the first call:

php: symbol lookup error: /ext/pdo_duckdb.so: undefined symbol: smart_str_ferealloc

That message is honest but unfriendly, and it arrives at runtime rather than at startup. The next releases of pdo_duckdb, phpser, and mdparser fix that with a startup check. On PHP 8.3 and newer, the module compares the running patch with the one it was compiled against and refuses to start on an older one, naming both versions. This is mdparser's:

if (php_version_id() < PHP_VERSION_ID) {
    zend_error(E_CORE_WARNING,
        "mdparser was built for PHP %s and cannot run on PHP %s; "
        "install a build for this PHP version or build from source",
        PHP_VERSION, php_version());
    return FAILURE;
}

With that check in place, pdo_duckdb keeps the binaries it already builds against the patched headers, and phpser and mdparser can move theirs back to the patched headers and regain the memory_limit stop. A server still on 8.4.26 gets a clear startup warning instead of a crash on the first request, and with a critical CVE fixed in 8.4.27 it should move anyway.

pdo_duckdb 0.9.0: DuckDB 2.0

DuckDB 2.0.0 went GA on October 21, and pdo_duckdb 0.9.0, out today, bundles it in every prebuilt binary. 0.8.0 had already built against the 2.0 previews. Source builds still work against DuckDB 1.5.3 and newer; configure detects 2.0 from duckdb_v2.h, because the 2.0 headers still report C API version 1.5.6, and it refuses to build when 2.0 headers meet an older libduckdb.

The upgrade is not transparent, and the README has a full section on it. The one most likely to bite is the file format. DuckDB 2.0 writes new files at storage version 69, and DuckDB 1.5 reads only 64 to 68. If a 1.5 process has to read the file too, ask for the older format when you open it:

$db = new PDO('duckdb:/data/shared.duckdb;storage_compatibility_version=v1.5.6');

On the 0.9.0 binary, a file opened that way reports v1.5.0+ in duckdb_databases(), and a default one reports v2.0.0+. Existing 1.5 files keep their format after 2.0 writes to them. The rest is DuckDB's own behaviour: a failed statement now invalidates the whole transaction, integer division by zero throws, single-arrow lambdas are rejected, and the new PEG parser is the default. That last one has a sharp edge for parameter binding: $1_0 means parameter 10 under PEG and parameter 1 under the legacy parser. Each change has a setting that restores 1.5 behaviour.

0.9.0 also adds PDO::DUCKDB_ATTR_NATIVE_STRUCTURES, an opt-in that fetches LIST, ARRAY, STRUCT, TUPLE, MAP, UNION, JSON, and VARIANT values as lossless PHP structures instead of canonical strings, with exact numerics, binary keys, and SQL NULL kept distinct from VARIANT null. VARIANT works top-level and nested, and the appender accepts PHP arrays and objects for it.

One fix in 0.9.0 matters even if you never touch 2.0: two PDO connections to the same database file could lose writes and see stale reads. Equivalent paths now share one DuckDB instance while transactions stay connection-local.

Performance, with the hardware attached

Most of the speed work came before the scans. Apart from the nameparser row, which I re-ran for this post, these are the numbers from each project's changelog with the conditions it states.

Project Change Measured on
nameparser 1.6.0 16 KB hostile row: 17.4 s to 0.064 s i9-13950HX, PHP 8.4.27 (my rerun of 1.5.0 vs 1.7.0)
phpser 0.6.0 range(0, 9999) encodes to 7 bytes; encode 57% faster, decode 66% faster aarch64
phpser 0.7.0 Signed decode 53 to 85% faster; 1,000-row rowset decodes in 429 KB instead of 645 KB aarch64
fastjson 0.9.0 fastjson_validate() costs 0.47 to 0.90x a full parse, down from 1.1 to 1.9x Neoverse-N1
fastjson 0.9.0 to 0.10.0 Key-heavy decode peak memory about 15% lower; file decode peak 9 to 33% lower Neoverse-N1
fast_uuid 0.8.0 uuid_v4() 76 to 42 ns; uuid_v4_bin_batch(100) 61 to 27 ns per UUID; uuid_v7_bin_batch() 25.9 to 16.6 ns Neoverse-N1
mdparser 0.6.2 toAst() 5 to 9% faster; retained AST memory 47% lower on softbreak-dense input; code-heavy toXml() output 49% smaller in memory aarch64, PHP 8.4.25
php_clickhouse 0.12.0 Temporal, string, and IP inserts about 19% faster; IPv4 selects about 12% 200,000-row probes

The phpser integer-run encoding is the one I'd point at. A run with a constant step is stored as a start, a step, and a count, so range(0, 9999) encodes to 7 bytes and range(0, 999999) to 8. Native serialize() needs 137,790 bytes for the first one.

fast_uuid's speedup comes from refilling its random buffer with a userspace AES-256-CTR generator with fast key erasure, instead of a getrandom() call per 8 KiB, on CPUs with AES instructions. It has one caveat, and the changelog states it: clones of one VM snapshot can repeat up to 64 KiB of output per thread.

Breaking changes worth reading before you upgrade

Most of these releases are drop-in. These are not:

  • php_clickhouse 0.13.0: whole-number Decimal inserts scale like the server's (above).
  • mdparser 0.8.0: heading IDs carry a user-content- prefix.
  • phpser 0.10.0: Closures and other unserializable objects throw instead of quietly encoding NULL. Check what you put in $_SESSION before upgrading. New wire tags since 0.7.0 mean readers must upgrade before writers.
  • php_excel 2.9.0 and 2.9.1: dates use PHP's timezone rather than the OS one, and text is UTF-8 regardless of the process locale. Apps that relied on the locale to pass non-UTF-8 bytes through will see different output. 2.7.0 also rejects a font from a different ExcelBook in addText().
  • fastchart 1.8.0: heavy SVG that used to render slowly may now throw ValueError.
  • phonetic 0.4.4: oversized BMPM and Daitch-Mokotoff work throws a catchable ValueError rather than ending the request.
  • statgrab 2.2.2: Statgrab::ERROR_UNSUPPORTED is 44, matching libstatgrab; it was 41.

On the feature side, phpser 0.8.0 and 0.9.0 added signed sessions (phpser.session_key, HMAC-SHA256), an APCu serializer (apc.serializer=phpser), keyed payloads, and an exported C API. nameparser 1.4.0 understands joint salutations, so Mr. and Mrs. Brad Smith keeps Brad as the first name, with new isJoint(), getSalutations(), and getPartner(), and 1.4.1 raised first-and-last accuracy on 30,000 NPPES provider rows from 95.94% to 97.18%. statgrab 2.3.1 fixes a heap overflow in the bundled libstatgrab's Linux command-line handling that the scan didn't cover.

What a scan is worth

None of these projects were neglected before October. Each had a test suite and documentation that said where its limits were. The scans still found an allowlist that didn't reach a nested call, a Decimal that came back ten thousand times too small, and a 16 KB name that took 17 seconds. Two of the most consequential findings sat in code I had already called linear or already tested under ASan, and for the Decimal bug the ASan testing is exactly what hid it.

The fixes are released and listed above. Upgrade pdo_duckdb if you are moving to DuckDB 2.0, php_clickhouse if you talk to a server you don't control, and PHP itself to 8.4.27 or 8.5.12 whatever else you do.