Security Incident June 27, 2025. Targeted Supply Chain Attack

Adrian, Founder here.

On June 27, 2025 Groundhogg was the victim of a targeted attack with the intent of malware distribution to our paid customers.

Here’s what happened, what we know, what we did, and what we’re doing!

A detailed breakdown of the incident is below. Before reading in full we have the following security recommendations and precautions for customers.

These recommendations are assuming the worst. We give them out of an abundance of caution. We do not have evidence to support this information may have been compromised, but better safe than sorry.

  • Review your site administrators and revoke access to any non-essential accounts.
  • Update all administrator (and other privileged users) passwords.
  • If you’ve ever provided Groundhogg administrator access (typically [email protected]), revoke the user account or update the password.
  • Review your plugins and theme list, remove all non-essential or suspicious plugins and delete any inactive plugins.
  • Run a vulnerability scan with WordFence or other security tool.

Please implement those recommendations, then continue reading.

What happened?

Here’s a breakdown of what happened and when.

📅 Friday, June 27, 2025

🕛 12:12 PM

We received a support ticket from a customer explaining they could not activate our Advanced Features add-on because it triggered a fatal error when activating.

🕞 3:30 PM

I begin investigating the cause, attempting to generate errors logs and determine the source of the fatal error.

🕠 5:45 PM

After failing to generate any error logs to see the fatal error, I finally opened up the plugin file groundhogg-pro.php via SFTP to see if there was an issue, or perhaps a corrupted download.

I immediately noticed code that was not supposed to be there. The following was added to the top of the main plugin file.

if (!defined('WP_SIGN_LICENSE')) {
	require __DIR__ . '/includes/license.php';
}

die();

It includes a file which was added to the includes folder, license.php. It’s purpose is to provide a backdoor into the WordPress installation, stealthily obfuscated as license authorization.

I removed the plugin from the customer’s site, and looked for evidence of further infection to see if other files on their site had been compromised as a result. After finding none, I moved on.

🕕 5:55 PM

I checked our private GitHub repository of the Advanced Features add-on, and fortunately the infected code was not present there.

I then downloaded a copy of the add-on direct from our website and checked it’s contents. Sure enough, the malicious code was present.

The meant someone was able to upload the malicious file to Groundhogg.io, and replace our source .zip with the malicious one.

🕕 6:05 PM

Don’t panic, stay cool.

I immediately disabled file downloads from EDD so customers could not download anything while we investigated.

<?php

add_filter('edd_requested_file', function ($file) {
    // Prevent all file downloads
    wp_die('Downloads are temporarily disabled while we perform maintenance. Please try again later.');
});

🕕 6:10 PM

Started a scan with the WordFence vulnerability scanner on Groundhogg.io.

🕡 6:20 PM

The WordFence vulnerability scan revealed 2 files in plugins that were not supposed to be there.

One in the Yoast SEO plugin, and one in Really Simple Security plugin.

Both files offered back door access, and are likely the vectors used to swap out the infected .zip.

⚠️ I’m not suggesting that either of these plugins are responsible or contain vulnerabilities. In both cases it appears the files were modified or added from another attack vector.

I deleted the infected files, and replaced the plugins with clean versions from the repo.

I then deleted all unnecessary and inactive plugins, in the event the 3rd vector was present among them from an unknown zero day vulnerability.

🕡 6:40 PM

Now that backdoor access and other threats had been mitigated, we updated passwords, revoked application passwords, rotated API keys and other secrets.

🕖 6:52 PM

With the obvious evidence of a security threat, we emailed potentially impacted customers that there is an ongoing investigation and to take immediate precaution.

🕖 7:14 PM

I upgraded our WordFence license from Premium to Response, and submitted a incident report with them to investigate.

🕢 7:33 PM

I receive confirmation of from WordFence that my case was being investigated.

🕢 8:56 PM

While the plugin source files on GitHub we not compromised, they could have been 😱

We use the Easy Digital Downloads GitHub Download Updater add-on to generate the distributable plugin .zip files from our private repositories.

Currently it uses the Oauth Application flow to create the access token to read from repositories. The application flow uses the repo scope which provides blanket read and write access to not only repositories, but also personal and organizational settings.

My fear is that an attacker could steal the access token and compromise the source files. The token is static, there is no token expiration or refresh, so it is possible.

The Git Download Updater add-on only requires read access for repositories, which can be achieved through a Fine-Grained personal access token. So I deleted my GitHub application and generated a PAT, then filtered the access token in EDD with the snippet below. Works perfectly!

<?php

const GITHUB_PAT = 'my-personal-access-token';
add_filter( 'edd_get_option_gh_access_token', fn( $value ) => GITHUB_PAT, 10, 1 );

Now if the access token is stolen, the worst that can happen is an attacker can see my private repositories, but not change them.

🕤 9:36 PM

WordFence starts their security audit on Groundhogg.io.

🕚 11:11 PM

Another email to potentially impacted customers about the confirmed incident and recommend actions and precautions to take.

📅 Saturday, June 28, 2025

🕛 12:16 AM

I received comprehensive WordFence’s audit (~5 hours from submission to report), and began implementing their recommendations.

🕛 12:20 AM

Activated WordFence auto-scan at the highest sensitivity and left it to run overnight to monitor for threats in the event our initial sweep did not clear up all threats.

🕧 12:30 AM

Given the targeted nature of the attack, I also reviewed our other website properties for signs of infection, running high sensitivity WordFence scans.

Fortunately there were no signs of infection. I did take additional precautionary measures such has…

  • Ensure all plugins are updated
  • Delete all non-essential plugins and themes
  • Implement the WordFence recommendations
  • Update passwords, revoke application passwords, and rotate API keys.

🕘 9:00 AM

Downloading add-ons from Groundhogg.io is still disabled, as we need to review all distributable plugin .zip files for signs of infection before we can safely allow customers to download them.

We have lots of add-ons, so rather than check each one individually, I opted to simply regenerate the .zip files from their sources which I confirmed were clean from the git commit logs.

The Easy Digital Downloads Git Download Updater add-on does not provide a utility to regenerate all zips at once, so I wrote one which is activated with a handy WP-CLI command.

<?php

/**
 * Given a download and an optional file key, pull a fresh copy of a plugin distributable from GitHub
 *
 * @throws \EDD\GitDownloadUpdater\Exceptions\ResourceNotFoundException
 *
 * @param $file_key
 * @param $download_id
 *
 * @return array|false|string
 */
function edd_pull_from_git( $download_id, $file_key = null ) {

	$provider = edd_git_download_updater()->providerRegistry->getProvider( 'github' );

	$download_files = get_post_meta( $download_id, 'edd_download_files', true );

	if ( empty( $download_files ) ) {
		return false;
	}

	$zip = false;

	$fileProcesser = edd_git_download_updater()->process_file;

	foreach ( $download_files as $key => $file_info ) {

		if ( $file_key !== null && $file_key != $key ) {
			continue;
		}

		// completely reset the file processer
		unset( $fileProcesser->download_id );
		unset( $fileProcesser->version );
		unset( $fileProcesser->url );
		unset( $fileProcesser->repo_url );
		unset( $fileProcesser->file_key );
		unset( $fileProcesser->original_filename );
		unset( $fileProcesser->original_foldername );
		unset( $fileProcesser->tmp_dir );
		unset( $fileProcesser->file_name );
		unset( $fileProcesser->changelog );
		unset( $fileProcesser->condition );
		unset( $fileProcesser->edd_dir );
		unset( $fileProcesser->sl_version );
		unset( $fileProcesser->errors );
		unset( $fileProcesser->sub_dir );
		unset( $fileProcesser->folder_name );
		unset( $fileProcesser->source );

//		if ( ! empty( $file_info['git_file_asset'] ) ){
//			$fileProcesser->url = $file_info['git_file_asset'];
//		}

		if ( empty( $file_info['git_url'] ) ){
			continue;
		}

		$condition = empty( $file_info['condition'] ) ? 'all' : $file_info['condition'];

		$fileProcesser->condition = $condition;

		if ( defined( 'WP_CLI' ) && WP_CLI ) {
			WP_CLI::log( wp_json_encode( [
				$download_id,
				$file_info['git_version'],
				$file_info['git_url'],
				$key,
				$file_info['git_folder_name'],
				$file_info['name'],
				basename( dirname( $file_info['git_url'] ) ),
				basename($file_info['git_url'] ),
				$provider,
			] ) );
		}

		$zip = $fileProcesser->process(
			$download_id,
			$file_info['git_version'],
			$file_info['git_url'],
			$key,
			$file_info['git_folder_name'],
			$file_info['name'],
			basename( dirname( $file_info['git_url'] ) ),
			basename($file_info['git_url'] ),
			$provider,
		);
	}

	return $zip;
}

class EDD_Pull_Latest_Command {

	public function __invoke( $args, $assoc_args ) {

		$downloads = get_posts( [
			'post_type'   => 'download',
			'post_status' => 'publish',
			'numberposts' => - 1,
		] );

		foreach ( $downloads as $download ) {

			$result = edd_pull_from_git( $download->ID );

			if ( ! $result ) {
				WP_CLI::log( "Skipping {$download->post_title} (no repo URL)" );
			} else {
				WP_CLI::log( "Processed {$download->post_title}! File located at {$result['path']}" );
			}
		}

		WP_CLI::success( "All done." );
	}
}

if ( class_exists( 'WP_CLI' ) ){
	WP_CLI::add_command( 'edd pull-latest', 'EDD_Pull_Latest_Command' );
}


This successfully regenerated all our distributable .zip files for our add-ons from their GitHub source.

🕛 12:00 PM

Okay, but what if it happens again? We can’t risk distributing malware to customers. We need a way to verify that the .zip file a customer is downloading matches the source in GitHub.

When generating the .zip from our GitHub source, we can run a hash on it to create checksum, which we store somewhere.

When a file is downloaded, we check the requested file against our checksum, and if they don’t match for whatever reason, the download will fail. Here is an example implementation.

<?php

/**
 * Convert a URL to an absolute path
 *
 * @param $input
 *
 * @return false|mixed|string
 */
function resolve_wp_content_path( $input ) {
	// If it's already an absolute path, just return it
	if ( file_exists( $input ) || str_starts_with( $input, WP_CONTENT_DIR ) ) {
		return $input;
	}

	// If it's a URL within wp-content, convert to a path
	$wp_content_url = content_url();

	if ( str_starts_with( $input, $wp_content_url ) ) {
		$relative_path = str_replace( $wp_content_url, '', $input );
		return WP_CONTENT_DIR . $relative_path;
	}

	// Input is not a valid path or wp-content URL
	return false;
}

/**
 * Make sure that the path is used for the requested file download
 * 
 * @param $requested_file
 *
 * @return false|mixed|string
 */
function edd_requested_file_make_abspath( $requested_file ){
	return resolve_wp_content_path( $requested_file );
}

add_filter( 'edd_requested_file', 'edd_requested_file_make_abspath', 1 );

/**
 * When given a fresh zip after downloading from Github, generate a checksum which we'll use to compare downloads later...
 *
 * @param $zip  array path and url of the zip file in question
 * @param $repo string name of the repo of the file
 *
 * @return void
 */
function edd_generate_checksum( $zip, $repo ) {
	$checksum    = hash_file( 'sha256', $zip['path'] );
	$download_id = edd_git_download_updater()->process_file->download_id;
	update_post_meta( $download_id, $repo . '-checksum', $checksum );
}

add_action( 'edd_git_zip_saved', 'edd_generate_checksum', 10, 2 );


/**
 * Check the checksum before downloading the plugin files
 *
 * @param string $requested_file, should be an absolute path at this point
 * @param array $download_files
 * @param int $file_key
 *
 * @return string
 */
function edd_check_checksum_before_download( $requested_file, $download_files, $file_key ) {

	$requested_file = resolve_wp_content_path( $requested_file );

	// Plugin file name.
	$plugin_filename = basename( $requested_file );

	// If it's not a zip, bail.
	if ( ! str_ends_with( $plugin_filename, '.zip' ) ) {
		return $requested_file;
	}

	$file_info = $download_files[ $file_key ];
	$repo_name = basename( $file_info['git_url'] );
	$checksum  = hash_file( 'sha256', $requested_file );

	global $wpdb;

	$meta_key   = $repo_name . '_checksum';
	$meta_value = $checksum;

	$exists = $wpdb->get_var( $wpdb->prepare(
		"SELECT 1 FROM $wpdb->postmeta WHERE meta_key = %s AND meta_value = %s LIMIT 1",
		$meta_key,
		$meta_value
	) );

	if ( ! $exists ) {
		wp_die( "Checksum failed." );
	}

	return $requested_file;
}

add_filter( 'edd_requested_file', 'edd_check_checksum_before_download', 9, 3 );

This will ideally prevent customers from downloading potentially compromised copies of plugins in the future, should our site ever be hacked again.

🕒 3:00 PM

With no signs of additional infection, WordFence scans coming back clean, and distributables re-generated, downloads are re-enabled.

📅 June 30th, 2025

🕐 1:08 PM

Submitted an abuse report to the domain registrar of the malicious domain in use, groundhogg[dot]org.

🕔 5:00 PM

Published an incident report to the Groundhogg blog to be transparent with customers and the WordPress community at large.

📅 July 3rd, 2025

🕣 8:45 AM

Received confirmation from the domain registrar that the malicious domain in use in the attack, groundhogg[dot]org, has been suspended.

Who was impacted?

In the case of the compromised groundhogg-pro.zip, we confirmed 5 downloads of the compromised plugin file, and 2 cases of customers who tried to install the compromised files. Fortunately, in all cases there is no obvious evidence of additional infection. We’ve recommended to all customers they run scans, change passwords, and take extra security precautions.

In the case of the infection on Groundhogg.io, the full scope of the impact is not yet fully known. The attackers had backdoor access, which means they could access any information on the site, but it’s unclear if they did or not. If they did decide to scrape information, they would have been able to access…

  • Customer information such as names and email addresses
  • URLs of customer’s sites (via our licensing system)
  • Limited tracking telemetry such as plugin and WordPress versions

Information that would not be exposed or compromised

  • Information about our customers’ contacts, as we do not have centralized storage for contacts.
  • Billing information, that’s safely secured in Stripe and PayPal.

What we’re doing.

We’re continuing our investigation into how this happened, and continuing to harden our service to prevent possible future hacks.

We’re continuing to notify customers of the breach, and provide security recommendations as necessary.

We’ll update this post as more information becomes available.

In summary.

I’ll be honest, this is very embarrassing.

I want to apologize to our customers for any added stress and inconvenience cause by this incident.

This is a reminder that you can be doing everything right, and still get it wrong.

If you have questions regarding the breach, please email us at [email protected] and we’ll be happy to answer your questions.

Stay safe.

Want CRM tips in your inbox?

Subscribe for hot tips, takedowns, and other juicy CRM and marketing goodness. Published sporadically when we feel like it.