Abstract. Version 2.85 of the WordPress plugin 301 Redirects registers an AJAX action that verifies a nonce but never checks a capability. Any logged-in user — a Subscriber will do — can therefore write to a site-global option (CWE-862). We reported it to the maintainer under coordinated disclosure and are holding the working proof of concept back until a fix ships. This write-up covers the root cause, the secure-coding lesson behind it, and the detection and hardening steps that follow from the mechanics.
Keywords: WordPress security, missing authorization, broken access control, CWE-862, AJAX nonce, capability check, privilege boundary.
1. Introduction
301 Redirects (slug eps-301-redirects) manages URL redirects from the admin area and runs on more than 300,000 sites. A large share of those sites accept public registration, which means they issue Subscriber-level accounts to strangers. On any such install, a flaw that lets a Subscriber step across an authorization boundary deserves a close look — even one whose direct impact stays small.
The flaw here is missing authorization, the server-side half of broken access control. The code confirms who sent a request, through a nonce, but never confirms whether that user is allowed to run the action, through a capability check. It is one of the most common mistakes in the WordPress plugin ecosystem, and this instance is about as clean a teaching example as you will find of why the two checks solve different problems.
2. Affected component
| Plugin | 301 Redirects — slug eps-301-redirects |
| Tested version | 2.85 (current at time of testing) |
| Active installations | 300,000+ |
| Vulnerability type | Missing Authorization / Broken Access Control |
| CWE | CWE-862 Missing Authorization |
| Required privilege | Any authenticated user — Subscriber |
| CVSS 3.1 | ~4.3 (Low) AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N |
| Status | Fresh; code-verified and lab-reproduced on the current version |
3. Technical analysis
The plugin registers an AJAX action, eps_dismiss_pointer, available to authenticated users. The handler checks a nonce, then writes to the site-global eps_pointers option. At no point does it ask whether the current user may change that option.
Root-cause code — eps-301-redirects.php:
add_action('wp_ajax_eps_dismiss_pointer', array($this, 'dismiss_pointer_ajax')); // authenticated; any role
function dismiss_pointer_ajax() {
check_ajax_referer('eps_dismiss_pointer'); // nonce only — NO current_user_can()
if (!isset($_POST['pointer_name'])) { wp_send_json_error(); }
$pointers = get_option('eps_pointers');
$pointer = trim(sanitize_text_field(wp_unslash($_POST['pointer_name'])));
if (empty($pointers) || empty($pointers[$pointer])) { wp_send_json_error(); }
unset($pointers[$pointer]);
update_option('eps_pointers', $pointers); // SINK: site-global option write
wp_send_json_success();
}check_ajax_referer() validates a WordPress nonce, and a nonce is an anti-CSRF token. It establishes that the request came from a page the user legitimately loaded and that the page is recent. It says nothing about the user's privileges. Privileges are the job of current_user_can( $capability ), and this handler never calls it. The only gate standing in front of a site-wide update_option() is therefore "is the caller logged in, and do they hold a valid nonce?" A Subscriber clears both.
Registering the action under wp_ajax_ alone, not wp_ajax_nopriv_, does correctly keep it away from anonymous visitors. But authenticated is not authorized. A Subscriber is authenticated, and on this plugin that is all it takes to reach the sink.
4. How the flaw is reached (conceptual)
A Subscriber can trigger the action because of a second oversight further upstream: the nonce it needs is handed out far too widely.
The plugin enqueues its admin assets — the localized nonce among them — in a plugin_resources() method hooked on init and gated by is_admin() and nothing more. is_admin() only reports whether a request targets the admin area. It is not a capability check, and it returns true for any logged-in user who loads an admin-area page. Subscribers are allowed to open some of those pages — their own profile screen, for one — and the nonce sits in the output of pages like that.
At a teaching level, the chain is three links:
- A handler that writes to a global option is guarded by a nonce and nothing else.
- That nonce ships on admin-area pages Subscribers may view, because issuance is gated on
is_admin()rather than on a capability. - A Subscriber ends up holding a valid nonce for an action meant only for administrators, and the handler never rechecks privileges when it runs.
The outcome is a privilege-boundary crossing: a user whose only capability is read changes a site-wide setting they have no authorization to touch. We deliberately leave out the request sequence and the payloads. For defenders the lesson is the shape of the mistake, not a recipe anyone can run.
5. Impact assessment
In practice the impact is low. The one option this handler can reach is eps_pointers, a store of admin-onboarding UI hints ("pointers"). A Subscriber can delete entries from it and so hide those hints from administrators. There is no data disclosure, no availability loss, and no way to change redirects or any other security-relevant setting down this path.
The CVSS vector reflects that: AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N (~4.3, Low) — network-reachable, low complexity, low privilege, no user interaction, unchanged scope, no confidentiality or availability impact, and low integrity impact only. What makes the finding worth publishing is not its blast radius but the pattern it exposes on a 300,000-install plugin. Point that same pattern at a more sensitive option and the result stops being low.
6. Remediation
Add an authorization check to the handler, right next to the nonce check that is already there. The plugin defines a capability filter it uses elsewhere — in add_widget(), for example — so reusing it keeps the behavior consistent:
function dismiss_pointer_ajax() {
check_ajax_referer('eps_dismiss_pointer');
if (!current_user_can(apply_filters('eps_301_redirects_capability','manage_options'))) {
wp_send_json_error(); // authorization: only privileged users
}
// ... existing logic ...
}For site owners:
- Update to a fixed release the moment the maintainer ships one. As of this writing, 2.85 is still the current release and no fix exists yet.
- Treat the issue as low-risk but real in the meantime, and apply the hardening below — above all on sites with open registration.
7. Detection and hardening for site owners
Everything below follows directly from the mechanics above.
- Keep low-privilege accounts to a minimum. If the site does not need public registration, turn it off (Settings → General → Membership). The flaw needs an authenticated Subscriber, so fewer of those accounts means less exposure.
- Apply least privilege. Audit the accounts you have and give each user the lowest role that does the job. Stale or unexpected Subscriber accounts are exactly the attack surface that matters here.
- Watch
admin-ajax.phpfor the odd caller. Requests carrying this plugin's dismiss-pointer action from accounts that are not administrators are worth a look in your access logs. More broadly, authenticated non-admins invoking admin-oriented AJAX actions make a solid detection heuristic. - Virtual patching / WAF. A web application firewall or virtual-patch rule can hold the missing authorization boundary at the edge — for instance, by limiting this AJAX action to privileged sessions — until the plugin is updated. That is a mitigation concept, not a recommendation of any one product.
- Stay current. Keep plugins updated and follow the maintainer's release notes so the fixed version lands quickly.
8. The broader lesson
A nonce is not an authorization control. That is the one idea to carry away, and it holds far beyond this plugin:
check_ajax_referer()andwp_verify_nonce()answer an anti-CSRF question: did this request come from a legitimate, recent page load by this user?current_user_can()answers the authorization question: is this user allowed to do this?
Every state-changing handler needs both answers. A second principle rides along with it: do not leak capability-bearing tokens to users who lack the capability. Gating nonce issuance on is_admin(), a context check, instead of on a capability widens the audience that can act. When you build a privileged action, gate the token and the handler on the same capability, and deny by default.
9. Responsible disclosure
The research followed coordinated (responsible) disclosure. We notified the maintainer of 301 Redirects privately, and the full working proof of concept stays out of this public article until a fix is available. We plan to publish the complete technical detail only after a fixed release ships, or after the standard 90-day window, timed in coordination with the maintainer, and we will request a CVE.
All testing ran in an isolated, localhost-bound lab against the public GPL plugin. No third-party or production systems were touched. The test plugin and test accounts were removed afterward. Nothing here is meant to enable attacks; it is published so developers and site owners can recognize this bug class, prevent it, and detect it.
References
- CWE-862: Missing Authorization — https://cwe.mitre.org/data/definitions/862.html
- OWASP: Broken Access Control — https://owasp.org/Top10/A01_2021-Broken_Access_Control/
- WordPress Plugin Developer Handbook — Checking User Capabilities — https://developer.wordpress.org/plugins/security/checking-user-capabilities/
- WordPress Plugin Developer Handbook — Nonces — https://developer.wordpress.org/plugins/security/nonces/
current_user_can()reference — https://developer.wordpress.org/reference/functions/current_user_can/- FIRST.org 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.