CVE-2026-96451 The account form edits whoever was fetched last in Ultimate Member

Intrudify's autonomous pentesting engine discovered a privilege escalation vulnerability in Ultimate Member, affecting all versions up to and including 2.13.1. The plugin keeps a current user of its own in a global, set with um_fetch_user() and restored with um_reset_user(). A file-download handler pins a request-supplied user into that global and returns on several paths without restoring it. The Account form, running later in the same request, writes to whoever is in the global. A logged-in Subscriber can therefore take over, delete or disable any account on the site, including the administrator.

Discovered by
Intrudify autonomous engine
Validated & reported by
Disclosure
1 October 2026
Severity
High · CVSS 8.8
Vulnerability class
Privilege escalation, CWE-266
Affected
Ultimate Member ≤ 2.13.1
Fixed in
2.14.0
Active installs
200,000+
Attacker privilege
Subscriber
User interaction
None, the administrator does nothing
Outcome
Account takeover, user deletion, administrator lockout

Summary

Ultimate Member is a membership plugin: it adds registration, profiles and an Account page where a member edits their own details. Alongside WordPress's own current user it maintains a second notion of the current user, a global that templates and handlers read through um_user(). Any code that wants to work with somebody else calls um_fetch_user() first and um_reset_user() afterwards.

Intrudify's autonomous testing engine found that the Account form decides whose account to edit by reading that global. It also found a handler that fills the global with a request-supplied user id and then returns early without putting it back. Both run on template_redirect in the same request.

Three defects have to line up, and on 2.13.1 they do. The context is pinned and never restored. The form reads the context and its nonce names only the tab, so the attacker's own nonce authorises the write. And the nonce on the download link is built by concatenating two numbers with nothing between them, which lets an attacker mint a token for a user they were never given one for.

Technical detail

Two handlers, one request

download_routing() serves the files members attach to profile fields. account_submit() processes the Account form. Both are registered on template_redirect.

// Two handlers on the same action, in the same request.

// includes/core/class-files.php:59
add_action( 'template_redirect', array( &$this, 'download_routing' ), 1 );

// includes/core/class-account.php:47
add_action( 'template_redirect', array( &$this, 'account_submit' ), 10002 );

// 10001 priorities apart. Whatever the first one leaves behind,
// the second one reads.

Priority 1 and priority 10002. Nothing in between restores the user context, so the state the first handler leaves behind is the state the second one starts from.

The handler that does not put the user back

download_routing() validates the nonce on the download link, then fetches the user whose file is being requested. After that it has three ways to decide the request is not valid, and in 2.13.1 all three return without restoring the context.

// includes/core/class-files.php, 2.13.1

um_fetch_user( $user_id );        // request-supplied id, now UM's current user
$field_data = get_post_meta( $form_id, '_um_custom_fields', true );

if ( empty( $field_data[ $field_key ] ) ) {
    return false;                  // no um_reset_user()
}

if ( ! um_can_view_field( $field_data[ $field_key ] ) ) {
    return false;                  // no um_reset_user()
}

// A um_field that does not exist on the form takes the first exit.
// The request keeps rendering with somebody else's user in the context.

The first exit is the useful one. The field key comes straight from the URL, so asking for a field that does not exist on the form takes that branch. The download fails, the request carries on being rendered, and UM's current user is now whoever the attacker named in the path.

The form that reads it

um_submit_account_details() is where the Account form applies its changes. It reads the user id from the context.

// includes/core/um-actions-account.php, 2.13.1

function um_submit_account_details( $args ) {
    $user_id = um_user( 'ID' );                   // line 224: the pinned user

    // password tab, line 229
    $args['user_id'] = get_current_user_id();    // the requester

    // delete tab, line 253
    if ( current_user_can( 'delete_users' ) || um_user( 'can_delete_profile' ) ) {
        UM()->user()->delete();
    }

    // general tab, line 429
    UM()->user()->update_profile( $changes, 'account' );
}

// The password tab already took the requester. Five lines above it,
// the other two tabs take whoever is in the context.

The password tab is the detail worth sitting with. It is in the same function, five lines below the assignment, and it already uses get_current_user_id(). The correct value was in the file, in scope, on the line underneath. The general and delete tabs read the context instead.

The delete branch is gated on current_user_can( 'delete_users' ) || um_user( 'can_delete_profile' ). The first half asks about the requester, who is a Subscriber and fails it. The second half reads the setting off the pinned user, so the gate opens whenever the target's own role is allowed to delete its profile, which is ordinary membership configuration. The password confirmation on that tab is checked against $current_user, which is the attacker's own password.

A nonce that names the verb and not the object

// What authorises the write. includes/core/um-actions-account.php:22

wp_verify_nonce( $args['um_account_nonce_' . $tab], 'um_update_account_' . $tab );
//                                         names the tab, never the user

// Whether the handler runs at all. includes/um-short-functions.php:1426

return ( ! empty( $_POST['_um_account'] ) && is_user_logged_in() );
// no check that the request is on the Account page, so the POST can go
// to the um-download URL that does the pinning

The nonce action contains the tab and never the user id, so a nonce the site issued to the attacker for their own Account page authorises an edit to anybody. And because um_submitting_account_page() only asks whether _um_account is present and the requester is logged in, the Account form will process a POST sent to any URL on the site, including the download URL that does the pinning. One request does both halves.

Two numbers with nothing between them

The download link carries its own nonce, built by concatenating the user id and the form id with no separator.

// The action string each release hands to wp_create_nonce(), printed
// for three pairs. PHP 8.3.

2.13.1   $user_id . $form_id . 'um-download-nonce'
  user=13  form=6     attacker, own profile    136um-download-nonce
  user=1   form=36    administrator, forged    136um-download-nonce
  user=2   form=7     control, unrelated       27um-download-nonce

2.14.0   "um-download-nonce-$user_id-$form_id"
  user=13  form=6                              um-download-nonce-13-6
  user=1   form=36                             um-download-nonce-1-36
  user=2   form=7     control, unrelated       um-download-nonce-2-7

(13,6) and (1,36) share an action string   2.13.1: YES    2.14.0: no
(2,7) and (13,6) share an action string    2.13.1: no     2.14.0: no

// The second row is the control. If an unrelated pair collided too, the
// comparison would be measuring nothing.

A token minted for user 13 on form 6 is a token for user 1 on form 36. An attacker whose own id begins with 1 can read a valid token off their own profile page and spend it on the administrator. Nothing needs to be guessed or brute-forced.

The chain

  1. The attacker opens any member profile that shows a file field and reads the download token out of the link.
  2. They open their own Account page and read their own um_account_nonce_general.
  3. They POST to the download URL, naming the victim and a field key that does not exist, with the Account form's fields in the body.
  4. download_routing() runs at priority 1, verifies the token, pins the victim into the context and returns on the missing field without restoring it.
  5. account_submit() runs at priority 10002, accepts the attacker's own nonce, reads the victim from the context and writes.

Verification

The full chain was validated over HTTP against Ultimate Member 2.13.1 on a default WordPress install with no other plugins active, driven as a plain Subscriber with no administrator involvement at any point. The only configuration beyond defaults is a File Upload field on the Default Profile form, which is what makes the plugin render the download link that carries the token.

// One request. The path pins the target, the body edits it.

POST /um-download/6/NOSUCHFIELD/<member_id>/<token>

_um_account=1
_um_account_tab=general
um_account_nonce_general=<the attacker's own>
[email protected]

// HTTP 200. The member's user_email is now the attacker's, and Lost your
// password delivers the reset link to the attacker's inbox.

Three outcomes were confirmed by reading the database out of band and not inferred from the response: the victim's user_email replaced, which hands over the account through the ordinary password-reset flow; a member removed from wp_users; and the administrator's capabilities stripped with the account set inactive.

As a negative control, the same Account form submitted normally, straight to /account/ with no download request in front of it, edits only the attacker's own account. That is the intended behaviour, and it is what confirms the takeover comes from the context switch and not from the form being open in general.

The nonce collision was run separately, printing the action string each release builds for three pairs of ids. The third pair is a control: two unrelated pairs must not collide under either scheme, and they do not. Without that row the comparison would prove nothing, since a function that returned a constant would also show the two interesting pairs matching.

Impact

A Subscriber account is all that is required, and on a site running this plugin Subscriber accounts are the point: Ultimate Member exists to let the public register. The administrator does nothing and sees nothing.

The three outcomes compound. Changing a target's e-mail address converts into a full takeover through the site's own password-reset flow. Deletion removes a member outright. And an update aimed at the administrator locks the site's owner out of their own site.

The lockout is worth describing precisely, because the plugin's own defence is what carries it out. Ultimate Member ships a feature that watches for accounts quietly gaining administrative capabilities and neutralises them. It is on by default (includes/class-config.php:742).

// includes/frontend/class-secure.php:55, unchanged in 2.14.0

if ( is_user_logged_in() && ! current_user_can( 'manage_options' ) ) {
    // Exclude current Logged-in Administrator from validation checks.
    add_action( 'um_after_user_account_updated', array( $this, 'secure_user_capabilities' ), 1 );
}

// The condition asks what the REQUESTER may do. The function it registers
// inspects the user that was UPDATED. During the forged request those are
// two different people: a Subscriber and the administrator.

The registration condition asks whether the requester is an administrator, so that an administrator editing users is not policed by it. In this chain the requester is the attacking Subscriber, so the hook is registered. The function then inspects the user that was updated, which is the administrator, finds administrative capabilities on a front-end account update and treats it as suspicious activity. It removes every capability, resets the user level and sets the account inactive, and an inactive account is refused at login (includes/core/um-actions-login.php:137).

The defence makes exactly the mistake the main bug makes. One expression asks about the requester, the next one acts on the target, and the code reads as though they are the same person because in every normal request they are.

Remediation

Update Ultimate Member to 2.14.0 or later.

All five of the changes this report asked for shipped in that release, and the vendor's own comment on the central one states the rule plainly.

// 2.13.1  ->  2.14.0

- $user_id = um_user( 'ID' );
+ $user_id = get_current_user_id(); // Account can be submitted only for the current user.

- 'um_update_account_' . $tab
+ "um_update_account_$tab-$user_id"

- return ( ! empty( $_POST['_um_account'] ) && is_user_logged_in() );
+ return um_is_predefined_page( 'account' ) && ! empty( $_POST['_um_account'] ) && is_user_logged_in();

  if ( empty( $field_data[ $field_key ] ) ) {
+     um_reset_user();
      return false;
  }

- wp_create_nonce( $user_id . $form_id . 'um-download-nonce' )
+ wp_create_nonce( "um-download-nonce-$user_id-$form_id" )

The context restore was added on five exits, not the three in the reported path, which covers the traversal checks further down the same function. um_reset_user() was added in exactly one file across the whole release, so the repair is scoped to this defect and did not need to be swept through the plugin.

The 2.14.0 changelog names the finding: *"Fixed: Security issue related to Privilege Escalation. Fixed user account submission and nonce security. Reset the user if it hasn't the ability to download the file. (Reported by Intrudify (Patchstack))."* The same release closes four other security issues reported by other researchers, so a site owner on 2.13.1 is behind on more than this one.

Sites that cannot update immediately get no useful mitigation from disabling the Account page, because the handler does not require that page. Blocking requests to /um-download/ that carry a POST body is the narrowest stop-gap, since a legitimate download is a GET.

The wider lesson

The defect is ambient state used as an implicit argument. um_submit_account_details() never receives the user it edits. It reads the user from a global that any handler earlier in the request may have changed, and it is correct only for as long as every one of those handlers remembers to put the global back. That is a contract spread across a codebase and enforced nowhere, so it holds until one function grows an early return.

What makes it worth writing up is that the early return is not a mistake anyone would catch in review. A guard clause that rejects a bad field key and returns false is exactly what good code looks like. The bug is one level up: the function had taken on a responsibility, restoring a global, that its own shape gives no reminder of. Cleanup that depends on remembering belongs in the language, in a destructor or a scope guard or a callback that wraps the work, and not in the author's memory.

The second point is about authorisation tokens. A nonce that names the action but not the object proves that the requester meant to do something of that kind, and it proves nothing about what they meant to do it to. um_update_account_general is a sentence with no object in it. Any nonce action that guards a write to a specific record should carry that record's identifier, and the fix here does exactly that.

The third is the cheapest to act on. The download token was built by concatenating two integers into one string. Two numbers with no delimiter between them do not reconstruct uniquely, so 13 and 6 are indistinguishable from 1 and 36. Anywhere a security decision is derived from joined values, a separator that cannot occur inside either value is the whole of the defence, and it costs one character.

Last, the defence repeated the handler's mistake. The capability check asks what the requester may do and the action it guards applies to a different user entirely. That same shape has appeared in this research set before, in a save guard that decided once at plugin load whether the current user was an administrator. Worth asking of any security check: the subject of this condition and the object of the action it protects, are they the same account?

Disclosure timeline

  • 2026-09-21 Vulnerability identified and full chain validated by Intrudify, reported via Patchstack
  • 2026-09-29 Fixed release 2.14.0 published by Ultimate Member
  • 2026-10-01 Public disclosure · CVE-2026-96451 assigned

Questions

What is CVE-2026-96451?

CVE-2026-96451 is a privilege escalation vulnerability in the Ultimate Member WordPress plugin, versions 2.13.1 and earlier, reachable by any logged-in Subscriber. It was discovered autonomously by the Intrudify AI penetration testing engine. The plugin's file-download handler fetches a request-supplied user into a global user context and returns on several paths without restoring it, and the Account form, which runs later in the same request, edits whichever user is in that context.

What can an attacker achieve with CVE-2026-96451?

A logged-in Subscriber can change any other user's e-mail address and then take the account over through the site's password-reset flow, delete any member, or send a forged update at the administrator. The last of those triggers the plugin's own anti-privilege-escalation feature against the administrator, stripping every capability and setting the account inactive so it can no longer log in. No administrator interaction is required at any point.

How do I fix CVE-2026-96451?

Update Ultimate Member to version 2.14.0 or later. The fix takes the account being edited from get_current_user_id instead of the global context, binds the target user id into the account nonce action, restricts the account handler to the Account page, restores the user context on every exit of the download handler, and puts separators into the download nonce action so two different user and form pairs can no longer produce the same token.

References

More discovered CVEs from Intrudify

See what a pentest
finds in your app.