Abstract. WPS Hide Login (2,000,000+ active installations) ships an authenticated AJAX handler that validates a CSRF nonce but never checks the caller's capability. Any logged-in user, down to a Subscriber, can therefore write arbitrary WordPress site transients — a textbook missing-authorization flaw (CWE-862). This is a defensive case study: it covers the root cause, explains conceptually why a low-privilege account reaches the write, and gives site owners and developers prevention and detection guidance. The flaw is unpatched at the time of writing, so under coordinated disclosure the operational proof-of-concept is held back until a fix ships.
Keywords: WordPress, missing authorization, broken access control, CWE-862, capability check, nonce, site transient, least privilege.
1. Introduction
WPS Hide Login moves the default wp-login.php endpoint to a custom slug, and it does so on more than 2,000,000 sites. That reach is what makes the bug interesting. Many of those installs run membership portals, WooCommerce stores, or open registration — the kind of site where low-privilege accounts are plentiful and cheap to obtain. A flaw that only needs a logged-in account lands in exactly those environments. There is a certain irony, too, in finding broken access control inside a plugin whose selling point is login hardening.
Missing authorization — broken access control — is one of the most common and most damaging bug classes in the WordPress plugin ecosystem, and one of the least dramatic. No memory corruption, no SQL injection, no remote code execution. A privileged state change is simply left ungated, and an account that should never be able to trigger it can. WPS Hide Login gives us a clean, concrete instance of the pattern to study in code.
2. Affected component
| Plugin | WPS Hide Login — slug wps-hide-login |
| Tested version | 1.9.19 (current/latest at time of testing) |
| Active installations | 2,000,000+ |
| Vulnerability type | Missing Authorization / Broken Access Control |
| CWE | CWE-862 Missing Authorization |
| Required privilege | Any authenticated user — Subscriber (capability read) is enough |
| User interaction | None |
| CVSS 3.1 | 4.3 (Low) – 5.4 (Medium) — AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:L (a triager may set A:N → 4.3; impact is capped by the constrained stored value) |
| Status | Fresh (0-day); confirmed in code and reproduced on an isolated localhost lab on the current version; unpatched at time of writing |
3. Root cause
To persist the dismissal of its own admin notice, WPS Hide Login registers an authenticated AJAX action, wp_ajax_dismiss_admin_notice. The handler validates a CSRF nonce and nothing else. That matters because a nonce and an authorization check answer different questions. A nonce confirms that a request came from the logged-in user's own session — it defends against cross-site request forgery. It never establishes that the user is allowed to perform the action.
The action is wired straight to the handler, with no authorization wrapper in between:
add_action( 'wp_ajax_dismiss_admin_notice', array( $this, 'dismiss_admin_notice' ) );
// (no wp_ajax_nopriv_ variant — so this is authenticated-only, which is the ONLY gate)The handler verifies the nonce, then feeds an attacker-controlled option name directly into a site-transient write:
public function dismiss_admin_notice() {
check_ajax_referer( 'wps-hide-login-dismissible-notice' ); // CSRF nonce only — NOT an authz check
$option_name = sanitize_text_field( $_POST['option_name'] ); // fully caller-controlled, no allow-list
$dismissible_length = sanitize_text_field( $_POST['dismissible_length'] );
$transient = 0;
if ( 'forever' != $dismissible_length ) {
$dismissible_length = ( 0 == absint( $dismissible_length ) ) ? 1 : $dismissible_length;
$transient = absint( $dismissible_length ) * DAY_IN_SECONDS;
$dismissible_length = strtotime( absint( $dismissible_length ) . ' days' );
}
set_site_transient( $option_name, $dismissible_length, $transient ); // SINK: arbitrary site-transient write
wp_die();
}Two defects reinforce each other. First, there is no current_user_can() gate. Writing a site transient is an administrative operation, yet the handler never asks whether the caller holds a capability such as manage_options. The nonce check passes for every logged-in user, so it buys no privilege separation whatsoever. Second, option_name has no allow-list. Even a handler restricted to administrators ought to constrain which transient it touches; here the name is taken verbatim from the request, so the write is not confined to the plugin's own notice keys.
The plugin's own settings saves show the correct pattern by contrast. They route through WordPress core's options.php, which enforces both check_admin_referer('siteoptions') and the manage_options capability. The defect is precisely that second control going missing from the AJAX handler.
4. Reaching the write (conceptual)
A short trace, at teaching level rather than as a reproducible request sequence, shows why a low-privilege account lands on the vulnerable write.
Start with registration. The action exists only in its authenticated form (wp_ajax_…); there is no wp_ajax_nopriv_… counterpart. The effective gate is therefore nothing more than "be logged in" — and on membership sites, WooCommerce stores, and anything with open registration, Subscriber accounts are handed out freely. A Subscriber can already reach the wp-admin pages their role allows, such as their own profile.
Then there is the nonce. A notice-dismissal nonce ought to be emitted only on the admin screen that actually shows the notice. In this plugin, the function that enqueues the dismissal script is hooked unconditionally to the admin-script-loading event, and the one line that would have scoped it to the plugin's own settings screen is commented out. So the nonce that unlocks the handler is localized into the page for any user who can load any admin screen — Subscribers among them.
Both preconditions fall into place without effort, which leaves a Subscriber holding everything the handler demands: a valid session for the authenticated-only registration, and a valid nonce for the CSRF check. The handler then performs the privileged write on their behalf. For defenders the takeaway is conceptual rather than procedural. The only thing standing between an untrusted account and this state change is a check nobody wrote. There is no tooling, timing, or payload craft to it — the capability was simply left ungated.
5. Impact
The stored value is constrained: either an integer timestamp or the literal string forever. That cap is worth stating plainly, because it keeps the finding honest — this is not object injection, not SQL injection, not code execution. What a low-privilege user does gain is the ability to make a site-wide state change that belongs to administrators. In practice, an authenticated Subscriber can:
- Suppress pending update notifications. Overwrite the core
update_plugins,update_themes, orupdate_coresite transients with a scalar value and WordPress's update machinery finds no->responsepayload where it expects one. Administrators then see no pending plugin, theme, or core updates — security updates included — until the next scheduled refresh, and because the write can be repeated, that blind spot can be held open indefinitely. Concealing the existence of available fixes is a meaningful enabler for a follow-on attack. - Corrupt or evict arbitrary site transients owned by WordPress core or by other plugins — caches, rate-limit counters, feature flags, license and validity caches — producing functional breakage or cache stampedes.
Either way, a privilege boundary is crossed that should hold: a Subscriber cannot normally write site transients at all. The vector AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:L captures a network-reachable, low-complexity attack that needs low privilege and no interaction, with no confidentiality loss and limited integrity and availability impact. Whether a triager credits the availability impact is what moves the score between 4.3 (Low) and 5.4 (Medium).
6. The fix
The root fix is the check that is missing — gate the handler so only a sufficiently privileged user reaches the write:
public function dismiss_admin_notice() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( -1, 403 );
}
check_ajax_referer( 'wps-hide-login-dismissible-notice' );
// ... existing logic ...
}Add an allow-list for defence in depth. Constrain option_name to the plugin's own notice keys — a fixed prefix such as wps-hide-login-* is enough — so that even an authorized caller cannot reach unrelated core or third-party transients.
Until the vendor ships a release:
- Track the plugin's changelog and apply the fixed version the moment it is published.
- Keep the number of low-privilege accounts small and audit who holds them.
- Lean on the defence-in-depth hardening below, which shrinks the blast radius of any single ungated handler.
7. Detection and hardening for site owners
The only real precondition is a working login, so the practical questions are who can log in and how update state is being watched.
- Least privilege. Keep Subscriber and other low-privilege accounts to the minimum the site actually needs. Where registration is open or self-service — membership, e-commerce — treat each such account as a plausible actor for authenticated bugs like this one.
- Watch for inconsistent update state. Hiding updates is the highest-value abuse here. If the admin "Updates" screen says everything is current while an independent source — an external inventory, or a
wp-clicheck run out of band — reports a newer plugin, theme, or core release, treat the gap as a reason to investigate. A core update transient holding a bare scalar instead of an object structure is itself anomalous. - Monitor privileged state changes off the dashboard. The in-dashboard update badge should not be your only source of truth for whether components are current; a second, out-of-band one defeats this style of concealment outright.
- Reduce the feature's exposure. If a hardening layer or WAF lets you virtually patch — by requiring an administrator capability on this specific AJAX action, or by rejecting requests that carry an unexpected
option_name— that holds the line until the code fix lands. Derive any such rule only from the mechanics described here. - Keep everything updated. An update-suppression bug is self-ironic: the cure is updating. Make sure the plugin is upgraded as soon as a fixed version ships.
8. The broader lesson
The principle underneath this is old and routinely forgotten, so it bears repeating: a nonce is not an authorization check. The two answer different questions. A nonce answers "did this request genuinely come from this user's session?" and defends against CSRF. Authorization answers "is this user permitted to do this?" and is a decision about capability. Verify the first, skip the second, and the handler is open to every account that can obtain a valid nonce — on WordPress, that is every logged-in user.
Treat it as a rule for every state-changing endpoint, whether an AJAX action, a REST route, or an admin-post handler: an explicit capability check appropriate to the operation (current_user_can( … )), standing on its own and not leaning on any nonce. Then, wherever a sink consumes a caller-supplied identifier — an option name, a transient key, a file path, a post ID — constrain it to an allow-list instead of trusting whatever the request carries. Authentication, CSRF protection, and authorization are three distinct controls, and shipping one or two of them does not ship the rest.
9. Responsible disclosure
This research was conducted and is being published under coordinated disclosure. The flaw is unpatched. The vendor has been notified privately, and the full operational proof-of-concept — the exact request sequence and payload values that reproduce the write on a live site — is deliberately held back from this public article until a fix is available. What remains here is the root-cause code, the suggested fix, and a conceptual account of the mechanism, enough for developers and site owners to understand, prevent, and detect the bug class without being handed a ready-to-run weapon.
All verification ran on the researcher's own isolated, localhost-bound lab against the public GPL plugin. No third-party or production systems were touched. The test plugin and the test account were removed afterward, and every transient altered during testing was restored.
References
- CWE-862: Missing Authorization — https://cwe.mitre.org/data/definitions/862.html
- WordPress Plugin Handbook — Checking User Capabilities — https://developer.wordpress.org/plugins/security/checking-user-capabilities/
- WordPress Plugin Handbook — Nonces — https://developer.wordpress.org/plugins/security/nonces/
- WordPress Developer Reference —
current_user_can()— https://developer.wordpress.org/reference/functions/current_user_can/ - WordPress Developer Reference —
set_site_transient()— https://developer.wordpress.org/reference/functions/set_site_transient/ - OWASP — Broken Access Control — https://owasp.org/Top10/A01_2021-Broken_Access_Control/
- FIRST — CVSS v3.1 Calculator — https://www.first.org/cvss/calculator/3.1
Published for defensive and educational purposes. Noroxi LLC conducts authorized security research only; operational exploit detail is withheld until a fix is available.
Exploited this week · every Monday
New KEV entries, fresh PoCs, WordPress triage and what to do this week. No account needed.