WP Manifestindependent plugin directory
manifest / security / wp-sudo

Sudo – Research Prototype WP Registry grade D. High-severity findings · 43 findingsWP RegistryHigh-severity findings · 43 findingsOpen the reportD archived releases

Concluded research prototype — do not install. Final finding: github.com/dknauss/Sudo/blob/main/docs/finding.md

by Dan Knauss · github.com/dknauss/wp-sudo · website

42stars
16release downloads
4forks

Install

The author publishes release zips, so WP-CLI can install straight from GitHub:

wp plugin install https://github.com/dknauss/wp-sudo/releases/download/v4.9.2/wp-sudo.zip

Sudo — concluded

Fuwa-no-seki barrier gate

So full of cracks, the barrier gatehouse of Fuwa lets both rain and moonlight in — quietly exposed, yet enduring.

Abutsu-ni, Diary of the Waning Moon

This 13th-century poem was chosen at the start of this project, for its gate metaphor. It turned out to describe the project all too well: Sudo's barrier gate had cracks too — seven of them, all verified — and what endures is not the gate but the record of where the light came through.

[!CAUTION] Do not install this plugin. Not on production, not on staging, not on any site with real users, credentials, or data. It contains seven verified high-severity bypasses of its own central claim. They are documented rather than fixed, because they are the result.

Sudo was a six-month research prototype investigation of action-gated reauthentication in WordPress exploring one question, initially: What if WordPress requires a fresh proof of intent before consequential operations, regardless of role? It took its name and its symbol from the gate: 門, the radical that runs through East Asian writing, evoking the fortified pass where everyone and everything attempting to cross the gate is examined rather than trusted. The project is finished. This repository is archived and read-only.

What was tried, and what happened

WordPress asks for your password once, at login, and then never again. A valid session cookie is permission to do anything — install a plugin, which is arbitrary code execution; change another user's email and then their password; make every new signup an administrator. Steal the cookie, and you have the site.

The idea was to put a gate in front of the dangerous operations: notice the request, demand the password again, and only then let it through.

The gate could not reliably tell which requests were dangerous. Not because the list of dangerous operations was incomplete — that is the limitation everyone expects — but for a subtler reason.

To decide whether a request is about to delete a user, the plugin has to work out what WordPress is going to do with it. WordPress works that out too, in its own code, using its own rules. So two pieces of software are answering nearly the same question, separately, and never comparing answers. When they disagree, the plugin waves through something WordPress treats as a deletion.

They disagreed seven times under well-calibrated independent adversarial testing after passing many adversarial reviews before. WordPress read a value from $_REQUEST; the plugin read it from $_POST. WordPress matched a URL case-insensitively; the plugin did not. WordPress accepted PATCH; the plugin listed only PUT — a set of small, ordinary discrepancies, each one a complete bypass of the gate for the operations it covered.

Nothing in WordPress core or Sudo could have detected these gaps. Neither side can see the other's rules, and WordPress is under no obligation to keep its own rules stable.

Sudo's own tests could not find the holes either. Thanks to test-driven development, there were over 1,600 automated tests, plus static analysis and a mandatory adversarial review process from the beginning. A test looked like: build a request that means "delete a user", hand it to the gate, check the gate stops it. But "a request that means delete a user" was built from the plugin's own understanding — the same understanding that was wrong. A wrong assumption produces a test that passes while proving nothing. All seven critical bypasses were eventually found by an independent adversarial analysis — another AI reading WordPress's source and the plugin's side by side.

That is the result, and it is a negative one, documented in full rather than quietly fixed.

[!TIP] New here? Start with docs/sudo-architecture-history.md. It's a short, plain-language walk through every major approach this project tried — what the shipped plugin attempted, what a separate WordPress-core research track tried instead, a newer idea sketched after conclusion but never built, and concrete next steps for both. Read it before the technical documents below; they assume the context it provides.

The result

A WordPress plugin cannot provide ecosystem-wide action-gated reauthentication through route enumeration and post-submission interception.

Two mechanisms fail, and they fail on the same operations.

Route matching drifts from core. An adversarial audit found seven high-severity bypasses across six independent axes — REST route case, HTTP method set, $_POST versus $_REQUEST, action-name derivation, matcher evaluation order, and surface coverage. Each is a total bypass. All seven were independently verified against WordPress 7.x source and simulated an attack where an admin session is hijacked by the attacker who does not know the hijacked admin's password.

The defect is not an incomplete rule list. It is that the plugin's matching predicate and the predicate WordPress core dispatches on are two independently maintained things that drift, with nothing able to detect the drift.

Axis Core dispatches on Plugin matched on
REST route case preg_match( '@^…$@i' ) patterns with no i flag
File editor write 'POST' === $_SERVER['REQUEST_METHOD'] action=update required
option_page source $_REQUEST $_POST, in the self-protection rules
Bulk promote isset( $_REQUEST['changeit'] ) an action-name allowlist
REST method set EDITABLE = 'POST, PUT, PATCH' array( 'PUT', 'PATCH' )
Edited user $_REQUEST['user_id'] $_POST['user_id']

Effect vetoes work, but not where it matters. Hooking the effect rather than the route is sound, and does gate unambiguous destructive effects such as delete_user and activate_plugin. It cannot be generalised to option writes or capability mutation, because core and ordinary plugins fire those same hooks incidentally during normal admin loads — there is no intent signal to key on. Every one of the seven bypasses lands in that excluded set.

1,308 unit tests, 243 integration tests, 112 E2E tests, PHPStan level 6, Psalm, and a mandatory adversarial review gate detected none of the seven bypasses. They could not have: every test asserts the plugin against its own model of a request, so a wrong predicate produces a wrong test that passes. All six axes were found by reading core and the matcher side by side.

What this argues for

A narrow WordPress core primitive:

  1. Explicit effect vetoes — an intent signal at consequential effects, so a guard can distinguish an actor's intended operation from the same hook firing incidentally during a page load.
  2. Action-bound, single-use approvalactor A may perform effect E, once, within window W, consumed by the effect rather than by a route, authorising a specific effect and never "re-run this stored request."
  3. A disposition contract for non-interactive surfaces — present action-bound proof, refuse, or follow an explicitly separate and auditable machine policy. Not universal reauthentication: cron and the auto-updater have no present human, so reauthentication is category-incoherent there.

Where the work went

The finding pointed somewhere specific: authorization has to happen where an effect is committed, inside WordPress, rather than being guessed at from a request outside it. That successor work — including a Core patch demonstrating it, and a second failed attempt that explains why the obvious fix does not work either — is at wp-effect-authorization. Its history is mirrored here on the research/capability-floor branch.

Documents

Document Contents
docs/finding.md The technical result and what a core primitive would need
docs/audit-verification-record.md Independent verification of all seven bypasses against WP 7.0 source
docs/post-mortem.md How a heavily tested project failed to see what it had already diagnosed
docs/security-model.md Threat model and the boundaries the prototype never claimed to cover
docs/upstream-sources.md Every third-party claim, with enclosing symbol, machine-checked
PROJECT-STATUS.md The research-prototype classification and why it exists

The implementation and test suites are retained, read-only, as the evidence the findings rest on. Deleting them would leave assertions nobody could reproduce, which is the failure mode this project exists to document.

Honest scope

Every bypass presupposes an already-authenticated administrator session. None is a privilege crossing by a low-privileged or unauthenticated actor. They are complete defeats of what this plugin claimed to provide — reauthentication as a barrier in front of a compromised admin session — and nothing more than that.

The findings concern this plugin's architecture. They do not establish that no plugin can gate effects it owns, and they are not a vulnerability report against WordPress core or any third-party plugin.

Acknowledgements

Sudo's core design, development, and inexorable fate owes a debt to four people:

  • John Blackbourn, for the action-gating concept — that consequential operations should require a fresh proof of intent, regardless of role. It was the single biggest conceptual contribution to the project. A minimal, five-minute-readable demonstrator of the same core-primitive argument (Trac #20140) is preserved at consequential-actions — also concluded, also archived, also not for installation.
  • Tim Nash, for pragmatic security ideas, including the lockdown for roles and permissions, which shaped Sudo's opt-in admin-escalation guard and its opt-in role/capability lockdown audit, which have always been a viable mechanism for user account oversight.
  • Calvin Alkan, for critical early feedback on the Sudo concept, the only (then) complete and accurate documentation for WordPress user authentication, and for his work on Fortress as inspiration. Calvin's insistence that a normal plugin cannot fully achieve what Fortress does was a motivation to see how close a plugin could get — and how the UX might be smoothed out. Calvin's "Is this becoming a SIEM?" critique shaped Sudo's explicit not-a-SIEM boundary. (Which was intentionally violated a bit.)
  • Austin Ginder, for AI advice and adversarial testing — helping make the machine a better collaborator, then turning it loose as a tireless skeptic until confident claims yielded the expected contrary evidence.

License

GPL-2.0-or-later.

Releases

Newest 25 of 41 recorded releases. Each count is every asset in that release; expand a row for the breakdown.

Tag
Published
Assets
Downloads
v4.9.2 latest
Jul 29, 2026 1mo ago
wp-sudo.zip
6
Jul 29, 2026 1mo ago
wp-sudo.zip
0
Jul 27, 2026 1mo ago
wp-sudo.zip
0
Jul 24, 2026 1mo ago
wp-sudo.zip
2
Jul 16, 2026 1mo ago
wp-sudo.zip
4
Jul 6, 2026 2mo ago
wp-sudo.zip
3
Jul 5, 2026 2mo ago
wp-sudo.zip
1
Jun 28, 2026 2mo ago
tag only
Jun 27, 2026 2mo ago
tag only
Jun 24, 2026 2mo ago
tag only
Jun 22, 2026 2mo ago
tag only
Jun 13, 2026 2mo ago
tag only
Jun 13, 2026 3mo ago
tag only
Jun 8, 2026 3mo ago
tag only
May 11, 2026 4mo ago
tag only
May 11, 2026 4mo ago
tag only
May 11, 2026 4mo ago
tag only
Apr 21, 2026 4mo ago
tag only
Mar 22, 2026 5mo ago
tag only
Mar 8, 2026 6mo ago
tag only
Mar 8, 2026 6mo ago
tag only
Mar 5, 2026 6mo ago
tag only
Mar 1, 2026 6mo ago
tag only
Feb 28, 2026 6mo ago
tag only
Feb 27, 2026 6mo ago
tag only
All releases on GitHub →
D grade

Security

Sudo – Research Prototype 4.9.2 · audited by WP Registry

High-severity findings.

7 high 13 medium 23 low
Audited release
4.9.2
Findings
43
Worst severity
high
Content hash
8298dc0dc28f357bc32be37e…

Findings

  • high REST gate route patterns are case-sensitive while WordPress dispatches routes case-insensitively — one character voids the entire REST rule set

    auth_bypass

    WP_REST_Server::match_request_to_handler() dispatches with preg_match( '@^' . $route . '$@i', $path ) — note the /i modifier (WP 7.0.2, wp-includes/rest-api/class-wp-rest-server.php:1172). WP_REST_Request::get_route() returns the client's raw path verbatim: there is no set_route() call anywhere in WP_REST_Server, so the route is never canonicalised before rest_request_before_callbacks fires. Every one of the 12 route patterns in Action_Registry is delimited '#...#' with NO /i flag, and Gate::matches_rest() (class-gate.php:2537-2551) feeds get_route() straight into safe_preg_match(). Changing one letter of the path's case therefore makes WordPress route and execute the request while the gate's matcher returns false and intercept_rest() passes the response through untouched (class-gate.php:1856). With a hijacked admin session plus the wp_rest nonce (scrapeable from any admin page), all of the following run with no challenge and no audit event: POST /wp-json/wp/v2/Users {"roles":["administrator"]} (create an administrator); POST /wp-json/wp/v2/Users/1 {"password":"..."} (take over another admin); POST /wp-json/wp/v2/Users/2 {"roles":["administrator"]} (privilege escalation); POST /wp-json/wp/v2/Users/me/Application-Passwords (mint a DURABLE credential that survives the victim's password rotation and session revocation); POST /wp-json/wp/v2/Settings {"url":"https://attacker"} (repoint siteurl — an XSS-as-RCE primitive the plugin's own 4.8.0 changelog calls out). The ?rest_route=/wp/v2/Users/2 spelling works identically, so it does not depend on pretty permalinks. The namespace pre-filter at class-wp-rest-server.php:1159-1167 uses case-sensitive str_starts_with, but on a miss it falls back to $this->get_routes() (the complete route table), so even /WP/V2/users/1 reaches the case-insensitive match and dispatches. Only the six effects in Gate::arm_effect_guards() still die at the backstop; user create, promote, password change, email change, application-password creation and every option write have no effect-level guard, so for those the miss is total.

    includes/class-gate.php:2529-2551

    Recommendation

    Normalise before matching rather than relying on every rule author remembering a modifier: compare strtolower( $request->get_route() ) against lower-cased patterns, or have safe_preg_match() always compile route patterns with the i flag. Normalise trailing slashes and collapsed duplicate slashes at the same point. Add a regression test asserting that /wp/v2/Users/1 and /wp/v2/users/1 gate identically. Independently, extend the REST effect backstop to cover user create/promote/password/email and application-password creation so a matcher miss is not a total miss.

  • high Plugin and theme file editors write on any POST, but the gate matches only action=update — omit one field for an ungated arbitrary PHP write (RCE)

    auth_bypass

    The editor.plugin and editor.theme rules require $_REQUEST['action'] === 'update' with method POST (class-action-registry.php:502-506, 518-522), enforced by the strict in_array() at class-gate.php:1790. WordPress core's editors do not read an action parameter at all when performing the write: wp-admin/plugin-editor.php:95-96 and wp-admin/theme-editor.php:123-124 are both plain `if ( 'POST' === $_SERVER['REQUEST_METHOD'] ) { $edit_result = wp_edit_theme_plugin_file( wp_unslash( $_POST ) ); }`. wp_edit_theme_plugin_file() requires only file, newcontent, a valid nonce for 'edit-plugin_{file}' / 'edit-theme_{stylesheet}_{file}', and the edit_plugins/edit_themes capability. Exploit for an actor holding a live admin cookie and no sudo session: GET /wp-admin/plugin-editor.php?plugin=hello.php&file=hello.php (not gated — the rule demands POST) to scrape the rendered nonce, then POST to the same URL with file=, newcontent=<?php system($_GET['c']); ?>, nonce=<scraped>, and NO action field. The gate computes $request_action = '' (class-gate.php:1635), no rule matches, intercept() returns at :1399, and the file is written. There is no effect-level backstop for the editors — arm_effect_guards() registers only activate_plugin, delete_plugin, delete_theme, delete_user, export_wp and upgrader_pre_install. Note the AJAX twin IS covered ('edit-theme-plugin-file' at class-action-registry.php:508 and 524), which is what makes this a matcher gap rather than an accepted scope decision: the same effect is gated through one door and open through the other.

    includes/class-action-registry.php:498-528

    Recommendation

    Match the editors the way core dispatches them: on plugin-editor.php / theme-editor.php gate ANY POST carrying a 'newcontent' parameter, regardless of 'action'. More generally, allow a rule's callback to match independently of the actions list instead of running only after it passes — the actions list is currently a mandatory pre-filter that silently narrows every rule whose target screen does not dispatch on 'action'.

  • high Self-protection bypass: WP Sudo's own settings rules match $_POST['option_page'] while core reads $_REQUEST['option_page']

    auth_bypass

    wp-admin/options.php:26 derives $option_page from $_REQUEST — query string or body — and wp_magic_quotes() builds $_REQUEST as array_merge($_GET, $_POST). The two rules that protect WP Sudo from itself, options.wp_sudo and options.wp_sudo_access, read only $_POST['option_page'] (class-action-registry.php:699, 718). Vector A: POST /wp-admin/options.php?option_page=wp-sudo-settings with the body carrying action=update, _wpnonce (nonce action 'wp-sudo-settings-options', rendered by settings_fields() on the settings screen and scrapeable with the same cookie), and wp_sudo_settings[...]. Core resolves option_page from the query string, check_admin_referer passes, register_setting()'s sanitize_callback runs, and the option is written — while the gate's callback sees an unset $_POST['option_page'], returns false, and no rule matches. The attacker must OMIT option_page from the body, since $_POST wins collisions in $_REQUEST. Vector B: core's 'All Settings' page. POST option_page=options with page_options=wp_sudo_settings and the freely-obtainable options-options nonce reaches update_option('wp_sudo_settings', ...) via a branch that never consults $allowed_options. No options.php rule matches. Either vector sets cli_policy / cron_policy / xmlrpc_policy / rest_app_password_policy / wpgraphql_policy to 'unrestricted'; sanitize_settings() (class-admin.php:1004-1009) accepts all of them. Gate::get_policy() then makes gate_cli/gate_cron/gate_xmlrpc register audit-only hooks and makes intercept_rest() return the response untouched — every gated operation runs unchallenged on those surfaces. The write fires no wp_sudo_action_* event, so the disable is not recorded. The author diagnosed this exact bypass and fixed it three rules earlier: the options.critical callback reads $_REQUEST['option_page'] under a comment stating that keying on $_POST 'would be bypassable two ways, both verified: option_page travels in the QUERY STRING'. The lesson was not carried to the two self-protection rules 90 lines below.

    includes/class-action-registry.php:689-725

    Recommendation

    Read $_REQUEST['option_page'] in both callbacks, mirroring options.critical and core. Additionally match when $_POST['page_options'] names wp_sudo_settings, or simply when $_POST['wp_sudo_settings'] is present regardless of option_page, to close Vector B. Best: register pre_update_option_wp_sudo_settings / pre_update_site_option_wp_sudo_settings in arm_effect_guards() rather than only in register_function_hooks() — the 'core rewrites options incidentally' rationale for excluding option filters does not apply to the plugin's own option, which core never touches.

  • high users.php role change is ungated when the 'action' parameter is omitted — core routes on 'changeit', the gate routes on 'action'

    auth_bypass

    WP_Users_List_Table::current_action() (wp-admin/includes/class-wp-users-list-table.php:355-361) returns 'promote' purely from isset($_REQUEST['changeit']), before consulting the parent — the 'action' parameter is irrelevant to core on this path. users.php then switches on that return value and calls $user->set_role($role). Gate::matches_admin_pagenow() rejects the rule at `in_array( $request_action, $actions, true )` (class-gate.php:1790) BEFORE reaching the rule's narrowing callback at :1799. The user.promote rule's actions list is array('promote','-1'), and its callback DOES encode the correct changeit+new_role predicate — but that callback is unreachable when 'action' is absent. Exploit: GET /wp-admin/users.php (not gated — the rule needs the action param) to scrape the bulk-users nonce, then POST /wp-admin/users.php with _wpnonce=<bulk-users>, changeit=1, new_role=administrator, users[]=<victim id>, and NO action field. Core promotes the target to administrator; the gate computes $request_action = '' and matches nothing. No backstop catches it: arm_effect_guards() deliberately omits user.promote (documented at class-gate.php:216-218); the capabilities-meta guard exists only in register_function_hooks() (CLI/cron/XML-RPC); and arm_escalation_guard() is opt-in behind apply_filters('wp_sudo_guard_escalation', false), which defaults OFF, and even when enabled blocks only writes that NEWLY grant administrator. Consequence: durable persistence — a second administrator account that outlives the stolen cookie, password rotation and session revocation.

    includes/class-gate.php:1788-1803

    Recommendation

    Evaluate a rule's callback as an alternative to the actions list rather than behind it, or add '' to user.promote's actions. Structurally, a rule whose target screen uses a WP_List_Table subclass must consult that subclass's current_action() override, not the raw $_REQUEST['action']. Add a set_user_role / capabilities-meta guard to arm_effect_guards() (the newly_grants_administrator() predicate at class-gate.php:1162-1171 can be generalised), and default wp_sudo_guard_escalation to true.

  • high Self-protection defeat: POST /wp/v2/plugins/<plugin> deactivates WP Sudo itself — the plugin.* REST rules omit POST, and deactivation has no effect-level backstop

    auth_bypass

    WordPress registers the plugin item route under WP_REST_Server::EDITABLE (class-wp-rest-plugins-controller.php:80), and EDITABLE is the string 'POST, PUT, PATCH' (class-wp-rest-server.php:40). The plugin.activate and plugin.deactivate rules list 'methods' => array('PUT','PATCH'). Gate::matches_rest() tests in_array($request->get_method(), $methods, true) (class-gate.php:2555), so a POST to the same route matches no rule. Activation is still caught downstream by the activate_plugin guard in arm_effect_guards(). DEACTIVATION is not: WP_REST_Plugins_Controller's status handler calls deactivate_plugins(), which only writes the active_plugins option, and pre_update_option_active_plugins is hooked exclusively inside Gate::register_function_hooks() (CLI/cron/XML-RPC). The plugin's own comment at class-gate.php:739-741 explains the reasoning — 'No generic deactivate_plugin action exists — the hook is dynamic... We intercept at the option level instead' — but that option-level interception was never extended to the interactive or REST surfaces. So with an admin cookie and a wp_rest nonce: POST /wp-json/wp/v2/plugins/wp-sudo/wp-sudo with {"status":"inactive"} switches WP Sudo off. No rule matched, no backstop fired, no wp_sudo_action_blocked event was recorded. The mu-plugin does not save it — mu-plugin/wp-sudo-loader.php:171-173 returns inert as soon as the plugin leaves active_plugins — so the CLI/cron/XML-RPC gate dies with it and every subsequent request is completely ungated. The equivalent admin path IS covered (plugins.php action=deactivate, method ANY), and the users rules DO list all three EDITABLE verbs under a comment explaining exactly this trap (class-action-registry.php:324-327). The plugins rules were never brought in line.

    includes/class-action-registry.php:117-132

    Recommendation

    Add 'POST' to plugin.activate / plugin.deactivate / plugin.delete, and derive rule method sets from the WP_REST_Server::EDITABLE/CREATABLE/DELETABLE constants rather than hand-listing them. Add a regression test asserting each rule's method set is a superset of the core route's registered methods. Independently, move the pre_update_option_active_plugins shrink-detector into arm_effect_guards() so deactivation has an effect-level backstop on every surface, and special-case WP_SUDO_PLUGIN_BASENAME so the plugin cannot be switched off without an active sudo session.

  • high Core AJAX action 'add-user' creates an administrator with no challenge — user.create declares no ajax matcher and has no effect backstop

    auth_bypass

    WordPress registers add-user in admin-ajax.php's $core_actions_post list and dispatches it to wp_ajax_add_user(), which does check_ajax_referer('add-user'), current_user_can('create_users'), then edit_user() with no user id — a full user INSERT taking user_login, email, pass1/pass2 and role from $_POST. The user.create rule gates only pagenow=user-new.php (actions createuser/adduser) and REST POST /wp/v2/users. Its AJAX surface is explicitly 'ajax' => null (class-action-registry.php:458), and Gate::matches_ajax() tests a strict in_array against each rule's ajax.actions — no rule lists 'add-user', so the request matches nothing. There is no effect-level backstop either: user.create is deliberately excluded from arm_effect_guards(), and the wp_pre_insert_user_data guard exists only in register_function_hooks() (CLI/cron/XML-RPC). The default-OFF escalation guard is the sole remaining net, and only for administrator grants. The nonce is rendered by core on wp-admin/user-new.php (wp_nonce_field('add-user','_wpnonce_add-user')) and on network/site-users.php, all reachable with the same admin cookie; check_ajax_referer('add-user') accepts it via _ajax_nonce or _wpnonce. So: POST /wp-admin/admin-ajax.php with action=add-user, the scraped nonce, and role=administrator creates a new administrator account silently. This is the single most valuable outcome for a session thief — a credential of their own that outlives the stolen cookies — and it is one of the plugin's headline gated actions. This is the second core AJAX action found unmapped, which points at the systemic issue: the AJAX surface is an exact-string allowlist that has to be kept in sync with core by hand, with no effect-level guard behind it.

    includes/class-action-registry.php:449-464

    Recommendation

    Set 'ajax' => array( 'actions' => array( 'add-user' ) ) on user.create. Then sweep the whole of admin-ajax.php's $core_actions_post list against the registry, and add a user-creation effect guard to arm_effect_guards() so the AJAX surface does not depend on a hand-maintained enumeration.

  • high Two Factor lifecycle bridge resolves the edited user from $_POST['user_id'] while core uses $_REQUEST['user_id'] — a query-string target strips a victim admin's 2FA ungated

    auth_bypass

    The bundled bridge exists so that 'a compromised session must not be able to mint or downgrade' a user's second factor. Its admin-surface predicate resolves the target on user-edit.php from $_POST['user_id'] ONLY, and returns false — i.e. not a gated action, no challenge — when that body field is absent (bridge lines 143-147). WP core resolves it differently: wp-admin/user-edit.php:16 is `$user_id = ! empty( $_REQUEST['user_id'] ) ? absint( $_REQUEST['user_id'] ) : 0;` and line 158 fires do_action('edit_user_profile_update', $user_id). Verified against upstream two-factor 0.16.0, Two_Factor_Core::user_two_factor_options_update() is hooked there and writes update_user_meta($user_id, '_two_factor_enabled_providers', ...) plus delete_user_meta($user_id, '_two_factor_provider'). Exploit: POST /wp-admin/user-edit.php?user_id=7 (victim id in the QUERY STRING only) with body action=update, the two scraped nonces, and _two_factor_enabled_providers[]=x (array present so upstream does not early-return), and no user_id in the body. The bridge returns false and no challenge fires; core sets $user_id = 7 and upstream writes an empty provider list for user 7 and deletes their primary provider. A second variant sends ?user_id=<victim> in the query and user_id=<attacker> in the body, so the bridge diffs the ATTACKER's provider set and returns false while core edits the victim. No built-in rule backstops this: user.promote_profile needs $_POST['role'], user.change_password needs pass1/pass2, user.change_email needs a differing email — the minimal 2FA-strip POST carries none. profile.php targeting is correct (it uses get_current_user_id()), so only the cross-user surface is broken. Consequence: silent removal of another administrator's second factor, which also downgrades every FUTURE sudo challenge for that user to password-only. Precondition: the bridge is an opt-in mu-plugins drop-in shipped inside the plugin (nothing in includes/ requires bridges/*), and the actor must already hold an authenticated admin session. The plugin's own built-in rule at class-action-registry.php:414-421 documents this precise pitfall — 'Reading only $_POST["user_id"] would desync from a crafted user-edit.php?user_id=<victim>' — and the bridge does not apply the lesson.

    bridges/wp-sudo-two-factor-lifecycle-bridge.php:139-154

    Recommendation

    Resolve the target exactly as core does, copying the pattern the plugin already uses in its own built-in rule: read $_REQUEST['user_id'] on user-edit.php, and treat an unresolvable target as GATED (fail closed) rather than as 'not a lifecycle change'. Additionally hook personal_options_update / edit_user_profile_update at priority 1 and require sudo there, so a future upstream contract change cannot silently reopen the hole.

  • medium Effect-level backstop covers 6 of 20 gated operations and is armed only on admin_init / rest_api_init

    auth_bypass

    This is the structural root cause behind the matcher bypasses, reported separately because fixing each spelling individually leaves the class open. Gate::arm_effect_guards() (class-gate.php:340-402) registers exactly six hooks: activate_plugin, delete_plugin, delete_theme, delete_user, export_wp, upgrader_pre_install. The gated-operation set in Action_Registry is twenty. The fourteen with no effect-level guard on the interactive/AJAX/REST surfaces are plugin.deactivate, theme.switch, user.create, user.promote, user.promote_profile, user.change_password, user.change_email, auth.app_password, editor.plugin, editor.theme, options.critical, options.wp_sudo, options.wp_sudo_access, core.update, plus every multisite network.* rule. For all of these the only layer is a string comparison against $pagenow, $_REQUEST['action'], REQUEST_METHOD and a REST route regex — so any spelling core accepts that the matcher does not is a total bypass. The exclusions are deliberate and documented at class-gate.php:210-218, and the registry concedes the consequence at class-action-registry.php:554-561: 'On the interactive surface this matcher is the ONLY layer... a browser admin POST has no effect-level backstop here.' Two further gaps in the same shape. (a) Both backstops are registered on admin_init and rest_api_init only, so a request detect_surface() classifies as 'unknown' — any authenticated front-end request — has neither a matcher nor a guard. (b) activate_plugin( $plugin, '', $network_wide, true ) with $silent=true does not fire the activate_plugin action at all, so that guard is skippable by any caller passing $silent. The full CLI/cron/XML-RPC hook set at class-gate.php:728-905 shows the plugin already knows how to guard all twenty effects; that coverage is simply not extended to the surfaces a session thief actually uses. Prior registry finding M1 (audit 1310, v4.0.0) is therefore partially, not fully, fixed.

    includes/class-gate.php:340-402

    Recommendation

    Extend arm_effect_guards() toward the register_function_hooks() set, using the change-detection predicates the plugin already owns (value_echoes_stored_option(), newly_grants_administrator(), the active_plugins count comparison) so benign high-frequency writes do not fire. At minimum add guards for plugin deactivation, theme switch, user create, user promote, password change, application-password creation and the wp_sudo_settings option. Arm the guards from a surface-independent hook (plugins_loaded or init) rather than admin_init/rest_api_init so the front-end surface is covered, and default wp_sudo_guard_escalation to true.

  • medium Rules pinned to an HTTP method are bypassed by re-sending the identical query string with another method; theme switch and multisite site operations have no backstop

    auth_bypass

    Gate::matches_admin_pagenow() rejects a rule when $_SERVER['REQUEST_METHOD'] is not string-equal to the rule's declared method (class-gate.php:1794-1797). Core's admin handlers almost never check the request method — they test only for a parameter's presence — so any rule pinning a method is evadable. (a) theme.switch declares method 'GET' while wp-admin/themes.php:20-35 executes switch_theme() on isset($_GET['action']) && 'activate' === $_GET['action'] with no method test. POST /wp-admin/themes.php?action=activate&stylesheet=X&_wpnonce=<switch-theme_X> switches the active theme with no challenge, and there is no pre_update_option_stylesheet backstop on the interactive surface. (b) The same effect is reachable with no method trick at all: wp_ajax_customize_save -> WP_Customize_Manager::save() calls switch_theme() when publishing a changeset for a non-active theme. The registry has no ajax matcher for theme.switch, so the Customizer's 'Activate & Publish' is entirely ungated. (c) On multisite, network.site_delete / _deactivate / _spam / _archive all declare 'GET' while the real destructive request is a POST — wp-admin/network/sites.php executes wpmu_delete_blog() from $_GET['action'] on a POST form, and the deleteblog_<id> nonce is rendered in the row action on the ungated sites.php list screen. (d) Multisite bulk site deletion is not enumerated at all: sites.php?action=allblogs (bulk) and sites.php?action=delete_sites (the confirm POST that loops wpmu_delete_blog()) have no rule of any method, so bulk deletion of every non-main site is ungated without needing any trick. A related instance of the same class: core.update declares POST while wp-admin/update-core.php:978 dispatches on $_GET['action'], and $_REQUEST is POST-shadowable — so POST /wp-admin/update-core.php?action=do-core-upgrade with body action=noop&upgrade=1 reinstalls core at an attacker-chosen version with no challenge. classify_upgrader_effect() returns null for core, so the upgrader backstop explicitly passes core updates through.

    includes/class-gate.php:1794-1797

    Recommendation

    Set method 'ANY' on every rule whose core handler reads $_GET/$_REQUEST rather than $_POST — theme.switch, core.update and all four network.site_* rules at minimum — and move any genuine narrowing into the rule callbacks, where options.critical already does it correctly. Rules whose target page dispatches on $_GET must be matched against $_GET, since $_REQUEST is attacker-shadowable via the POST body. Add ajax matchers for customize_save and admin matchers for sites.php actions allblogs and delete_sites. Add pre_update_option_stylesheet and a wpmu_delete_blog / wp_uninitialize_site guard to arm_effect_guards().

  • medium Governance-capability grant/revoke accepts any target user ID with no promote_users or edit_user check

    missing_capability

    handle_grant_cap() validates only that the nonce is good, that the caller passes wp_sudo_can('manage_wp_sudo'), that $cap is one of the four GOVERNANCE_CAPS, and that get_userdata($target_user_id) returns a WP_User. It then calls $target->add_cap($cap) on that arbitrary user ID. There is no current_user_can('promote_users'), no current_user_can('edit_user', $target), and no check that the target holds administrator authority. The Access-tab UI that drives this endpoint lists only administrators (get_users(array('role'=>'administrator')) at class-admin.php:2063-2071), so the handler is materially more permissive than the interface it serves. Consequence traced end to end: the settings page is registered with capability manage_wp_sudo (class-admin.php:411, 430), so granting manage_wp_sudo to a Subscriber gives that Subscriber the Settings -> Sudo screen, where render_field_policy() exposes cli_policy, cron_policy, xmlrpc_policy, rest_app_password_policy and wpgraphql_policy as Disabled/Limited/Unrestricted selects. That Subscriber can obtain their own sudo session (the challenge only asks for the user's own password) and set every surface to Unrestricted — voiding the entire control from an account the operator believes is inert. The same call also delivers view_wp_sudo_activity / export_wp_sudo_activity (read and CSV-export the audit log) and revoke_wp_sudo_sessions (tear down other operators' sudo windows). On a default install the caller is an administrator, for whom this is a persistence primitive rather than an escalation. But this plugin's governance model explicitly separates manage_wp_sudo from manage_options (functions-governance.php:39-45), and Plugin::activate() grants the caps to the activating user only — never to the administrator role. In that supported configuration the caller is a non-administrator (e.g. an Editor granted manage_wp_sudo) holding neither edit_users nor promote_users, and the handler lets them write capabilities onto users they cannot otherwise touch. handle_revoke_cap() is the symmetric write with the same missing checks.

    includes/class-admin.php:2318-2357

    Recommendation

    Require current_user_can('promote_users') (or 'edit_user', $target_user_id) in addition to wp_sudo_can('manage_wp_sudo') in both handlers, and reject targets that do not already hold administrator authority — mirroring the administrators-only list the Access tab renders.

  • medium Application-password creation has no effect-level coverage on any surface

    auth_bypass

    The plugin advertises 'create application password' as a gated action (rule auth.app_password), and the rule is genuinely enforced on the two enumerated entry points: the admin page (pagenow=authorize-application.php) and the core REST route, via Gate::intercept_rest(). But there is no effect-level hook for the operation anywhere in the plugin — grep for create_application_password / wp_create_application_password across includes/ returns zero matches. arm_effect_guards() covers six unrelated effects, and register_function_hooks() (cli/cron/xmlrpc) covers thirteen, none of them application passwords. So every path to WP_Application_Passwords::create_new_application_password() other than those two enumerated request shapes mints a credential with no challenge and no audit event: `wp user application-password create <user>` on WP-CLI (the Limited CLI policy blocks the other gated effects but not this one), WP-Cron, XML-RPC, a third-party admin-post.php handler, or any custom REST route wrapping app-password creation. Combined with the REST case-sensitivity finding, the enumerated REST route is bypassable too. The sink is not cosmetic: an application password is a durable out-of-band credential that survives the sudo window entirely, survives the victim's password rotation, and whose own REST usage is thereafter governed only by rest_app_password_policy. This is the same enumerated-matching-with-no-backstop shape as the prior M1 finding, surviving on the single sink where it matters most, and it contradicts the readme's claim that app-password creation is gated.

    includes/class-gate.php:340-402

    Recommendation

    Add a guard on the wp_create_application_password action (WP 5.6+) to BOTH arm_effect_guards() and register_function_hooks(), mapped to rule id auth.app_password. Because that action fires after the password is generated, pair it with a pre-check, and at minimum emit wp_sudo_action_blocked so an un-gated mint is visible in the audit log even where it cannot be stopped.

  • medium A sudo session is minted automatically on every wp_login with no sudo-level challenge, before any second factor

    auth_bypass

    Plugin::grant_session_on_login() is hooked to wp_login at priority 10 and calls Sudo_Session::activate() unconditionally; its entire gate is the wp_sudo_grant_session_on_login filter, which defaults true (opt-out, not opt-in). Three consequences. (a) For the full session_duration window (default 15 minutes, plus 120s of grace) after EVERY login, the gate is open for every gated effect — plugin/theme install and activate, the plugin and theme file editors (RCE-equivalent), user delete, role change, password change, app-password create, siteurl/home/admin_email writes, core update, WXR export. During that window the security control the operator installed does nothing. (b) The plugin's own docblock at class-plugin.php:434-437 states the grant runs BEFORE the second factor: 'Two Factor hooks it at PHP_INT_MAX, so for 2FA-enrolled users this grant runs before the second factor is verified.' The sudo proof is therefore written on the strength of the password alone, for an account whose whole point is that the password alone is insufficient. (c) wp_login is a generic identity-established signal, not a password-verified one. Any component that establishes identity another way and fires it — magic-link/passwordless, social/SSO, 'log in as customer', OAuth handlers, auto-login-after-registration — mints a full sudo credential with zero password knowledge. The plugin acknowledges this at class-plugin.php:456-458. The mint is genuine, not a no-op: activate() -> set_token() writes a fully valid HMAC-signed proof and sets the cookie, indistinguishable at verification time from one earned by answering the challenge.

    includes/class-plugin.php:450-470

    Recommendation

    Default wp_sudo_grant_session_on_login to false, or at minimum gate the grant on evidence that a WordPress password was actually verified this request, and re-hook it to fire after the 2FA plugin's interrupt rather than before it. Expose the post-login grant duration separately so an operator can make it shorter than an explicitly-challenged window.

  • medium Early gate silently disarms when the mu-plugin shim loads but its loader does not — and Site Health reports the same constant as healthy

    auth_bypass

    The mu-plugin shim defines WP_SUDO_MU_LOADED at mu-plugin/wp-sudo-gate.php:18 unconditionally, as the first statement after the ABSPATH guard, before it has resolved or required the loader. The loader is the only thing that calls Gate::register_early(), which is the sole registration of gate_non_interactive() — the ENTIRE WP-CLI / WP-Cron / XML-RPC gate. Plugin::init() then treats the constant as proof the early gate is armed and skips its own registration: `if ( ! defined( 'WP_SUDO_MU_LOADED' ) ) { $this->gate->register_early(); }` (class-plugin.php:88-90). So any path where the shim runs but the loader does not arm leaves CLI, Cron and XML-RPC completely ungated with no in-plugin fallback: the baked __WP_SUDO_LOADER_PATH__ path is stale after a directory rename or site move; there is no wp-content/plugins/wp-sudo/ directory because the plugin was installed from the GitHub zip under a different directory name; or glob() returns false under open_basedir. The foreach simply falls through with no require and no signal. The failure is silent in both directions. Nothing logs, and Site_Health::test_mu_plugin_status() derives 'Sudo MU-Plugin is installed / status: good' from defined('WP_SUDO_MU_LOADED') — the very same constant — so the health check confirms the control is healthy at the moment it is off. There is also no version or integrity check anywhere: class-upgrader.php contains no mu-plugin logic, so an installed shim is never refreshed, re-personalised or validated against the shipped one on upgrade. Observable symptom: gated operations complete over WP-CLI and XML-RPC with no challenge and no wp_sudo_action_blocked event, while both Settings -> Sudo and Site Health show the MU-plugin as installed.

    mu-plugin/wp-sudo-gate.php:18-48

    Recommendation

    Make the constant mean 'the early gate is armed', not 'a file was included': move the define() to the END of mu-plugin/wp-sudo-loader.php after the muplugins_loaded registration, and have the shim define nothing. In Plugin::init() prefer an idempotent arm keyed on a flag the loader's own callback sets. Have Site_Health::test_mu_plugin_status() report 'shim present but loader unresolved' as critical when the shim file exists but the arming flag is absent, and surface it as an admin notice.

  • medium Uninstall teardown runs before the delete_plugin backstop can block it, and requires no sudo session

    auth_bypass

    Core's delete_plugins() calls uninstall_plugin() FIRST and only then fires do_action('delete_plugin'). WP Sudo's protection for a non-enumerated delete path is the effect-level backstop hooked on delete_plugin (class-gate.php:346-352) — i.e. it fires strictly after uninstall.php has already run to completion. uninstall.php's own authorization is Uninstall_Guard::is_authorized(), which requires only current_user_can('delete_plugins') and NO sudo session, and returns true unconditionally whenever WP_CLI is defined — regardless of the configured cli_policy, including 'disabled'. What completes before the block: Event_Store::drop_table() destroys the wpsudo_events table (the plugin's entire tamper-evident activity log); wp_sudo_cleanup_mu_shim() deletes the mu-plugin early gate; delete_option('wp_sudo_settings') discards every hardened policy; $editor->add_cap('unfiltered_html') hands the Editor role unfiltered_html back; and wp_sudo_cleanup_user_meta() wipes every user's sudo state including the lockout/throttle meta. The backstop then wp_die()s, so the plugin FILES survive — the site looks like nothing happened, the plugin is still installed and the gate still runs on defaults, but the audit trail is gone and the early gate is gone. The mainstream interactive paths are safe (plugins.php delete-selected, AJAX delete-plugin, and REST DELETE /wp/v2/plugins are all enumerated and intercepted before core reaches delete_plugins()), so the exposed callers are the non-enumerated ones the backstop was added for — a third-party plugin manager, a custom admin-post.php handler, or any code calling delete_plugins() directly. That is precisely the prior M1 class of caller, which means the M1 fix does not hold for the half of 'delete plugin' that destroys data.

    includes/class-uninstall-guard.php:32-42

    Recommendation

    Require an active sudo window in the uninstall guard, not just a capability, and on the CLI branch honour the configured cli_policy instead of returning true unconditionally. Better, arm the guard one hook earlier — hook pre_uninstall_plugin in arm_effect_guards() alongside delete_plugin — so the block lands before any teardown runs. The events table in particular should not be droppable from a path a non-sudo actor can reach.

  • medium Any authenticated user can write unbounded rows into the sudo activity log with no nonce and no capability, burying high-severity alarms

    dos

    Gate::intercept() is registered on admin_init priority 1 and gates on nothing but get_current_user_id() > 0. WordPress fires admin_init on admin-ajax.php as well, and matches_ajax() decides a match purely by comparing $_REQUEST['action'] against a fixed list — no nonce, no capability, and it runs BEFORE core's own current_user_can() gate on the target handler. So a Subscriber holding only a valid auth cookie can POST admin-ajax.php with action=install-plugin (or ~20 other registry actions) and produce one row in the wpsudo_events table per request via wp_sudo_action_gated -> Event_Recorder -> Event_Store::insert(). There is no rate limit, no per-user quota, no table-size bound and no dedup. Consequence 1 — anti-forensics by flooding. The dashboard widget is the ONLY reader of this table, and it renders recent_for_dashboard(50): ORDER BY created_at DESC LIMIT 50 across all event types, with no per-type reservation, no paging and no server-side filter. Roughly 50 crafted requests push every genuine lockout, escalation_blocked and session_revoked row out of the operator's view, and a sustained loop keeps them out. The widget's Time/Event/Surface dropdowns are pure client-side JS over the already-rendered 50 rows, so an operator who selects 'Blocked' after a flood gets 'No matching events' — which reads as 'nothing happened' rather than 'the server never sent it'. There is no alternative reader: Event_Store::recent() has zero callers, and the export_wp_sudo_activity capability is granted but no export feature implements it. Consequence 2 — decoy forgery. The attacker chooses which rule_id gets logged by choosing $_REQUEST['action'], so they can manufacture rows labelled plugin.delete or user.delete, which the widget decorates with a red 'Critical' badge, attributed to their own Subscriber account. Consequence 3 — unbounded table growth: retention is 14 days with no row cap.

    includes/class-event-store.php:471-498

    Recommendation

    Rate-limit or deduplicate action_gated/action_blocked writes per user per rule per short window before Event_Store::insert(). Reserve widget slots by severity: run a second bounded query for escalation_blocked, lockout, role_drift_detected and session_revoked and merge, so routine noise cannot bury an alarm. Make the widget's filters server-side, or at minimum surface the total row count so 'No matching events' cannot be mistaken for 'no such events exist'. Cap total table rows and prune by count as well as age.

  • medium The escalation_blocked alarm records the target user, not the actor — the widget implicates the victim

    insecure_config

    Event_Recorder::on_escalation_blocked() writes the hook's first argument into the row's user_id column and puts only array('severity' => 'high') in context. But at all three fire sites that first argument is $target_id — the user being promoted, granted super admin, or deleted — not the actor. The acting user is computed at every one of those sites (`$actor = (int) get_current_user_id();` at class-gate.php:999, 1054, 1131) and then discarded: it is passed to neither the hook nor the row. The single highest-severity event this plugin can raise therefore carries no attribution. The dashboard widget renders that user_id in its 'User' column with no marker distinguishing actor from target, beside a red 'Escalation' pill — so an operator investigating reads 'User: alice — Escalation — Promote user' and concludes alice attempted a privilege escalation, when alice is the account someone tried to promote. Every other event type in the recorder uses user_id as the actor, so the inconsistency is invisible in the UI and actively misleading. on_session_revoked had the same shape and solved it: it stores the target in user_id and mirrors the operator into context['revoked_by']. on_escalation_blocked never got that treatment. The more serious case is the one class-gate.php:995 itself names — 'an escalation reaching a broken-access-control route', i.e. a lower-privileged actor exploiting a third-party BAC to write wp_N_capabilities. In exactly that scenario the alarm designed to catch it names the promoted account and leaves the actor unrecorded.

    includes/class-event-recorder.php:264-274

    Recommendation

    Mirror the on_session_revoked pattern: capture the actor (get_current_user_id() is correct here, since the hook fires synchronously in the attacker's own request) and store it in context['actor'], then render an explicit target-vs-actor distinction in the widget's User column. Note DASHBOARD_SELECT_COLUMNS omits `context`, so the actor must be added to that column list or carried in a column the widget already selects.

  • medium WP 2FA bridge disables TOTP replay protection by omitting the $user argument to is_valid_authcode()

    auth_bypass

    WP 2FA 4.1.0 implements TOTP replay protection inside \WP2FA\Authenticator\Authentication::is_valid_authcode( $key, $authcode, $user = null ): it reads the user's wp_2fa_last_totp_step meta, rejects a code whose time step is <= the last accepted step, and persists the accepted step — but ONLY when the third argument is a WP_User. WP 2FA's own callers always pass it (TOTP::validate_token() and TOTP::validate_totp_authentication()). The WP Sudo bridge calls it with two arguments. $user is therefore null, $last_used_step stays 0, the replay check can never fire, and the accepted step is never persisted. Consequence: the sudo challenge accepts a TOTP code that has ALREADY been consumed — including the code the victim used to log in through WP 2FA's own gate seconds earlier — so an attacker holding the victim's session plus one observed or phished code can mint a sudo session. And because the step is never burned, the same code stays valid across the whole configured time-step allowance and remains usable against WP 2FA's login gate afterwards. This directly defeats the freshness property a sudo window exists to provide: the point is a proof generated just now, and a replayable proof is a proof generated at some earlier time. This is the only WP 2FA method affected — the email path deletes the stored token on success and Backup_Codes::validate_code deletes the used code, so both are genuinely one-time. Only the TOTP branch drops the replay argument.

    bridges/wp-sudo-wp2fa-bridge.php:177-188

    Recommendation

    Pass the user so WP 2FA's own replay protection engages: is_valid_authcode( $key, $code, $user ). Better, delegate to WP 2FA's own validation entry point so the bridge inherits upstream's replay handling, failure counters and any future hardening instead of re-implementing a subset of it.

  • medium Single-site settings save is authorized by manage_options, not manage_wp_sudo — the capability separation is not enforced on the write path

    missing_capability

    The settings page is registered with manage_wp_sudo (class-admin.php:414) and render_settings_page() re-checks wp_sudo_can('manage_wp_sudo'). The multisite save handler checks it too. But the single-site save does not: register_setting() hands the write to wp-admin/options.php, which resolves the required capability via apply_filters("option_page_capability_{$option_page}", 'manage_options') — and the plugin registers no option_page_capability_wp-sudo-settings filter anywhere in the tree. The effective authorization for writing wp_sudo_settings on single-site is therefore manage_options. That inverts the plugin's stated model. functions-governance.php:39-45 states the governance model 'deliberately separates manage_wp_sudo from manage_options', and Plugin::activate() grants the four governance caps to the ACTIVATING USER ONLY — never to the administrator role. So on a site with two administrators, the one deliberately excluded from Sudo governance is still authorized by core to write the option. The gap runs the other way too: a non-admin granted manage_wp_sudo — the entire point of the separate capability — is wp_die()'d by options.php when they press Save, so the intended non-admin manager role cannot actually save settings.

    includes/class-admin.php:650-662

    Recommendation

    Add add_filter( 'option_page_capability_' . self::PAGE_SLUG, fn() => 'manage_wp_sudo' ); in register_settings(), so options.php authorizes with the same capability the menu and the render guard use. wp_sudo_map_governance_meta_cap() already maps manage_wp_sudo through map_meta_cap, so core's current_user_can() will evaluate it correctly and the non-admin manager path starts working as designed.

  • medium Every settings save silently wipes all per-application-password policy overrides

    insecure_config

    sanitize_settings() rebuilds the whole option from the submitted form and derives app_password_policies solely from $input['app_password_policies']. The settings form never renders any field with that name — register_sections() emits only policy_preset_selection, session_duration and the five surface policy selects, and a repo-wide grep finds no form input anywhere. So on every save the key is absent, the loop never runs, and $sanitized['app_password_policies'] is written as an empty array, discarding every per-password override. Those overrides are a security control in their own right: Gate::get_app_password_policy() consults them BEFORE falling back to the global REST App Password policy, and handle_app_password_policy_save() correctly requires manage_wp_sudo AND an active sudo session to set one. The dangerous direction is a stricter-than-global override. The plugin ships a 'Headless Friendly' preset that sets rest_app_password_policy to Unrestricted; an operator on that preset who pins one high-risk token to 'limited' or 'disabled' loses that pin the next time anyone saves the Settings tab for an unrelated reason. The token silently regains fully ungated REST access, with no notice, no audit event and no UI trace — while the operator's mental model still says it is restricted. The same wipe occurs on the multisite path, which routes through the same sanitizer.

    includes/class-admin.php:991-1024

    Recommendation

    Seed $app_password_policies from the stored settings (get_stored_settings() is already called in this method) and only replace it when $input['app_password_policies'] is present, or round-trip the existing map as hidden inputs. Add a regression test asserting that saving the Settings tab leaves an existing per-UUID override intact.

  • medium Critical-alert bridge burns an event's dedupe reservation before the cap check, permanently discarding suppressed alerts

    dos

    wp_sudo_critical_alert_bridge_flush() calls reserve($event, $window) FIRST. reserve() is not a query — it SETS the dedupe transient and returns true. Only afterwards does the function consult the hourly cap and `continue` without dispatching. The alert is therefore never delivered, yet its dedupe slot is consumed for the full window (default one hour). If the same event fires again inside that window — even after the cap counter has rolled over — reserve() returns false and the event is skipped entirely. The alert is not deferred, it is destroyed. The cap is ONE counter shared by every event key at a given scope, default 10/hour, and two of the mapped hooks are attacker-driven by construction. wp_sudo_escalation_blocked has dedupe identity $rule . ':' . $target, so an attacker cycling ten target user IDs produces ten distinct identities that all pass dedupe and each increment the shared counter. wp_sudo_lockout has identity $user . ':' . $ip and is reachable by any logged-in user, including a Subscriber, via the nonce-checked challenge handler by submitting wrong passwords. So an attacker can deterministically spend the hour's alert budget on noise of their own choosing, and the next genuinely urgent event — wp_sudo_capability_tampered, or a real escalation_blocked — is suppressed AND its dedupe slot burned, so it will not be re-sent when it recurs. The operator receives only 'N further critical event(s) were suppressed this hour', naming neither the event key nor the target. That is an anti-forensics primitive against the very alerting channel this bridge exists to provide. Secondary defect in the same block: the digest is hard-coded 'scope' => 'network', so on multisite it routes to the network admin even when every suppressed event was site-scoped — the site administrator whose alerts were dropped is never told.

    bridges/wp-sudo-critical-alert-bridge.php:299-337

    Recommendation

    Check the cap BEFORE reserving, so a capped-out event keeps its dedupe slot and is delivered once the window rolls over. Give the cap per-event-key budgets, or at minimum separate budgets for attacker-driven events (lockout, escalation_blocked) versus operator-integrity events (capability_tampered, missing_builtin_rules), so cheap noise cannot starve the alerts that matter. Make the digest enumerate the suppressed event keys and derive its scope from the suppressed events rather than hard-coding 'network'.

  • low Challenge page renders the 2FA field with no pending-state gate and no send throttle, allowing a cross-site-triggerable OTP send

    dos

    Challenge::render_page() calls render_two_factor_fields($user) unconditionally whenever the challenge page draws and the user has no active sudo session. That invokes the primary Two Factor provider's authentication_page(), which for \Two_Factor_Email is state-changing: it generates and emails a fresh OTP when no valid token exists. The plugin recognised this on the sibling path — handle_ajax_2fa_partial() gates the send behind Sudo_Session::get_2fa_pending() AND a wp_sudo_resend_<user_id> counter capped at 3 per 5 minutes, with an inline comment marking it as a hardening fix. The identical send on the full-page render has NEITHER gate: no pending-state requirement, no throttle, no nonce, and it sits on a page registered at capability 'read'. An unauthenticated attacker can therefore trigger it against any logged-in victim with <img src="https://site/wp-admin/admin.php?page=wp-sudo-challenge"> on any page the victim visits — no stash_key needed, since session-only mode renders the same fields. With the stock Two Factor provider the damage is bounded by the provider's token lifetime. It is NOT bounded when the shipped WP2FA bridge is deployed: that bridge calls generate_token() unconditionally for the email method with no has-valid-token check, so every request produces one email — an unbounded OTP flood at the victim's inbox and at the site's outbound mail reputation.

    includes/class-challenge.php:556-564

    Recommendation

    Apply the discipline the AJAX partial already has: only emit the 2FA step when the request actually reached the 2FA stage, or render it lazily via the existing throttled AJAX partial, and route every provider render that can send through the shared wp_sudo_resend_<user_id> counter. Separately, fix the WP2FA bridge to check for a live token before calling generate_token().

  • low Reauthentication lockout fully self-resets every 300s, leaving a sustained password oracle for a session thief

    unsafe_input

    handle_ajax_auth() is reachable by any logged-in user with only a wp_sudo_challenge nonce, and attempt_activation() answers with a distinguishable 401 invalid_password versus 200 success — a clean password oracle. The throttle is real and not evadable (the per-IP key derives from REMOTE_ADDR only, filter_var-validated, with no X-Forwarded-For; the per-user half is usermeta keyed on get_current_user_id()). But it has no long-horizon ceiling: MAX_FAILED_ATTEMPTS is 5 and LOCKOUT_DURATION is 300s, and when the 300s elapses is_locked_out() calls reset_failed_attempts(), which deletes the per-user failure rows AND, via the stored pointers, both IP-scoped transients including the rolling 24-hour failure window. So the counter returns to zero every five minutes rather than accumulating: a steady 5 guesses per 5 minutes is roughly 1,440 attempts per day, indefinitely, with no operator notification beyond the wp_sudo_lockout action. The actor is precisely the one this plugin exists to stop — someone holding a stolen session but not the password. The consequence is worse than the session they already hold: recovering the plaintext password permanently defeats the sudo gate, survives session revocation, and is reusable against the victim's other accounts.

    includes/class-sudo-session.php:796-810

    Recommendation

    Keep the rolling 24-hour per-(IP,user) failure window across lockout expiry instead of clearing it in the natural-expiry path — leave the full clear to the operator-initiated clear_reauth_lockout(), which already exists — and/or escalate LOCKOUT_DURATION geometrically per lockout episode. Surface repeated lockouts in the activity log and the critical-alert bridge so an operator sees a grind in progress.

  • low mu-plugin shim require_once's the first glob match under wp-content/plugins/*/mu-plugin/, with no origin check

    file_access

    When the baked path and the canonical wp-sudo/ path both miss, the shim falls back to glob( WP_PLUGIN_DIR . '/*/mu-plugin/wp-sudo-loader.php' ) and require_once's the first existing match. It does not verify the discovered file's origin, contents, header, or that it belongs to an active WP Sudo installation. Consequence: an actor who can place a file at wp-content/plugins/<anydir>/mu-plugin/wp-sudo-loader.php gets that PHP executed as a must-use plugin on EVERY request — front-end, wp-cron.php, xmlrpc.php, admin — before every regular plugin and before authentication, without ever requesting the file's URL. That is strictly more powerful than dropping a webshell: it survives removal of whatever plugin created it, runs for unauthenticated visitors, and executes ahead of any security plugin. The precondition is a file-write primitive scoped to a plugin subdirectory (a path traversal or arbitrary write in some other plugin, or a partially-restricted upload) — this shim converts a constrained write into reliable persistent execution. Reachability is higher than the 'last-resort fallback' framing suggests: the plugin's own manual-install instructions tell operators to copy the unpersonalized template, whose sentinel guard discards candidate 1 entirely, so for an install whose directory is not literally wp-sudo/ the glob is the live resolution mechanism on every request.

    mu-plugin/wp-sudo-gate.php:28-48

    Recommendation

    Constrain the fallback to a directory WordPress itself vouches for: derive the plugin slug from get_option('active_plugins') / get_site_option('active_sitewide_plugins') (entries ending in /wp-sudo.php) and build the loader path from that. Before require_once, assert the file's header contains the loader's own identifying marker, and bail with the existing wp_sudo_mu_loader_unresolved_plugin_path signal otherwise.

  • low Uninstall deletes the mu-plugin shim from a hardcoded path while installation writes it to WPMU_PLUGIN_DIR

    insecure_config

    Installation resolves the destination through Admin::get_mu_plugin_dir(), which honours a custom WPMU_PLUGIN_DIR. Uninstall does not: wp_sudo_cleanup_mu_shim() deletes only the hardcoded WP_CONTENT_DIR . '/mu-plugins/wp-sudo-gate.php'. On a site defining a custom WPMU_PLUGIN_DIR — common on managed hosts and in wp-config hardening setups — the shim is written to the custom directory and never removed. After the plugin is uninstalled and its files deleted, that file keeps executing on every request forever, defining WP_SUDO_MU_LOADED and falling through to the glob discovery described in the adjacent finding, with no WP Sudo installation left to constrain what it finds. The same mismatch means an operator who moves their mu-plugins directory and reinstalls ends up with two shims.

    uninstall.php:190-196

    Recommendation

    Resolve the path identically on both sides: use WPMU_PLUGIN_DIR when defined, falling back to WP_CONTENT_DIR . '/mu-plugins', and for safety delete from both locations. Verify the file being deleted is WP Sudo's shim (a marker comment or hash) before unlinking.

  • low Sudo session is unscoped: one challenge authorizes every gated action, and is network-wide on multisite

    insecure_config

    The proof record carries only token, expires and hmac — no action, rule or blog scope. Every enforcement call site passes only a user ID (Sudo_Session::is_active($user_id) at twelve sites in class-gate.php plus class-public-api.php:65); there is no per-rule variant. Consequence: a challenge the operator answered believing they were confirming a low-stakes change — admin_email, or the plugin's own settings page — equally authorizes plugin install, theme install and the plugin/theme file editor (an RCE-equivalent effect) for the remainder of the window. Multisite dimension: the state is written with update_user_meta(), which is the network-global wp_usermeta table, and the cookie is scoped to COOKIE_DOMAIN, which multisite sets to the shared network domain. A sudo window earned on a low-stakes subsite therefore satisfies the gate for network-admin operations and for every other subsite. This is rated low as a standalone — unscoped sudo is how sudo(8) itself behaves, and the actor must already hold the capability for the target action since WordPress capability checks run independently — but it is a meaningful chain amplifier for any finding that induces a single challenge.

    includes/class-sudo-session.php:1035-1039

    Recommendation

    Record the rule_id that triggered the challenge in the (already HMAC-covered) proof record and let Gate rules opt into scoped verification, so a challenge answered for a low-consequence rule does not silently authorize plugin-install or the file editor. On multisite, fold get_current_blog_id() into the HMAC so a subsite window does not satisfy network-admin gates.

  • low Session TTL is read unclamped at mint time; the documented 1-15 minute bound lives only in the settings-form sanitizer

    insecure_config

    activate() computes the credential's lifetime as (int) Admin::get('session_duration', 15) with no clamp, and Admin::get() returns the raw stored option value verbatim. The only enforcement of the documented 1-15 minute bound is sanitize_settings(), which runs solely as the register_setting() sanitize_callback and on the network settings save. Any other write to the wp_sudo_settings option — the settings-write authorization bypass reported separately, third-party code calling update_option(), the upgrader (which writes Admin::OPTION_KEY directly at two sites, bypassing the sanitizer), or a direct DB write — sets an arbitrary sudo TTL. Critically, the resulting expiry is then HMAC-signed by build_hmac() and honoured by resolve_valid_proof() as a legitimately-issued value; there is no sanity ceiling on the enforcement path. A single option write therefore converts the product from a 15-minute window into a permanently-open gate, and the record is cryptographically valid so no tamper detection fires. The negative direction is fail-closed: a negative or zero duration yields an already-past expiry.

    includes/class-sudo-session.php:405-416

    Recommendation

    Clamp at the point of use, not only in the form sanitizer: $duration = max( 1, min( 15, (int) Admin::get( 'session_duration', 15 ) ) ). Optionally also reject any resolved proof whose expires - issued_at exceeds the ceiling, so a pre-existing over-long record cannot be honoured after the fix.

  • low Sudo cookie's Secure flag is conditional and filter-overridable, and its path was widened to the whole site

    insecure_config

    cookie_secure() derives the Secure flag from is_ssl() || force_ssl_admin() and then runs it through the public wp_sudo_cookie_secure filter. Both the sudo credential cookie and the 2FA challenge cookie take their secure attribute from it. On a TLS-terminating reverse proxy that neither populates $_SERVER['HTTPS']/X-Forwarded-Proto nor defines FORCE_SSL_ADMIN — a misconfiguration the docblock itself names — the bearer credential is issued WITHOUT the Secure flag and can be emitted over any plaintext request to the same host. Separately, the cookie path is COOKIEPATH (the site root) rather than ADMIN_COOKIE_PATH, and 4.9.2 actively expires the old /wp-admin-scoped cookie to force the wider scope. The credential is therefore transmitted on every front-end request, widening exposure to any front-end component that logs or reflects request headers. Mitigating controls are real and unconditional: httponly is always true, samesite is always Strict, and the maximum lifetime is 15 minutes; a stolen cookie is also useless without the victim's LOGGED_IN cookie.

    includes/class-sudo-session.php:931-943

    Recommendation

    Default the Secure flag to true for a security-control credential and require an explicit opt-out constant for plain-HTTP development, rather than inferring it from is_ssl(). Consider issuing two cookies (a COOKIEPATH one for front-end/REST gates and an ADMIN_COOKIE_PATH one for wp-admin) so the credential is not broadcast on every front-end page view.

  • low Sudo window survives a password change made through a code path that fires no hook, and survives a role demotion

    auth_bypass

    Enumerating the lifecycle hooks actually registered: wp_logout, after_password_reset and profile_update (with a correct old-vs-new password-hash comparison) all tear the window down. Missing: clear_auth_cookie, wp_destroy_all_sessions, wp_destroy_other_sessions, delete_user/deleted_user, set_user_role, remove_user_from_blog. Most are harmless — the login-session binding added in 4.1.0 makes the session-destroy family moot, and core deletes usermeta on user delete. Two are real, if narrow. (1) A password changed by a direct wp_set_password() call fires neither after_password_reset nor profile_update, so the old sudo credential stays valid for up to session_duration plus the 120s grace — a stolen-cookie attacker retains gated authority through the very password rotation intended to evict them. (2) A role demotion via set_user_role() does not end the window, though WordPress capability checks still apply independently so the practical impact is limited to the reauth gate itself.

    includes/class-plugin.php:160-174

    Recommendation

    Add wp_destroy_all_sessions / wp_destroy_other_sessions and set_user_role teardown hooks for completeness, and — since wp_set_password() fires no action — fold a prefix of $user->user_pass into build_hmac(), which makes every password change invalidate every outstanding proof automatically regardless of which code path performed it.

  • low Challenge Target line can under-describe the request it names, weakening consent for a general sudo grant

    insecure_config

    The challenge page grants a GENERAL sudo session — the class docblock says so explicitly — which makes the 'Target:' line the only thing telling an operator what their password is buying. Three defects erode it. (1) capture_target() calls target_value_echoes_stored_option() for every matched rule with no rule context, so any rule whose effect field happens to be named siteurl, home, admin_email, new_admin_email, default_role or users_can_register has that field DROPPED from the Target line whenever the submitted value equals the stored option — even when for that rule the field is not an option write at all. The file documents this as a known issue deferred during a release freeze. (2) truncate_target_value() cuts at 100 characters and appends no ellipsis or marker, so a bulk users[] list renders as a short, complete-looking list while the request carried more. (3) The compensating signal, target_complete, is computed, stored, and read by nothing. Net effect: an operator lured into a same-origin gated request sees a coarse label plus a Target line that may be blank, partial, or truncated without saying so, and reauthenticates — opening a general sudo window. Rated low because the amplifier is gone: automatic replay was removed in 4.9.0, so a mis-described stash is never executed by the server. The residual harm is degraded informed consent.

    includes/class-request-stash.php:432-505

    Recommendation

    Thread the matched rule into capture_target() and apply target_value_echoes_stored_option() only when the matched rule is options.critical. Append a visible marker when a value is truncated or a target param suppressed. Either surface target_complete on the challenge page or delete it, so no future reader mistakes it for a live guard.

  • low Stashed privileged request body is stored unsanitized at rest and is not swept on uninstall

    information_disclosure

    Two hygiene defects around the at-rest copy of a privileged request. (1) sanitize_params() does not sanitize anything despite its name — it only OMITS sensitive keys and copies every other value verbatim, still slashed, into the stored array. For user.create that means user_login, email, first_name, last_name, url, role and locale sit raw in a wp_options transient; for user.delete the target user IDs; for the plugin's own settings rules the whole wp_sudo settings array. No production code reads the stashed POST data today (the would-be reader, the auto-submit form, is dead since 4.9.0), so this is latent rather than exploitable — but the stored values are raw request bytes, and the first consumer that echoes them inherits an XSS sink. (2) uninstall.php performs a raw-SQL sweep for the wp_sudo_ip_* lockout transients and deletes the stash INDEX usermeta, but never sweeps the _transient__wp_sudo_stash_* rows themselves, so captured request bodies outlive the plugin in wp_options until WordPress's daily expired-transient sweep. Verified negative worth recording: passwords are genuinely NOT stashed. pass1, pass2, pass1-text, pwd, password, user_pass, token, secret and api_key are all caught by the sensitive-key filter (plus suffix matching) before the value is copied, on every path including capture_target().

    includes/class-request-stash.php:898-943

    Recommendation

    Rename sanitize_params() to filter_sensitive_params() so no future reader trusts the name, and apply an actual scalar sanitizer before storage. Add a raw-SQL sweep for _transient__wp_sudo_stash_% and its timeout twin (and the sitemeta equivalents) alongside the existing lockout-transient cleanup in uninstall.php.

  • low Site-wide application-password policy map is localized into profile.php for any manage_wp_sudo holder

    information_disclosure

    maybe_enqueue_app_password_assets() reads the GLOBAL uuid-to-policy map covering every application password of every user on the site and localizes it verbatim as wpSudoAppPasswords.policies. The enqueue fires on hook_suffix profile.php as well as user-edit.php, and its only gate is wp_sudo_can('manage_wp_sudo'). Under this plugin's own governance model that caller need not be an administrator, so a non-admin governance operator viewing their OWN profile page receives the complete inventory of which application-password UUIDs across the site are marked 'unrestricted' — i.e. precisely which credentials bypass the sudo gate on REST. No credential material is exposed: UUIDs are identifiers and WP_Application_Passwords stores only hashes. The value is targeting intelligence, and it names the gate-bypassing credentials, which is why it is reported rather than dropped.

    includes/class-admin.php:3588-3637

    Recommendation

    Filter the map to the UUIDs belonging to the profile being rendered before localizing: intersect the keys against WP_Application_Passwords::get_user_application_passwords($profile_user_id). The JS only ever indexes it by the UUIDs present in the rendered table, so narrowing the payload is behaviour-preserving.

  • low Version-stamped migrations, including a governance-capability backfill, are drivable from unauthenticated admin-ajax requests

    unnecessary_attack_surface

    Plugin::init() runs the upgrader under `if ( is_admin() || ( defined('WP_CLI') && WP_CLI ) )`. is_admin() is TRUE for admin-ajax.php, which is reachable unauthenticated, so any anonymous request to /wp-admin/admin-ajax.php executes Upgrader::maybe_upgrade() — the adjacent comment ('front-end visitors never trigger migrations') is inaccurate about the actor. Those routines are privileged writes: upgrade_3_3_0() grants all four governance capabilities to EVERY administrator when no holder exists; two routines issue CREATE TABLE; others rewrite the settings option and remove_role('site_manager'). Because the only gate is version_compare against the plain wp_sudo_db_version option, any primitive that can write an arbitrary option lets an actor lower the stamp and force every routine to re-run — including the governance backfill, which would re-grant Sudo governance authority to administrators an operator had deliberately revoked via the Access tab. No migration mints a sudo session, deactivates the gate, deletes the log or sets a permissive default, and none is reachable with attacker-controlled data, which is what keeps this low. On a current install the stamp equals the runtime version, so the unauthenticated trigger is a no-op in the steady state.

    includes/class-plugin.php:72-77

    Recommendation

    Replace is_admin() with a real actor test — is_user_logged_in() && current_user_can('activate_plugins') — or hook the upgrader to admin_init / upgrader_process_complete rather than plugins_loaded. Guard the sequence with a short-lived lock option so concurrent requests cannot interleave routines, and treat a stored version lower than an already-recorded high-water mark as tampering rather than as licence to re-run privilege-granting routines.

  • low Bulk sudo-session revocation consumes one rate-limit slot for an unbounded batch

    dos

    handle_bulk_revoke_sessions() reads $_REQUEST['users'] as an unbounded int array and then consumes exactly one rate-limit slot for the entire batch before iterating the teardown over every selected ID. The per-user row action, by contrast, burns one slot per revocation. An operator holding revoke_wp_sudo_sessions with an active sudo session can therefore revoke every sudo session on the site with one hand-crafted POST listing all user IDs, and repeat that ten times an hour — while the operator-facing message on rate-limit says 'You may revoke at most 10 sessions per hour', a control the code does not enforce. Effect is denial of the control's availability (other managers repeatedly kicked out of their sudo windows) plus an inaccurate security assurance. The target-side guards are sound: the teardown applies self-target, is_user_member_of_blog() and liveness checks, so forged user IDs cannot reach other network sites.

    includes/class-admin.php:1689-1712

    Recommendation

    Charge one slot per successful teardown, or cap the batch at the remaining slot count and report a partial result. Align the rate_limited notice text with whatever the code actually enforces.

  • low Sudo-session revocation result notice is rendered from unauthenticated query args, so a forged link fabricates a 'sessions revoked' confirmation

    unsafe_input

    render_revoke_result_notice() is hooked to admin_notices and reads wp_sudo_revoke_result, wp_sudo_revoke_count and wp_sudo_revoke_skipped_self straight from $_GET with no nonce and no integrity check, then renders the mapped message. The docblock claims 'an unrecognized/forged code fabricates none', which is true only for unrecognized codes — every RECOGNIZED code is forgeable. A link to /wp-admin/users.php?wp_sudo_revoke_result=success&wp_sudo_revoke_count=3 shows an administrator a green '3 active sudo sessions revoked.' notice when nothing was revoked. For a security product this is a false assurance delivered at exactly the moment the operator is doing incident response: they believe an attacker's sudo window was terminated when it is still open. The inverse ('You don't have permission to revoke sudo sessions.') is also available, to discourage a retry. No XSS is possible — every message is a static translated string and the count passes through absint() into a %d.

    includes/class-admin.php:1769-1804

    Recommendation

    Carry the outcome in a short-lived per-user transient (or a signed value) set by the handler and consumed once by the notice, instead of trusting redirect query args; or append a nonce to the redirect and verify it before rendering.

  • low Audit writes fail silently when the events table is absent, leaving a UI indistinguishable from 'nothing happened'

    insecure_config

    Every write path degrades to a silent no-op if the table is missing: insert() returns false and bulk_insert() returns 0 when table_exists() is false. No caller inspects those return values. create_table() returns silently when dbDelta cannot be loaded, and maybe_create_table() wraps the attempt in $wpdb->suppress_errors(true), so a failed CREATE produces no error anywhere. maybe_create_table() is reached from only two places: the Upgrader's migrations and the dashboard widget's own render. On a host where the DB user lacks CREATE privileges — routine on managed and hardened hosting — the migration silently fails and every subsequent security event is discarded. The widget then prints 'No recent activity', byte-identical to what a genuinely quiet site shows. The plugin has admin notices for far less but none for 'the audit table does not exist'. For a plugin whose secondary asset is a tamper-evident record, an audit subsystem that fails open AND fails silent is a false-assurance defect: the operator believes actions are being recorded and they are not.

    includes/class-event-store.php:198-245

    Recommendation

    Render a persistent notice on the Sudo settings screen and inside the widget when Event_Store::table_exists() is false. Have the Upgrader record a wp_sudo_events_table_missing option when create_table() does not result in an existing table, and re-attempt on later admin loads. At minimum, distinguish the widget's 'No recent activity' empty state from a 'logging unavailable' state.

  • low Both high-severity alarm producers are disabled by default, and the widget cannot filter for the security event types it does support

    insecure_config

    The activity log's two high-severity event types never occur on a default install. escalation_blocked: all three producers return immediately unless a third party opts in via apply_filters('wp_sudo_guard_escalation', false), and nothing in the plugin sets it true. role_drift_detected: Role_Audit::run_sweep() is registered unconditionally but is inert unless a manifest is configured. Event_Recorder subscribes to both and the widget carries labels and dedicated CSS for escalation_blocked — all dead code on a stock install. An operator looking at a quiet Recent Events panel reasonably infers 'no escalation attempts', when escalation detection was never armed and nothing says so. Two compounding UI gaps: role_drift_detected has no entry in event_labels(), so when it does fire it renders as a raw slug with no pill CSS — the least visually prominent row in the table; and the widget's Event-type filter omits escalation_blocked, role_drift_detected, session_revoked and recovery_mode, so an operator cannot isolate them even when present in the 50-row buffer.

    includes/class-dashboard-widget.php:318-417

    Recommendation

    Default wp_sudo_guard_escalation to true, or expose it as a settings toggle defaulting on. Add a role_drift_detected label and pill CSS. Add escalation_blocked, role_drift_detected, session_revoked and recovery_mode to the Event-type filter. Render a one-line notice in the widget when escalation guarding or the role manifest is not configured, so an empty panel is never mistaken for a clean record.

  • low Server-returned 2FA partial is injected via dangerouslySetInnerHTML in the block-editor reauth modal

    missing_escaping

    handle_ajax_2fa_partial() buffers render_two_factor_fields() and returns it as an html string. The consumer is admin/js/wp-sudo-editor-reauth.js, `dangerouslySetInnerHTML: { __html: partialHtml }` — a genuine innerHTML sink, so an event-handler payload (<img onerror=...>) would execute in an authenticated admin context, inside the exact modal where the admin is about to type their password. The HTML is composed from two sources WP Sudo does not own: the Two Factor plugin's $provider->authentication_page($user) and the public wp_sudo_render_two_factor_fields action. Every renderer shipped in this build was audited and none carries attacker-controlled data — the modal-capable allowlist is default-deny and limited to Two_Factor_Totp / Two_Factor_Email / Two_Factor_Backup_Codes, and the bundled WP2FA bridge escapes all its output. So this is NOT a live XSS; it is filed as an unescaped-sink contract risk because any provider or third-party hook that echoes a request value inherits an admin XSS on the reauth modal with no escaping layer in between.

    includes/class-challenge.php:750-759

    Recommendation

    Run the buffered partial through wp_kses() with a form-fields-only allowlist (input/label/p/span/select/option plus a safe attribute set, explicitly no on* attributes) before wp_send_json_success(), and document the constraint on the wp_sudo_render_two_factor_fields hook. That preserves the provider-verbatim contract while removing the executable-payload class.

  • low Non-interactive deactivation guard uses a count() heuristic that an equal-length active_plugins rewrite evades

    auth_bypass

    On CLI, cron and XML-RPC the only deactivation interception is the pre_update_option_active_plugins filter, which fires the block callback only when count($new_value) < count($old_value). A write that removes one plugin while adding another in the same update_option() call leaves the count unchanged and is not treated as a deactivation, so WP Sudo can be swapped out of the active list on those surfaces without the gate firing. The same filter is the ONLY deactivation coverage anywhere in the plugin — arm_effect_guards() registers no deactivation hook — so on the interactive and REST surfaces there is no second layer to fall back on.

    includes/class-gate.php:741-754

    Recommendation

    Replace the count comparison with array_diff( (array) $old_value, (array) $new_value ) and fire when the diff is non-empty, so any removal is caught regardless of what else changed in the same write.

  • low Last-manager guard in handle_revoke_cap() is a non-atomic check-then-act and undercounts multisite super admins

    dos

    The guard reads count_manage_wp_sudo_holders() and then, in a separate step, calls $target->remove_cap($cap). Two concurrent revoke requests can both observe count==2, both pass the <= 1 test, and both remove the capability, leaving the site with zero manage_wp_sudo holders — at which point the Sudo settings page, the Access tab and every governance AJAX endpoint are unreachable, and the only recovery is editing wp-config.php to define WP_SUDO_RECOVERY_MODE. Separately, count_manage_wp_sudo_holders() uses get_users(array('capability'=>'manage_wp_sudo')), and WP_User_Query capability queries read the STORED capabilities meta — so it cannot see multisite super admins, who pass wp_sudo_can() through the is_super_admin() short-circuit without holding the stored cap. On multisite the guard therefore blocks a revocation that would in fact have left the network fully administrable. The same limitation is documented for the drift panel but was not carried across to this counter.

    includes/class-admin.php:2387-2401

    Recommendation

    Re-count immediately after remove_cap() and restore the capability if the count reached zero, or serialize the operation behind a short transient lock. Union get_super_admins() into count_manage_wp_sudo_holders() on multisite so the guard reflects effective, not merely stored, governance authority.

  • low Cross-site-triggerable stash write evicts a victim's pending challenge

    dos

    Gate::intercept() runs at admin_init priority 1 and calls Request_Stash::save() purely on request-pattern match, before any nonce or referer validation and with no capability check. A cross-origin page can therefore make any logged-in user's browser issue a gated-shaped GET (e.g. <img src="https://victim/wp-admin/plugins.php?action=delete&plugin=x">) and force a stash write it never sees the result of. Each save() performs a usermeta write, two wp_options rows and a Set-Cookie. enforce_stash_cap() evicts the OLDEST entry first, so five lure requests reliably destroy a stash the victim created moments earlier — their open challenge tab then dead-ends on 'Invalid or expired challenge. Please try again.' A Subscriber can drive the same writes directly with no lure at all. The write is bounded — MAX_STASH_PER_USER is a deliberately non-filterable 5 and every stash carries a 300s TTL — so this is a nuisance and an interruption, not storage exhaustion.

    includes/class-request-stash.php:736-751

    Recommendation

    Require a plausible same-origin signal before performing the stash WRITE (the file already computes Sec-Fetch-Site in mint_binding_proof() — reuse it), or defer the write until the challenge page is actually rendered. When evicting under the cap, prefer evicting the newest entry so a flood cannot destroy an in-flight challenge.

  • low WP 2FA bridge challenge path has state-changing side effects that can make the challenge unsatisfiable

    dos

    Two state mutations inside what should be render and validate paths. (1) The render path calls \WP2FA\Authenticator\Authentication::generate_token() under the comment 'Generate and send the email OTP now.' Verified against WP 2FA 4.1.0: generate_token() only computes a code, stores wp_hash($token), and RETURNS the plaintext — it sends nothing; the only upstream caller takes the returned token and builds the mail itself. The bridge discards the return value. So the user is shown 'Enter the code sent to your email' and no email is ever sent, while the stored token hash is OVERWRITTEN, invalidating any code the user legitimately holds. The email-method sudo challenge can therefore never be satisfied. (2) The validate path calls \WP2FA\Methods\TOTP::get_totp_key(), which when the user has no stored key GENERATES one and PERSISTS it. A mere sudo-challenge attempt thus provisions a fresh TOTP secret for a user whose enrollment is incomplete — a secret nobody's authenticator app holds, which then also governs WP 2FA's own login gate. Both are fail-closed for the security control (nobody gains access); the consequence is availability — the administrator is locked out of the operations Sudo gates. Reported because a security control that cannot be satisfied is a control operators will disable.

    bridges/wp-sudo-wp2fa-bridge.php:88-92

    Recommendation

    For email: call WP 2FA's own send routine rather than the bare token generator, and do not overwrite an unexpired pending token — mirror the user_has_token()/user_token_has_expired() check the plugin already uses for the built-in Two_Factor_Email path, and share that resend throttle. For TOTP: read the stored secret without the provisioning side effect and treat 'no stored secret' as a failed challenge, so a validation path never writes credential state.

  • low Buffered flush stamps site_id at shutdown, misfiling multisite cross-blog escalation alarms

    insecure_config

    Event_Recorder::arm_buffer() is called on every request; all eleven hook callbacks enqueue into memory and nothing is written until a single bulk INSERT on shutdown. Event_Store::bulk_insert() resolves the site at FLUSH time via get_current_blog_id(), not at the time the event occurred. This matters because the escalation guard exists to catch cross-blog capability writes: actor_can_promote_on_target_blog() parses the target blog out of a wp_N_capabilities meta key and switch_to_blog()s into it purely to evaluate authority, then restores. The alarm therefore fires while the request is still on the ORIGINATING blog, and the row is stamped with the originating blog's site_id. Since the read path filters WHERE site_id = %d, the operator of the blog that was actually attacked sees nothing in their widget, and the operator of the originating blog sees a user.promote escalation row with no indication that it targeted a different site. The recorder's own docblock acknowledges flush-time stamping but scopes the reasoning only to session_revoked; the escalation guard's cross-blog case was not considered.

    includes/class-event-store.php:236-281

    Recommendation

    Capture get_current_blog_id() into the row at enqueue() time rather than at bulk_insert() time. For the cross-blog escalation case, record the TARGET blog id in the event context so an alarm about blog N is discoverable from blog N. Consider writing high-severity events synchronously rather than buffering them.

  • low Stash key is minted through the third-party-filterable wp_generate_password(), which the same file rejects for its binding proof

    predictable_token

    The stash key is minted with wp_generate_password(16, false), which passes through WordPress's random_password filter. The same file rejects that function for the binding proof 300 lines later and says why: 'random_bytes(): unfilterable, unlike wp_generate_password(), whose random_password filter a third-party plugin could collapse.' The reasoning applies identically to the stash key and was not applied. The direct consequence is small — the transient key is not user-namespaced, so a collapsed filter makes all users collide on one slot, and a collision fails CLOSED because get()'s ownership check rejects a stash whose stored user_id is not the caller's. It is reported mainly because the identical pattern mints credentials where the consequence is not small: the 64-character sudo token and the 2FA challenge nonce both use wp_generate_password(). On stock WordPress this is not a real weakness — wp_generate_password() is backed by a CSPRNG and 16 alphanumerics is roughly 95 bits. It requires a third-party plugin to filter random_password.

    includes/class-request-stash.php:231

    Recommendation

    Mint the stash key from bin2hex( random_bytes( 16 ) ) with the same try/catch fallback used for the binding proof, and namespace the transient key by user ID so a degraded generator cannot produce cross-user slot collisions. Apply the same substitution to the sudo token and the 2FA challenge nonce.

WP Registry hashes the installable build and reports on that exact bytes-for-bytes copy. Embargoed findings are withheld until they are disclosed, so a clean verdict means nothing public is outstanding. WP Manifest does not audit code itself.