Wednesday, September 9, 2009

Hack Cleanup

A while back one of my shared servers got hacked. The hacker added php error pages via .htaccess files. The code in the PHP was encoded so that it was difficult to read exactly what was happening, but basically they were trying to advertise on my dime.

This site happens to run on Joomla!, Flyspray and Magento. Each of them have many subfolders and I have no shell access. Recursively searching through the filesystem couldn't simply be performed by good old CLI tools like grep, find, sed, etc...

I found this php script for recursively going through the filesystem and modified it to look through all the .htaccess files and suspicious files matching the pattern of the hack (numbered php files like 23546.php) as well as an empty WP file. I also added the ability to set the depth of recursion and directories to exclude.

I also added some sorting functions, specifically one by modification time (recent first) so that at a glance, I can determine any files that have been compromised.

Options:

Mostly I expect you'll want to edit the code for your modifications, however I have exposed 2 options to the query string:
depth=n

Set the recursion depth to n, where n is an integer (-1 = bottomless, 0 = none [default], 1 = immediate subfolders only, etc...)

modify=1

The script runs in read only mode unless you add modify=1. If you only want a report of what it expects it will delete, leave modify=1 off the querystring.

Example: /hack-cleanup.php?depth=-1&modify=1

Inspect all subfolders until there are no more subfolders and delete anything that matches the defined pattern. Look at the code, specifically the unlink() calls.
Here's the code, hope it helps:


header('Content-Type: text/plain');

$exclude_dirs = array();
$root_dir = "";
$ro = TRUE;

function process_dir($dir, $depth = 0) {
if (! is_dir($dir)) return FALSE;
global $exclude_dirs, $root_dir, $ro;
if ($dir[strlen($dir)-1] != '/') $dir .= '/';

if (!$root_dir) {
$root_dir = $dir;
if ($ro)
echo "##########\n#" .
"\n# Operating in READ ONLY mode. Nothing will be deleted." .
"\n# Add modify=1 to query string to modify." .
"\n#\n##########\n";
else
echo "##########\n#" .
"\n# Modifications will be made!" .
"\n# Files MAY BE DELETED!" .
"\n#\n##########\n";
echo "\nRoot directory: $dir, depth: $depth";
echo "\n-- Excluded directories: ";
print_r($exclude_dirs);
}

$rel_dir = substr($dir, strlen($root_dir));

$dirs = explode('/', $rel_dir);

if (!strlen($rel_dir)) $current_depth = 0;
else if ( strpos($rel_dir,'/') < 0 ) $current_depth = 1;
else $current_depth = count($dirs);

$current_dir = end($dirs);

$list = array();

for ($handle = opendir($dir); (FALSE !== ($file = readdir($handle)));) {
$path = $dir . $file;
if (($file != '.' && $file != '..') && (file_exists($path))) {
if (is_dir($path) &&
($depth < 0 || $depth && $current_depth <= $depth)) {
if (in_array($path, $exclude_dirs) || in_array($file, $exclude_dirs)) {
echo "\n-- Skipping Excluded Directory: $path";
} else {
// echo "\n++ Recursing into: $path ++";
$list = array_merge($list, process_dir($path, $depth));
}
} else {
$entry = array('filename' => $file, 'dirpath' => $dir, 'path' => $path);

//---------------------------------------------------------//
// - SECTION 1 - //
// Actions to be performed on ALL ITEMS //
//----------------- Begin Editable ------------------//

$entry['modtime'] = filemtime($path);
$top_dir = $dirs[1];

//----------------- End Editable ------------------//
do if (!is_dir($path)) {
//---------------------------------------------------------//
// - SECTION 2 - //
// Actions to be performed on FILES ONLY //
//----------------- Begin Editable ------------------//
$entry['size'] = filesize($path);
if (strstr(pathinfo($path,PATHINFO_BASENAME),'log')) {
if (!$entry['handle'] = fopen($path,r)) $entry['handle'] = "FAIL";
}
if ($file == 'WP') {
echo "\n%% Deleting WP: " . $path;
if (!$ro) unlink($path);
}
if ($file == '.htaccess') {
$data = fread(fopen($path,r),filesize($path));
fclose($path);
if (preg_match('(/(\w+/)*\d+\.php)',$data,$matches)) {
echo "\n%% Deleting bad .htaccess in: " . $dir;
echo "\n%% Deleting: $dir" . $matches[0];
if (!$ro) unlink($path);
if (!$ro) unlink("." . $matches[0]);
} else {
echo "\n:: Inspected clean .htaccess: $path";
}
}
if (preg_match('/^\d{4,}(\.php)$/',$file)) {
echo "\n%% Deleting suspicious file: $file, $path";
if (!$ro) unlink($path);
}

//----------------- End Editable ------------------//
break;
} else {
//---------------------------------------------------------//
// - SECTION 3 - //
// Actions to be performed on DIRECTORIES ONLY //
//----------------- Begin Editable ------------------//

//----------------- End Editable ------------------//
break;
} while (FALSE);
$list[] = $entry;
}
}
}
closedir($handle);
return $list;
}

function modtcpare($a, $b) {
$at = $a['modtime'];
$bt = $b['modtime'];
return $bt - $at;
}

function pathcpare($a, $b) {
$at = $a['path'];
$bt = $b['path'];
return strcmp($at,$bt);
}

// done with function definitions, now run it...

// exclude all directories named 'cache' like this:
// $exclude_dirs[] = 'cache'
$exclude_dirs[] = './cache';

$desired_depth = $_GET['depth'];
if (!$desired_depth) $desired_depth = 0;
$modify = $_GET['modify'];
if (isset($modify)) $ro = ! $modify;

$result = process_dir('.', $desired_depth);

echo "\n\n-----\nEntries ordered by Path: " . count($result);
usort($result, 'pathcpare');
$cnt = 1;
foreach ($result as $file) {
echo "\n" . str_pad($cnt++, 6, " ", STR_PAD_LEFT) . ": " . $file['path'];
}


echo "\n\n-----\nEntries ordered by Modification Time: " . count($result);
usort($result, 'modtcpare');

$cnt = 1;
foreach ($result as $file) {
$mtime = date('Y-m-d H:i:s', $file['modtime']);
echo "\n" . str_pad($cnt++, 6, " ", STR_PAD_LEFT) . ": (" . $mtime .") " . $file['path'];
}

?>