August 25, 2026
CVE-2026–52715: Unauthenticated SQL Injection in GEO my WP via Map Boundary Search
GEO my WP is a WordPress plugin (10,000+ active installs) that adds map-based location search — store locators, proximity search…
By Guidancewhite
5 min read
GEO my WP is a WordPress plugin (10,000+ active installs) that adds map-based location search — store locators, proximity search, BuddyPress member search, that kind of thing. When you pan or zoom the map, the plugin fetches only the results inside the currently visible area by sending the map's southwest and northeast corner coordinates back to the server as two request parameters: swlatlng and nelatlng.
Those two parameters ended up concatenated straight into a SQL query with zero validation and no $wpdb->prepare(). Any anonymous visitor could turn a normal-looking search request into arbitrary SQL.
I cloned the plugin's repo and diffed the security fix commit (380fe2e9, "Security: harden boundary lat/lng SQL handling") against the version right before it to trace exactly how the two request parameters reach raw SQL.
Tracing the call chain
1. Entry point: the raw query string
includes/class-gmw-form.php
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 );
}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 );
}The comment says sanitization happens later. It doesn't — not for swlatlng/nelatlng. This method runs on any front-end page that has a locator search form on it, no login, no nonce, no capability check.
2. parse_str() with no whitelist
includes/gmw-functions.php (pre-fix, as shipped in 4.5.4)
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 );
// for some case where address is not an array.
if ( isset( $output['address'] ) && ! is_array( $output['address'] ) ) {
$output['address'] = urldecode( $output['address'] );
}
}
if ( ! empty( $output['sortby'] ) ) {
$output['orderby'] = $output['sortby'];
}
return $output;
}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 );
// for some case where address is not an array.
if ( isset( $output['address'] ) && ! is_array( $output['address'] ) ) {
$output['address'] = urldecode( $output['address'] );
}
}
if ( ! empty( $output['sortby'] ) ) {
$output['orderby'] = $output['sortby'];
}
return $output;
}parse_str() turns every key in the query string into an array key. Append ?swlatlng=whatever&nelatlng=whatever to the URL and $output['swlatlng'] / $output['nelatlng'] contain exactly that string. There's no allow-list of expected parameters and no type check anywhere in this function.
3. Passed straight into the search query args
plugins/posts-locator/includes/class-gmw-wp-query.php
// search within map boundaries.
if ( ! empty( $args['gmw_swlatlng'] ) && ! empty( $args['gmw_nelatlng'] ) ) {
$where .= gmw_get_locations_within_boundaries_sql( $args['gmw_swlatlng'], $args['gmw_nelatlng'] );
// When address provided, and not filtering based on address fields, we will do proximity search.
} elseif ( empty( $address_filters ) && ( ! empty( $args['gmw_address'] ) || ( ! empty( $args['gmw_lat'] ) && ! empty( $args['gmw_lng'] ) ) ) ) {
...// search within map boundaries.
if ( ! empty( $args['gmw_swlatlng'] ) && ! empty( $args['gmw_nelatlng'] ) ) {
$where .= gmw_get_locations_within_boundaries_sql( $args['gmw_swlatlng'], $args['gmw_nelatlng'] );
// When address provided, and not filtering based on address fields, we will do proximity search.
} elseif ( empty( $address_filters ) && ( ! empty( $args['gmw_address'] ) || ( ! empty( $args['gmw_lat'] ) && ! empty( $args['gmw_lng'] ) ) ) ) {
...This class extends WP_Query to build the actual post listing shown on the page. If both boundary parameters are set, it hands them off to the function that builds the SQL fragment and appends it to the WHERE clause. The same pattern shows up in plugins/members-locator/includes/class-gmw-members-locator-form.php, so BuddyPress member search was affected too.
4. The sink: string concatenation into SQL
includes/gmw-functions.php (pre-fix)
/**
* SQL to get locations within boundaries.
*
* @param string $southwest southwest coords comma separated.
* @param string $northeast northeast coords comma separated.
* @since 4.0.
*/
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]} ) ) )";
}/**
* SQL to get locations within boundaries.
*
* @param string $southwest southwest coords comma separated.
* @param string $northeast northeast coords comma separated.
* @since 4.0.
*/
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]} ) ) )";
}Two things stack up here:
explode(',', $southwest)splits"37.5,-122.4"into an array — it says nothing about whether the pieces are actually numbers.$sw[0],$sw[1],$ne[0],$ne[1]are interpolated directly inside a double-quoted string via{$var}. No$wpdb->prepare(), noesc_sql(), not even a(float)cast.
So if southwest is 0,0) OR SLEEP(5)-- -, explode() gives $sw[0] = "0" and $sw[1] = "0) OR SLEEP(5)-- -", and that second fragment lands verbatim inside the BETWEEN clause. This isn't a value being swapped in wrong — it's the query's structure being handed to whoever controls the request.
What exploitation looks like
A normal map interaction produces something like:
GET /?page_id=4&swlatlng=40.70,-74.02&nelatlng=40.75,-73.97GET /?page_id=4&swlatlng=40.70,-74.02&nelatlng=40.75,-73.97An attacker just replaces the coordinate with a SQL fragment:
GET /?page_id=4&swlatlng=0,0)%20UNION%20SELECT%20user_login,user_pass,3,4,5%20FROM%20wp_users--%20-&nelatlng=1,1GET /?page_id=4&swlatlng=0,0)%20UNION%20SELECT%20user_login,user_pass,3,4,5%20FROM%20wp_users--%20-&nelatlng=1,1- No login, no CSRF token, one GET request.
- The
SELECTagainstgmw_locationscan be turned into aUNION SELECTpulling admin password hashes out ofwp_users(real exploitation needs matching column count/types to the original query, but nothing in this code path stops you from getting there). - Time-based blind injection (
SLEEP()) works too if you want to exfiltrate data without a direct output channel. - Unauthenticated + network vector + direct confidentiality/integrity impact is exactly why this scored 9.3.
The fix in 4.5.5.1
The vendor closed this with two layers: validate the input, then parameterize the query.
Validation function
/**
* Parse and validate a comma separated lat,lng pair.
*
* @param string $boundary comma separated lat,lng value.
* @return array|false
*/
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 );
}/**
* Parse and validate a comma separated lat,lng pair.
*
* @param string $boundary comma separated lat,lng value.
* @return array|false
*/
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 );
}It checks that the comma-split value has exactly two pieces, that both are numeric, that they're finite floats, and that they fall inside valid latitude (±90) / longitude (±180) ranges. Any failure returns false.
Rejected right at the entry point
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'] );
}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'] );
}This runs inside gmw_get_form_values(), right after the request is parsed. Anything that doesn't validate never makes it downstream to the query builder at all.
The SQL builder now uses $wpdb->prepare()
function gmw_get_locations_within_boundaries_sql( $southwest = '', $northeast = '' ) {
if ( empty( $southwest ) || empty( $northeast ) ) {
return;
}
$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]
);
}function gmw_get_locations_within_boundaries_sql( $southwest = '', $northeast = '' ) {
if ( empty( $southwest ) || empty( $northeast ) ) {
return;
}
$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]
);
}Every {$sw[0]}-style interpolation is now a %f placeholder, and the actual values are passed as arguments to $wpdb->prepare(), which escapes them at the driver level. Because only values that already passed gmw_parse_latlng_boundary() reach this function, validation and escaping both have to be defeated for this to be exploitable again — defense in depth, not just a single gate.
Attack chain
Takeaways from the code itself
explode(',', $user_input)followed by unchecked interpolation into SQL is a recurring pattern: the assumption "it looks like a coordinate, so it must be a number" is doing all the security work, and it's wrong.- In WordPress,
parse_str($_SERVER['QUERY_STRING'], $output)turns every key an attacker sends into an array entry. Without an explicit allow-list, every value in that array is untrusted input — regardless of what the parameter name implies it should contain. $wpdb->prepare()'s%f/%d/%splaceholders type-cast and escape automatically, so even "obviously numeric" values like coordinates, distances, or IDs should go through it. Pairing input validation (is_numeric, range checks) withprepare(), like this patch does, means one layer failing doesn't automatically mean the other does too.