On August 23rd, 2026, Wordfence Argus, our AI research agent specializing in complex vulnerability chains, discovered a PHP Object Injection vulnerability in Tutor LMS, a WordPress e-learning plugin active on more than 100,000 websites. This vulnerability allows any authenticated attacker with subscriber-level access to achieve remote code execution on the server by exploiting an interaction between WordPress’s database abstraction layer and PHP’s serialization engine. Because Tutor LMS is built around student enrollment and most installations enable open registration by default, the authentication bar is effectively low for any visitor who can reach the site.

Our mission is to secure WordPress through defense in depth, which is why we are investing in proactive vulnerability research of this kind alongside our Bug Bounty Program. We are committed to making the WordPress ecosystem more secure through the detection and prevention of vulnerabilities, which is a critical element to our multi-layered approach to security.

Wordfence Premium, Wordfence Care, and Wordfence Response users received a firewall rule to protect against known exploits targeting this vulnerability on August 25, 2026. Sites using the free version of Wordfence will receive the same protection 30 days later, on September 24, 2026.

We sent full disclosure details to the Themeum team through our Wordfence Vulnerability Management Portal on August 23, 2026, the same day we received and validated the report. The Themeum team acknowledged the vulnerability on August 24, 2026, and released a fully patched version, 4.0.8, on September 10, 2026. We would like to commend the Themeum team for their prompt response and timely patch.

We urge users to update their sites with the latest patched version of Tutor LMS, version 4.0.8 at the time of this publication, as soon as possible.

Vulnerability Summary from Wordfence Intelligence

CVSS Rating
8.8 (High)
Affected Version(s)
<= 4.0.7
Patched Version
4.0.8
The Tutor LMS – eLearning and online course solution plugin for WordPress is vulnerable to PHP Object Injection in all versions up to, and including, 4.0.7 via the `withdraw_method_field` parameter of the `tutor_save_withdraw_account` AJAX handler. This is due to the handler lacking any capability or role check, relying solely on a nonce, while also passing attacker-supplied values through `esc_sql()`, which replaces every `%` character with a 66-byte HMAC placeholder token before the data is serialized and stored via `update_user_meta()`; when the meta is later retrieved, the placeholder is collapsed back to a single `%`, leaving serialized string length declarations 65 bytes greater than the actual content, and because array keys originate from entirely unescaped POST field names, `unserialize()` over-reads into attacker-controlled bytes, allowing injection of an arbitrary serialized object stream. This makes it possible for authenticated attackers, with subscriber-level access and above, to achieve remote code execution on the server by triggering the `GuzzleHttp\Cookie\FileCookieJar` POP chain, reachable via the `spl_autoload_register` loader in `TUTOR\RestAPI` which loads the plugin's own bundled PayPal Composer autoloader, writing attacker-controlled content to an attacker-specified filename. This has an unauthenticated pathway when user registration is enabled, which is common for students and teachers to register, and it requires the monetization feature to be enabled.

Technical Analysis

Unfortunately, insecure implementation of the plugin’s withdraw account management feature allows for PHP Object Injection leading to remote code execution. The vulnerability spans four components across the plugin, exploits an obscure interaction inside WordPress core’s database abstraction layer, and terminates in a destructor method of a bundled third-party library.

The entry point is the tutor_save_withdraw_account AJAX handler, registered in classes/Withdraw.php. As shown on line 44 of Withdraw.php, the handler is registered using the wp_ajax_ hook only — the authenticated hook — with no corresponding capability check anywhere in the handler body:

public function __construct() {
    add_action( 'wp_ajax_tutor_save_withdraw_account', array( $this, 'tutor_save_withdraw_account' ) );
    add_action( 'wp_ajax_tutor_make_an_withdraw', array( $this, 'tutor_make_an_withdraw' ) );
    add_filter( 'tutor_withdrawal_methods_all', array( $this, 'withdraw_methods_all' ) );
    add_filter( 'tutor_withdrawal_methods_available', array( $this, 'withdraw_methods_available' ) );
}

The handler’s only access gate is a nonce check via tutor_utils()->checking_nonce() on line 195. That nonce is emitted on every frontend page load via wp_localize_script in classes/Assets.php, meaning any logged-in subscriber can obtain a valid _tutor_nonce simply by loading the homepage.

On a typical Tutor LMS site that allows open student registration, an anonymous visitor can self-register as a subscriber via the tutor_register_student action and immediately obtain this nonce, giving them everything they need to reach the vulnerable handler. Notably, the sibling handler tutor_make_an_withdraw does enforce an is_instructor() capability check; the absence of the same check on tutor_save_withdraw_account is what leaves this path open to any subscriber.

Attacker-controlled data flows in through $_POST['withdraw_method_field'][$method], retrieved on line 204 via tutor_utils()->avalue_dot(). The keys of this associative array, the $input_name values, come entirely from POST field names and are never sanitized or constrained. The values are then processed on line 213:

$method_data               = tutor_utils()->avalue_dot( 'withdraw_method_field.' . $method, $_POST );
$available_withdraw_method = $this->withdraw_methods_all();
if ( tutor_utils()->count( $method_data ) ) {
    $saved_data                         = array();
    $saved_data['withdraw_method_key']  = $method;
    $saved_data['withdraw_method_name'] = tutor_utils()->avalue_dot( $method . '.method_name', $available_withdraw_method );
    foreach ( $method_data as $input_name => $value ) {
        $saved_data[ $input_name ]['value'] = esc_sql( sanitize_text_field( $value ) );
        $saved_data[ $input_name ]['label'] = tutor_utils()->avalue_dot( $method . ".form_fields.{$input_name}.label", $available_withdraw_method );
    }

    update_user_meta( $user_id, '_tutor_withdraw_method_data', $saved_data );
}

The root cause is the misuse of esc_sql() on a value destined for update_user_meta() rather than a raw database query string. esc_sql() internally calls wpdb::add_placeholder_escape(), which replaces every literal % character with a 66-byte HMAC-keyed placeholder token. This is a WordPress internal safety mechanism designed to prevent % characters from being misinterpreted as printf-style format specifiers in $wpdb->prepare(). When a value like AAAA%z passes through esc_sql(), it becomes a 71-character string in memory. update_user_meta() then calls maybe_serialize() on the entire $saved_data array, faithfully serializing this inflated string as s:71:"AAAA{token}z".

Here is the twist that turns this into a length desync. WordPress strips those placeholder tokens back out of every query immediately before execution, wpdb registers remove_placeholder_escape() on the query filter at priority 0, so it runs on the UPDATE statement that writes the user meta. As the row is written, the 66-byte token collapses back to a single % character in place, inside the already-serialized string. What actually lands in the database is s:71:"AAAA%z", a serialized string token that declares a length of 71 bytes but whose actual content is only 6 bytes. The corruption is baked into storage on write; the payload detonates the next time that blob is unserialized on read.

When maybe_unserialize() later hands this string to PHP’s unserialize(), the parser reads 71 bytes starting from the opening quote, over-reading 65 bytes past the end of AAAA%z and into the next array element. Because the keys of $saved_data come from unescaped POST field names that the attacker controls entirely, the attacker can craft a second array key whose content is a well-formed serialized object stream. After the 65-byte over-read consumes the attacker’s padding, the parser resumes execution on the injected payload.

Detonation requires only that the corrupted blob be unserialized, and sending the same request a second time is sufficient to cause it. On the first request, the corrupted user meta is written to the database. On the second request, the plugin’s update_user_meta() call invokes WordPress core’s update_metadata(), which to determine whether the value has actually changed, reads the previously-stored value via get_metadata_raw(), and get_metadata_raw() runs maybe_unserialize() on it.

That core-internal maybe_unserialize() on the corrupted blob materializes the attacker’s injected objects. The corrupted meta is equally a latent landmine for any other reader: WithdrawModel::get_user_withdraw_method() runs get_user_meta( $user_id, '_tutor_withdraw_method_data', true )maybe_unserialize(), and it is invoked both by the tutor_make_an_withdraw handler and whenever the withdrawal dashboard or account settings page is rendered, so simply viewing the withdrawal page also detonates the payload.

The injected payload is a two-element array. The first element references the class name ecommerce\PaymentGateways\Paypal\vendor\autoload, not a real class, but a name crafted so that when unserialize() tries to resolve it, the plugin’s registered autoloader maps the name to a filesystem path and includes, via require_once(), the corresponding file. The plugin registers a custom spl_autoload_register callback in classes/RestAPI.php:

private function loader( $class_name ) {
    if ( ! class_exists( $class_name ) ) {
        $class_name = preg_replace( array( '/([a-z])([A-Z])/', '/\\\\/' ), array( '$1$2', DIRECTORY_SEPARATOR ), $class_name );
        $class_name = str_replace( 'TUTOR' . DIRECTORY_SEPARATOR, 'restapi' . DIRECTORY_SEPARATOR, $class_name );
        $file_name  = $this->path . $class_name . '.php';

        if ( file_exists( $file_name ) ) {
            require_once $file_name;
        }
    }
}

This loader translates the class name ecommerce\PaymentGateways\Paypal\vendor\autoload into a filesystem path and, because the file exists in the plugin directory, it includes, via require_once(), the bundled PayPal Composer autoloader at ecommerce/PaymentGateways/Paypal/vendor/autoload.php. That Composer autoloader in turn registers the GuzzleHttp\* class hierarchy, making the second injected object, a genuine GuzzleHttp\Cookie\FileCookieJar, fully materializable.

When PHP’s garbage collector destroys the deserialized FileCookieJar instance, its __destruct() method calls save(), which calls file_put_contents( $this->filename, json_encode( $this->cookies ) ). Because the attacker controls both $this->filename (set to a .php path under wp-content/uploads) and the cookie Name property of a contained SetCookie object (set to a <?php echo shell_exec(...); ?> payload), the destructor writes a PHP web shell to disk. Any subsequent HTTP request to that path executes arbitrary operating system commands as the web server user, completing a full remote code execution chain reachable by any subscriber-level user.

The Patch

The Themeum team patched this vulnerability in version 4.0.8 with a set of changes to tutor_save_withdraw_account() in classes/Withdraw.php that address multiple layers of the attack chain simultaneously. The most important addition is a proper capability check immediately after the nonce verification, ensuring the handler is only reachable by instructors:

// Checking nonce.
tutor_utils()->checking_nonce();
$user_id = get_current_user_id();

// Withdraw account settings are for instructors only.
if ( ! tutor_utils()->is_instructor( $user_id ) ) {
    wp_send_json_error( array( 'msg' => tutor_utils()->error_message() ) );
}

This single addition closes the subscriber-level access pathway. The patch also removes esc_sql() from the value-sanitization path, the direct root cause of the length-desync primitive, replacing it with sanitize_text_field() and sanitize_email() applied after wp_unslash(), none of which introduce serialization-corrupting length inflation.

Critically, the patch introduces a strict whitelist for array keys: the loop now iterates over $form_fields (the plugin’s own declared field definitions for the chosen withdrawal method) rather than over the attacker-supplied $method_data, and only accepts keys present in both. This prevents an attacker from injecting arbitrary keys into the serialized payload regardless of the value-handling path. Finally, the method name is validated against the plugin’s list of available withdrawal methods before any field processing occurs:

$form_fields = $available_withdraw_method[ $method ]['form_fields'] ?? array();
if ( ! is_array( $form_fields ) || empty( $form_fields ) ) {
    wp_send_json_error();
}

$method_data = tutor_utils()->avalue_dot( 'withdraw_method_field.' . $method, $_POST );
if ( ! is_array( $method_data ) || ! tutor_utils()->count( $method_data ) ) {
    wp_send_json_error();
}

$saved_data                         = array();
$saved_data['withdraw_method_key']  = $method;
$saved_data['withdraw_method_name'] = $available_withdraw_method[ $method ]['method_name'] ?? '';

foreach ( $form_fields as $input_name => $field ) {
    if ( ! array_key_exists( $input_name, $method_data ) ) {
        continue;
    }
    $raw_value = $method_data[ $input_name ];
    if ( is_array( $raw_value ) ) {
        continue;
    }

    $field_type = $field['type'] ?? 'text';
    $value      = 'email' === $field_type
        ? sanitize_email( wp_unslash( $raw_value ) )
        : sanitize_text_field( wp_unslash( $raw_value ) );

    $saved_data[ $input_name ] = array(
        'value' => $value,
        'label' => $field['label'] ?? '',
    );
}

Together, these changes address the vulnerability at four independent points: the authentication gate, the serialization corruption source, the key injection vector, and the method validation bypass. Any one of the first three changes alone would have been sufficient to block exploitation; shipping all of them in a single patch demonstrates thorough remediation. A key highlight of defense in depth applied as a patch.

Disclosure Timeline

2026-08-23
Wordfence Argus, our automated research agent specializing in complex vulnerability chains, identified the PHP Object Injection vulnerability in Tutor LMS, and the Wordfence Threat Intelligence team validated it the same day
2026-08-23
Full disclosure details were sent to the Themeum team through our Wordfence Vulnerability Management Portal
2026-08-24
The Themeum team acknowledged the report and began working on a fix
2026-08-25
Wordfence Premium, Care, and Response users received a firewall rule to provide added protection against any exploits targeting this vulnerability
2026-09-10
The Themeum team released Tutor LMS version 4.0.8, which fully addresses the vulnerability
2026-09-11
Wordfence published this advisory to inform the broader WordPress community
2026-09-24
Wordfence free users receive the firewall rule
Wordfence action
Vendor / external action

Conclusion

In this blog post, we detailed a PHP Object Injection vulnerability within the Tutor LMS plugin affecting versions 4.0.7 and earlier. This vulnerability allows authenticated attackers with subscriber-level access to achieve remote code execution on the server by exploiting a serialization length-desync primitive and a POP chain terminating in the plugin’s bundled GuzzleHttp library. The vulnerability has been fully addressed in version 4.0.8 of the plugin.

We encourage all WordPress site owners running Tutor LMS to update to version 4.0.8 or later as soon as possible. Given that Tutor LMS sites commonly enable open student registration as a core part of their function, the effective authentication bar for exploitation is low on many affected installations, making this a high-priority update.

Wordfence Premium, Wordfence Care, and Wordfence Response users received a firewall rule to protect against any exploits targeting this vulnerability on August 25, 2026. Sites using the free version of Wordfence will receive the same protection 30 days later, on September 24, 2026.

If you know someone who uses this plugin on their site, we recommend sharing this advisory with them to ensure their site remains secure, as this vulnerability poses a significant risk.

The post 100,000 WordPress Sites Exposed to Remote Code Execution via PHP Object Injection Vulnerability Found by Wordfence Argus in Tutor LMS appeared first on Wordfence.