MAD Bugs: Finding and Exploiting a 21-Year-Old Vulnerability in PHP 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. MAD Bugs: Finding and Exploiting a 21-Year-Old Vulnerability in PHP When this bug shipped, the dinosaurs had just gone extinct, only 64.999979 million years prior. 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. Before we dive in, one piece of news. Stefan 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. PHP's unserialize function 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 with no /proc access 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. Caveat up front.The remote chain has a strong precondition on the target: it must have a class loaded that implements Serializable , calls unserialize recursively on inner data inside its own unserialize method, 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. The bug is a missing BG serialize lock ++ in zend user unserialize , a two-line omission whose code path has been vulnerable since PHP 5.1 shipped Serializable in 2005. We're also open-sourcing the audit skill that found it: /php-unserialize-audit https://github.com/califio/skills . But first, some history. The story of why this is still happening is more interesting than the bug itself. A Brief History of Unserialize Misery PHP 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 paths, RFI through allow url include , phar:// metadata deserialization, etc. But the most devastating attacks were use-after-free bugs in the engine itself: a working UAF in unserialize was a universal weapon against any application that fed user input through the function. The line of work started with Stefan Esser. His 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 / autoload made 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. Taoguang Chen and the UAF Gold Rush 2015–2016 In 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 , SplObjectStorage, session handlers, SplDoublyLinkedList, GMP, and more CVE-2015-0273, -2787, -6834, -6835 through 2017 . Every one followed the same pattern. A magic method or custom unserialize handler would free a zval that was still registered in var hash , the deserializer's table of parsed-so-far values; a later R:N back-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 on PHP 5.5.14. Check Point and PHP 7 2016 PHP 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: "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." Tooling 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 file exists , fopen , stat , 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. "Not a Security Issue" On 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. The justification was that unserialize was never designed for untrusted input and developers should use json decode instead; 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: "Do not pass untrusted user input to unserialize regardless of the options value of allowed classes." The Bug Against 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: Serializable reentrancy shares outer var hash. To see why, three pieces of background. var hash is the deserializer's table for resolving back-references. PHP's serialize format has R:N; and r:N; tokens that point at the N-th value parsed so far; the parser keeps a zval per slot. A zval is a 16-byte cell: 8-byte value , 4-byte u1 type tag plus flags , 4-byte u2 repurposed by context . Scalars IS LONG, IS DOUBLE, ... live inline in value ; 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 buffer. Property HashTable packs all entries into one contiguous allocation. Each bucket is 32 bytes: a 16-byte zval val , an 8-byte cached hash h , and an 8-byte pointer to the key string key . Buckets sit in arData in insertion order; a separate hash-index region routes lookups by hash & nTableMask . Collisions chain through a next field tucked inside the zval's u2 slot. The HT starts at nTableSize=8 and doubles on overflow, which means allocating a fresh arData , copying buckets over, and efree ing the old one. BG serialize lock keeps var hash private to each top-level unserialize . Hook points wakeup , unserialize , destruct bump the counter before user code runs; nested calls see the non-zero lock and allocate their own private var hash .The bug: zend user unserialize , the dispatch site for Serializable::unserialize , skips the bump. A body that calls unserialize $data recursively therefore shares the outer's var hash . Inner-parsed property zvals end up registered as outer slots, pointing into the inner-stream object's arData . If user code then mutates that object enough to trigger a property-table resize, zend hash do resize efree s the old arData and a later R:N; dereferences freed memory. // Zend/zend interfaces.c:442-460: NO serialize lock increment ZEND API int zend user unserialize zval object, zend class entry ce, const unsigned char buf, size t buf len, zend unserialize data data { zval zdata; ZVAL STRINGL &zdata, char buf, buf len ; // BG serialize lock ++ is MISSING here zend call method with 1 params // user PHP code runs Z OBJ P object , Z OBJCE P object , // without the lock NULL, "unserialize", NULL, &zdata ; zval ptr dtor &zdata ; ... } Every other user-code dispatch site during unserialization wakeup , unserialize , destruct 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 dispatch path. Triggering the UAF The smallest gadget that fires the bug looks like this: class CachedData implements Serializable { public function serialize : string { return ''; } public function unserialize string $data : void { unserialize $data - x = 0; } } This 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. Exploit Strategy Every payload to unserialize has the same shape: a top-level array containing the gadget, 32 spray strings, and one or more R:N back-references. Gadget frees arData , one spray reclaims it, R:N dereferences; only the spray content and the R:N choices change between steps. 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 . addr , 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 . uaf read to 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 . 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. The local and remote exploits follow this exact shape. They differ only in which fake object Closure vs. stdClass , which dispatch path, and how far Step 3 has to walk to find the function pointer. The phases below trace each step. Local Exploitation The 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. Step 1: Leak a heap address The payload to unserialize : a:41:{ // slot 1: top-level array i:0; C:10:"CachedData":