The Exploit
An authenticated WordPress user with Subscriber-level access can execute arbitrary PHP code on the server by sending a POST request to the wp-admin/admin-ajax.php endpoint with the wpext_handle_snippet_update action, provided an administrator has already created at least one code snippet.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.wordpress.local
Content-Type: application/x-www-form-urlencoded
Cookie: wordpress_logged_in=<subscriber_session>
action=wpext_handle_snippet_update&snippet_id=1&snippet_name=pwned&snippet_code=<?php system($_GET['cmd']); ?>&wpext_snippet_nonce=<any_value>
The server accepts the request without validating the nonce or checking user capabilities. The snippet_code parameter is passed directly to wp_unslash() and later executed, allowing the attacker to inject arbitrary PHP. On success, the attacker receives a JSON response confirming the snippet was updated, and the injected code executes whenever the snippet is rendered. The injected PHP remains persistent in the database until manually removed.
What the Patch Did
Before:
public function wpext_handle_snippet_update() {
ob_start();
$snippet_id = isset($_POST['snippet_id']) ? intval($_POST['snippet_id']) : 0;
$snippet_name = sanitize_text_field($_POST['snippet_name']);
$snippet_code = isset($_POST['snippet_code']) ? wp_unslash($_POST['snippet_code']) : "<?php echo 'Hello World'; ?>";
After:
public function wpext_handle_snippet_update() {
ob_start();
check_admin_referer('update_snippet', 'wpext_snippet_nonce');
// Check if user has admin capabilities
if (!current_user_can('manage_options')) {
wp_send_json_error(['message' => __('You do not have permission to perform this action.', WP_EXTENDED_TEXT_DOMAIN)]);
return;
}
$snippet_id = isset($_POST['snippet_id']) ? intval($_POST['snippet_id']) : 0;
$snippet_name = sanitize_text_field($_POST['snippet_name']);
$snippet_code = isset($_POST['snippet_code']) ? wp_unslash($_POST['snippet_code']) : "<?php echo 'Hello World'; ?>";
The patch added two critical security controls: check_admin_referer('update_snippet', 'wpext_snippet_nonce') to validate the CSRF nonce token, and current_user_can('manage_options') to enforce administrative capability checking before the function processes any POST data. Additionally, the nonce action name was synchronized from 'save_snippet' to 'update_snippet' in the form field generation, ensuring the nonce verification targets the correct action identifier.
Root Cause
CWE-862: Missing Authorization. The wpext_handle_snippet_update() AJAX callback receives user-controlled POST parameters (snippet_id, snippet_name, snippet_code) without first verifying that the caller holds the manage_options capability. While the code sanitizes text fields, it does not escape the snippet_code parameter; instead, it strips slashes with wp_unslash() and stores it verbatim in the database. When the snippet is later rendered via a shortcode or function call, the PHP code executes with the same privileges as the WordPress installation. The absence of check_admin_referer() also introduces a secondary CWE-352: Cross-Site Request Forgery vulnerability: an attacker can forge a request from a victim admin's session to modify snippets without explicit consent. The trust boundary violation occurs when an AJAX endpoint accessible to any authenticated user is allowed to modify code that will execute server-side.
Why It Works
The load-bearing line is current_user_can('manage_options'). Without it, any authenticated user can reach the code that writes $snippet_code to the database. Removing the nonce check alone would still allow a Subscriber to modify snippets via direct request (not CSRF), preserving the privilege-escalation vector. The engineer added check_admin_referer() for defense in depth: it stops CSRF attacks from compromised admin sessions and enforces that the request originated from a legitimate form interaction (nonce generation in the admin UI). The capability check is the primary gate—it is the only line that actually denies Subscriber-level users access to the dangerous operation. The nonce mismatch fix ('save_snippet' → 'update_snippet') ensures the nonce verification logic can succeed; a mismatch would cause the entire callback to fail even after the checks were added.
Hardening Checklist
-
Always call
current_user_can()on AJAX handlers before processing user input. Check against the minimum capability required for the operation (e.g.,manage_optionsfor code execution,edit_postsfor post modification). Place the check at the entry point of the handler, before$_POSTor$_GETis touched. -
Validate all CSRF tokens using
wp_verify_nonce()orcheck_admin_referer()on state-changing requests. Bind the nonce action name consistently between form generation (e.g.,wp_nonce_field('update_snippet')) and verification (e.g.,check_admin_referer('update_snippet')), and store the token in the request payload as a separate parameter. -
Audit all registered AJAX actions for missing authorization. Grep for
add_action('wp_ajax_', ...)and verify that each callback begins with capability checks. Distinguish betweenwp_ajax_(authenticated users) andwp_ajax_nopriv_(unauthenticated); do not usenoprivfor operations that modify state or expose sensitive data. -
Use
wp_unslash()only to reverse WordPress's automatic slash-addition on$_POST; never use it to sanitize user input for storage or execution. Pairwp_unslash()with appropriate sanitization (sanitize_text_field(),sanitize_textarea_field()) and output escaping (wp_kses_post(),esc_attr()) based on the context in which data will be used. -
Implement a code snippet execution whitelist or sandboxing layer. If user-supplied PHP code must be stored and executed, validate that it matches an expected pattern, execute it in a restricted context with minimal function availability, or use an alternative templating engine (e.g., Twig) instead of
eval()-like execution.
References
- https://nvd.nist.gov/vuln/detail/CVE-2024-11816