Published : June 29, 2026

CVE-2026-57667: Groundhogg — CRM, Newsletters, and Marketing Automation <= 4.5 Authenticated (Sales representative+) SQL Injection PoC, Patch Analysis & Rule

Plugin groundhogg
Severity Medium (CVSS 6.5)
CWE 89
Vulnerable Version 4.5
Patched Version 4.5.1
Disclosed June 25, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-57667:

The Groundhogg CRM, Newsletters, and Marketing Automation plugin for WordPress (version 4.5 and earlier) contains an authenticated SQL injection vulnerability. Attackers with sales representative-level access can inject arbitrary SQL via unsanitized sub-query strings passed to IN and NOT IN operators. The vulnerability carries a CVSS score of 6.5.

Root Cause: The vulnerability stems from insufficient escaping and preparation in several query-building functions within the plugin’s database abstraction layer. In `groundhogg/db/db.php` (lines 1107 and 1124), the table name was directly interpolated into a SQL string using curly brace syntax `{$relationships->table_name}` before being passed to `$wpdb->prepare()`. This allowed an attacker-controlled table name parameter to break out of the prepare context. Additionally, in `groundhogg/db/query/where.php` (lines 438 and 468), the `in()` and `notIn()` methods accepted raw SQL strings (identified by starting with ‘SELECT’) and inserted them directly into the query without parameterization. The patch replaces table name interpolation with the `%i` placeholder and replaces raw SQL string handling with `_doing_it_wrong()` deprecation notices, forcing the use of the `Query` class instead.

Exploitation: An authenticated attacker with sales representative access can craft requests to any endpoint that triggers a query using the vulnerable `in()` or `notIn()` methods with a raw ‘SELECT’ string. For example, the `contact-query.php` filters (lines 1925-2386) pass user-supplied arrays through `wp_parse_id_list()` and then directly to `$where->in()` or `$where->notIn()`. By providing a specially crafted string value that begins with ‘SELECT’ and contains SQL injection payloads (e.g., `SELECT password FROM wp_users WHERE 1=1 UNION SELECT …`), an attacker can append arbitrary SQL clauses. The most direct attack path is via the REST API endpoints or AJAX handlers that invoke contact queries with filter parameters like `optin_status`, `owner`, or `tags`.

Patch Analysis: The patch modifies three key areas. First, in `db.php` lines 1107 and 1124, the table name placeholder changed from direct string interpolation to `%i`, which `wpdb::prepare()` treats as an identifier placeholder and escapes properly. Second, in `where.php` lines 438-444 and 472-478, the vulnerable code path that accepted raw SQL strings for `in()` and `notIn()` is blocked by a `_doing_it_wrong()` call, forcing developers to use the `Query` class instead. Third, in `contact-query.php`, calls to `get_db()->get_sql()` that returned raw SQL strings are replaced with `Table_Query` objects that are properly handled by the updated `in()`/`notIn()` methods. The placeholder format detection in `getColumnFormat()` (line 221 of where.php) now uses `guessPlaceholderFormat()` for non-column values, improving type safety.

Impact: Successful exploitation allows an authenticated attacker with sales representative-level access to extract arbitrary data from the WordPress database, including password hashes, user emails, session tokens, and other sensitive information. The attacker can also potentially modify or delete data through stacked queries (if the database server supports them), leading to data integrity loss. The vulnerability does not grant code execution directly but provides the data necessary for further attacks such as credential stuffing or privilege escalation.

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/groundhogg/db/db.php
+++ b/groundhogg/db/db.php
@@ -1107,7 +1107,7 @@
 					$where[]       = [
 						'col'     => $this->get_primary_key(),
 						'compare' => 'IN',
-						'val'     => $wpdb->prepare( "SELECT primary_object_id FROM {$relationships->table_name} WHERE secondary_object_id = %d AND secondary_object_type = '%s' AND primary_object_type = '%s'", $val['ID'], $val['type'], $this->get_object_type() )
+						'val'     => $wpdb->prepare( "SELECT primary_object_id FROM %i WHERE secondary_object_id = %d AND secondary_object_type = %s AND primary_object_type = %s", $relationships->table_name, $val['ID'], $val['type'], $this->get_object_type() )
 					];

 					break;
@@ -1124,7 +1124,7 @@
 					$where[]       = [
 						'col'     => $this->get_primary_key(),
 						'compare' => 'IN',
-						'val'     => $wpdb->prepare( "SELECT secondary_object_id FROM {$relationships->table_name} WHERE primary_object_id = %d AND primary_object_type = '%s' AND secondary_object_type = '%s'", $val['ID'], $val['type'], $this->get_object_type() )
+						'val'     => $wpdb->prepare( "SELECT secondary_object_id FROM %i WHERE primary_object_id = %d AND primary_object_type = %s AND secondary_object_type = %s", $relationships->table_name, $val['ID'], $val['type'], $this->get_object_type() )
 					];
 					break;
 				case 'count':
@@ -1177,7 +1177,7 @@

 						// Select Clause
 						if ( is_string( $val ) && strpos( $val, 'SELECT' ) !== false ) {
-							$where[] = [ 'col' => $key, 'val' => $val, 'compare' => 'IN' ];
+							_doing_it_wrong( __METHOD__, 'Use the GroundhoggQuery class for sub queries', '4.5.1' );
 							break;
 						}

@@ -1561,7 +1561,7 @@
 					$this->parse_filters( $val, $exclude_query->where() );

 					if ( ! $exclude_query->where->isEmpty() ) {
-						$query->where()->notIn( $this->get_primary_key(), "$exclude_query" );
+						$query->where()->notIn( $this->get_primary_key(), $exclude_query );
 					}

 					break;
@@ -1609,7 +1609,7 @@

 					// Select Clause
 					if ( is_string( $val ) && strpos( $val, 'SELECT' ) !== false ) {
-						$query->whereIn( $key, $val );
+						_doing_it_wrong( __METHOD__, 'Use the GroundhoggQuery class for sub queries', '4.5.1' );
 						break;
 					}

--- a/groundhogg/db/query/table-query.php
+++ b/groundhogg/db/query/table-query.php
@@ -100,7 +100,7 @@
 					$exclude_query->parseFilters( $value );

 					if ( ! $exclude_query->where->isEmpty() ) {
-						$this->where()->notIn( $this->db_table->get_primary_key(), "$exclude_query" );
+						$this->where()->notIn( $this->db_table->get_primary_key(), $exclude_query );
 					}

 					break;
--- a/groundhogg/db/query/where.php
+++ b/groundhogg/db/query/where.php
@@ -218,7 +218,7 @@
 				$column = substr( $column, strpos( $column, '.' ) + 1 );
 			}

-			return get_array_var( $column_formats, $column, is_numeric( $value ) ? '%d' : '%s' );
+			return get_array_var( $column_formats, $column, self::guessPlaceholderFormat( $value ) );
 		}

 		return self::guessPlaceholderFormat( $value );
@@ -438,24 +438,28 @@
 	 */
 	public function in( $column, $values ) {

+		if ( is_string( $values ) && str_starts_with( $values, 'SELECT' ) ) {
+			_doing_it_wrong( __METHOD__, 'Use the GroundhoggQuery class for sub queries', '4.5' );
+			return $this;
+		}
+
 		$column = $this->sanitize_column( $column );

-		if ( ( is_string( $values ) && str_starts_with( $values, 'SELECT' ) ) || is_a( $values, Query::class ) ) {
+		if ( is_a( $values, Query::class ) ) {
 			$this->addCondition( "$column IN ( $values )" );

 			return $this;
 		}

 		$values = array_values( ensure_array( $values ) );
-		$values = map_deep( $values, 'sanitize_text_field' );

 		if ( count( $values ) === 1 ) {
 			return $this->equals( $column, $values[0] );
 		}

-		$values = maybe_implode_in_quotes( $values );
+		$placeholders = implode( ',', array_map( fn( $value ) => $this->getColumnFormat( $column, $value ), $values ) );

-		return $this->addCondition( "$column IN ( $values )" );
+		return $this->addCondition( $this->prepare( "$column IN ( $placeholders )", $values ) );
 	}

 	/**
@@ -468,24 +472,28 @@
 	 */
 	public function notIn( $column, $values ) {

+		if ( is_string( $values ) && str_starts_with( $values, 'SELECT' ) ) {
+			_doing_it_wrong( __METHOD__, 'Use the GroundhoggQuery class for sub queries', '4.5' );
+			return $this;
+		}
+
 		$column = $this->sanitize_column( $column );

-		if ( is_string( $values ) && str_starts_with( $values, 'SELECT' ) ) {
+		if ( is_a( $values, Query::class ) ) {
 			$this->addCondition( "$column NOT IN ( $values )" );

 			return $this;
 		}

 		$values = array_values( ensure_array( $values ) );
-		$values = map_deep( $values, 'sanitize_text_field' );

 		if ( count( $values ) === 1 ) {
 			return $this->notEquals( $column, $values[0] );
 		}

-		$values = maybe_implode_in_quotes( $values );
+		$placeholders = implode( ',', array_map( fn( $value ) => $this->getColumnFormat( $column, $value ), $values ) );

-		return $this->addCondition( "$column NOT IN ( $values )" );
+		return $this->addCondition( $this->prepare( "$column NOT IN ( $placeholders )", $values ) );
 	}

 	/**
--- a/groundhogg/groundhogg.php
+++ b/groundhogg/groundhogg.php
@@ -3,7 +3,7 @@
  * Plugin Name: Groundhogg
  * Plugin URI: https://www.groundhogg.io/?utm_source=wp-plugins&utm_campaign=plugin-uri&utm_medium=wp-dash
  * Description: CRM and marketing automation for WordPress
- * Version: 4.5
+ * Version: 4.5.1
  * Author: Groundhogg Inc.
  * Author URI: https://www.groundhogg.io/?utm_source=wp-plugins&utm_campaign=author-uri&utm_medium=wp-dash
  * Text Domain: groundhogg
@@ -24,8 +24,8 @@

 if ( ! defined( 'ABSPATH' ) ) exit; // Exit if accessed directly

-define( 'GROUNDHOGG_VERSION', '4.5' );
-define( 'GROUNDHOGG_PREVIOUS_STABLE_VERSION', '4.4.2' );
+define( 'GROUNDHOGG_VERSION', '4.5.1' );
+define( 'GROUNDHOGG_PREVIOUS_STABLE_VERSION', '4.5' );

 define( 'GROUNDHOGG__FILE__', __FILE__ );
 define( 'GROUNDHOGG_PLUGIN_BASE', plugin_basename( GROUNDHOGG__FILE__ ) );
--- a/groundhogg/includes/contact-query.php
+++ b/groundhogg/includes/contact-query.php
@@ -608,6 +608,7 @@
 		] );

 		$countries = maybe_explode( $filter['country'] );
+		$countries = array_intersect( $countries, array_keys( utils()->location->get_countries_list() ) );

 		if ( empty( $countries ) ) {
 			return;
@@ -687,7 +688,9 @@
 		$col     = "COALESCE($alias.meta_value,'$default')";
 		$query->add_safe_column( $col );

-		$where->in( $col, $filter['locales'] );
+		$locales = array_intersect( maybe_explode( $filter['locales'] ), array_keys( wp_get_available_translations() ) );
+
+		$where->in( $col, $locales );
 	}

 	/**
@@ -764,7 +767,7 @@
 			$subQuery->set_query_var( 'select', 'ID' );
 			$subQuery->setSelect( 'ID' );

-			$where->notIn( 'ID', $subQuery->get_sql() );
+			$where->notIn( 'ID', $subQuery );
 		} else {
 			self::set_where_conditions( $search['query'], $where );
 		}
@@ -1925,21 +1928,13 @@
 				case 'optin_status': // Include by opt-in status
 					if ( ! empty( $value ) ) {
 						$optin_stati = wp_parse_id_list( $value );
-						if ( count( $optin_stati ) === 1 ) {
-							$where->equals( 'optin_status', $optin_stati[0] );
-						} else {
-							$where->in( 'optin_status', $optin_stati );
-						}
+						$where->in( 'optin_status', $optin_stati );
 					}
 					break;
 				case 'optin_status_exclude': // Exclude by opt-in status
 					if ( ! empty( $value ) ) {
 						$optin_stati = wp_parse_id_list( $value );
-						if ( count( $optin_stati ) === 1 ) {
-							$where->notEquals( 'optin_status', $optin_stati[0] );
-						} else {
-							$where->notIn( 'optin_status', $optin_stati );
-						}
+						$where->notIn( 'optin_status', $optin_stati );
 					}
 					break;
 				case 'before': // Date before
@@ -1959,11 +1954,7 @@
 				case 'owner': // filter by owner
 					if ( ! empty( $value ) ) {
 						$owner_ids = wp_parse_id_list( $value );
-						if ( count( $owner_ids ) === 1 ) {
-							$where->equals( 'owner_id', $owner_ids[0] );
-						} else {
-							$where->in( 'owner_id', $owner_ids );
-						}
+						$where->in( 'owner_id', $owner_ids );
 					}
 					break;
 				case 'email': // Email search
@@ -2206,7 +2197,7 @@
 						$exclude_query->maybe_setup_query();

 						if ( ! $exclude_query->where->isEmpty() ) {
-							$where->notIn( 'ID', "$exclude_query" );
+							$where->notIn( 'ID', $exclude_query );
 						}
 					}

@@ -2386,23 +2377,21 @@
 		$tags = wp_parse_id_list( $tags );

 		if ( count( $tags ) === 1 ) {
-			$where->notIn( 'ID', get_db( 'tag_relationships' )->get_sql( [
-				'select'  => 'contact_id',
-				'tag_id'  => $tags[0],
-				'orderby' => false,
-				'order'   => false,
-			] ) );
+
+			$tagQuery = new Table_Query( 'tag_relationships' );
+			$tagQuery->setSelect( 'contact_id' )->where->equals( 'tag_id', $tags[0] );
+
+			$where->notIn( 'ID', $tagQuery );

 			return;
 		}

 		if ( $all ) {
-			$where->notIn( 'ID', get_db( 'tag_relationships' )->get_sql( [
-				'select'  => 'contact_id',
-				'tag_id'  => $tags,
-				'orderby' => false,
-				'order'   => false,
-			] ) );
+
+			$tagQuery = new Table_Query( 'tag_relationships' );
+			$tagQuery->setSelect( 'contact_id' )->where->in( 'tag_id', $tags );
+
+			$where->notIn( 'ID', $tagQuery );

 			return;
 		}
@@ -2410,12 +2399,11 @@
 		$subWhere = $where->subWhere();

 		foreach ( $tags as $tag ) {
-			$subWhere->notIn( 'ID', get_db( 'tag_relationships' )->get_sql( [
-				'select'  => 'contact_id',
-				'tag_id'  => $tag,
-				'orderby' => false,
-				'order'   => false,
-			] ) );
+
+			$tagQuery = new Table_Query( 'tag_relationships' );
+			$tagQuery->setSelect( 'contact_id' )->where->in( 'tag_id', $tag );
+
+			$subWhere->notIn( 'ID', $tagQuery );
 		}
 	}

ModSecurity Protection Against This CVE

Here you will find our ModSecurity compatible rule to protect against this particular CVE.

ModSecurity
SecRule REQUEST_URI "@streq /wp-admin/admin-ajax.php" "id:20261994,phase:2,deny,status:403,chain,msg:'CVE-2026-57667 SQL Injection via Groundhogg contact query filters',severity:'CRITICAL',tag:'CVE-2026-57667'"
SecRule ARGS_POST:action "@streq groundhogg_contact_search" "chain"
SecRule ARGS_POST:filter "@rx (?i)(?:%27|%28|SELECT|UNION|INSERT|UPDATE|DELETE|DROP|ORs+1=1)" "t:urlDecode,t:removeNulls"

Frequently Asked Questions

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Blac&kMcDonaldCovenant House TorontoAlzheimer Society CanadaUniversity of TorontoHarvard Medical School