1.题目给了源码,简单看了一下,重点需要关注/routes/index.js
中的三个路由和数据库执行文件database.ts
即可
2.首先看到database.ts
文件,一共两张表:users、files
,其中users
表中里面保存了admin用户的账号密码,另外再往files
表中插入了一条文件数据,这里到下面再解析。
3.利用给出的账号密码登陆admin账号,接下来看到router.post('admin')
这个路由,checkAuth
函数我们是已经通过校验的,因为我们账号并不是superuser
且/login
路由就已经isadmin=1
了。这个/admin
路由主要看我们输入的数据content
,它会先嵌入template
中,再被packjson
文件中安装的pdf
包的toFile()
方法转换为pdf文件,再写入到随机uuid的文件名内。
const checkAuth = (req: Request, res:Response, next:NextFunction) => {
let token = req.signedCookies['token']
if (token && token["username"]) {
if (token.username === 'superuser'){
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));
}
}
4.下面又调用了getCheckSum
方法,该方法按照文件的哈希值生成了一段校验码。然后调用DB.create()
方法将我们上传转换的pdf文件名和校验码给插入到数据表files
中了。
const getCheckSum = (filename: string): Promise<string> => {
return new Promise((resolve, reject) => {
const shasum = crypto.createHash('md5');
try {
const s = fs.createReadStream(path.join(__dirname , "../files/", filename));
s.on('data', (data) => {
shasum.update(data)
})
s.on('end', () => {
return resolve(shasum.digest('hex'));
})
} catch (err) {
reject(err)
}
})
}
5.接下来我们直接先看'/api/files/:id'
路由,该路由首先会判断我们是否为superuser
用户,接下来调用DB.getFile
方法,根据我们的token中的用户admin
和我们get中传递的校验码id
在files表中查找相关信息,并取得文件名,在/files/
目录下将文件给返回。这个时候就要联系上一步分析的/admin
上传方法了,在这个路由,我们根本看不到我们之前输入的数据content
转化成的pdf文件。因为以下两点:
- 首先,校验码是转化后的pdf文件哈希值,这个我们根本不知道是什么。
- 其次,一开始审计的时候注意到这一块插入
files
表并没有做预编译,但通过上面分析完结果才发现这三个参数都是固定死的。注意插入的数据库语句:await DB.Create('superuser', filename, checksum)
,也就是INSERT INTO files(username, filename, checksum) VALUES('superuser', {'uuid}.pdf', '{hash_value}');
。这里固定死了是superuser
用户,因此我们admin用户再怎么输入的数据转换的文件,都不可能被查询得到。
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('/');
}
});
6.其实我们下载的文件中,目录还有一条信息,那就是flag文件一开始就存在于/files/
目录下的。而我们第一步开始分析的初始化时,往files表中插入的下面这条初始化的sql数据。尽管我们有可传递的flag
文件校验码(该文件的哈希值),但是该文件所属的superuser
用户根本不存在的。所以我们如果用admin
用户的身份来传递这条校验码,也是在数据表files
中查询不出数据的。
INSERT INTO files (username, filename, checksum) VALUES ('superuser','flag','be5a14a8e504a66979f6938338b0662c');`)
7.这个时候,最后看的那个路由/api/files
就是破局的关键。当我们访问这个路由,并传递对应的参数,它就可以给我们往files表中插入一条自定义的数据。这里我们不但解决了校验码未知的问题,并且还解决了查询用户固定为superuser
的问题,同时又能顺利读取/files/
目录下flag文件。但是又有个问题,那就是这里需要本地127.0.0.1
进行访问,也就是需要ssrf。
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')
}
});
8.又回到了最开始/admin
,我们唯一可利用的输入点就在那里。这里提一下,当我们把html
文本转为pdf时,里面的script
脚本文件会自动加载解析。也就是这里能触发xss,当我们构造一个图片src或者别的标签自定义弹窗时,就可以构造请求本地的/api/files
了。
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 😥'})
}
}
});
9.这里有waf过滤了尖括号这些,参考别的文章可以用数组进行绕过。直接用img标签即可,试了一下meta
标签,环境直接崩溃。
payload:content[]=<img+src%3D"http%3A//127.0.0.1:8888/api/files?username%3Dadmin%26filename%3Dflag%26checksum%3D666">
10.最后直接访问/api/files/666
即可下载flag,后面试了script标签的window跳转和AJAX发送也是可以的。