BUUCTF-EasyLogin

这是一道 Node.js 语言的题目,在此记录我在做这道题的思考过程。

这道题考的是 CVE-2022-23540.

简单测试

进入题目环境:

 一个登录页面,由题目的名称 EasyLogin,我猜测这道题是身份认证缺陷的问题。不过,还是下意识测试一下弱口令,输入用户名为 admin,密码 123456,点击 LOGIN:

 失败了,再加大力度,直接上 burpsuite 暴力破解密码。然而,我发现发送太多请求会报 429:

 这可以说是一种服务器阻止暴力破解的提示,到这里基本可以断定题目不能通过弱口令就能完成的,所以得找其他方式。

在登录页面上,有个注册功能的链接,可以注册账号。一开始,我想注册一个用户名为 admin 的账号(根据经验,一般管理员账号名是 admin)。但是,不允许:

 只能注册用户名 admin 以外的账号...那就注册一个用户名为 test,密码为 123456 的账号。然后,用这个账号登录进入:

 来到了获取 flag 的地方。很好,直接点击 GET FLAG:

 权限拒绝......很明显,要有管理员的权限。到这里,可以明白通过正常的流程是拿不到 flag 的,需要了解更多的信息。

源码泄露

退出当前账号,重新来过。在登录页面上按 F12 查看源代码,发现有个 js 文件:

访问看看有什么东西:

 一段提示信息,一些 js 函数,重点明显在于理解这段提示信息,一开始我并没有理解它的含义,直到我随手在 URL 路径上输入 app.js:

 好家伙,泄露了源码。顿时,我明白了那段提示信息的含义:如果 koa-static 在设置静态文件的目录路径时,把路径设置为网站的根目录,那么就会造成程序的源码泄露。接下来,就是源码审计了。

找到路由

通过查看源码,我发现还有 rest.js 和 controller.js 两个源文件: 

这两个文件的路径是相对路径,显然是出题人自己编写的,需要重点审计。当我看完这两个文件后,我没有找到登录功能的代码和接口,而且在 controller.js 中发现还有 controllers 目录:

 这里的代码做几件事:

1. 找到 ./controllers/ 目录的所有 js 文件;

2. require 包含这些 js 文件进来;

3. 对这些 js 文件中的路由规则都添加到 router 对象中。

 在 controllers 目录下有个关于路由的文件,我需要找到它。这里,我只能不断猜测文件名,因为没有其他提示。所幸,我找到了:

找出缺陷

接下来就是找认证过程的缺陷,这里有 4 个路由:

/api/register
/api/login
/api/flag
/api/logout

分别对应注册、登录、获取 flag 和登出等 4 个处理过程,我首先排除掉 /api/flag 和 /api/logout,然后看 /api/register 的处理:

 不允许注册用户名为 admin 的账号,还是用三个等号的全等于进行比较,所以无法用 javascript 的大小写技巧绕过。在这个函数中,我只能控制 username 和 password 这两个输入,随后它们就被用于 jwt 的签发。所以,注册功能是没有缺陷的。

再看 /api/login 的处理 :

先获取请求的 jwt,然后进行验证。jwt 就包含很多可控制的输入点,可以推测这里会有缺陷。

从代码的逻辑可以明确两点:

1. 我提交的账号必须是 admin 用户名;

2. 我提交的 jwt 是合法的。

基于这两点,我必须正确签发我的 jwt,以实现伪造。在此之前,我又必须知道当初在注册账号时,为我的账号签发 jwt 的密钥。显然,我不可能知道这个密钥是什么,因为它被保存到一个数组中:

 不过,我可以控制使用哪个密钥或者不使用密钥,因为 secretid 参数在签发 jwt ,也就是在调用 jwt.sign() 函数时,将 secretid 作为参数传了进去。然后,在登录时,又从我提交的 jwt 中获取 secretid 参数,作为 globals.secrets 数组的下标,从中获取密钥。 secretid 是可控的,那么取出什么密钥也是可控的。接下来,我需要测试能否不使用密钥签发 jwt:

 我让 secretid 设置为 -1,那么在 jwt.verify() 验证 jwt 时,secrets[-1] 就无法取出一个密钥,那么返回值为 undefined。同时,我传入一个 undefined 作为密钥传入 jwt.sign()。但是,我在签发时失败了,上面的代码执行结果为:

 显然,传入 undefined 是错误的,我必须有传入一个长度不为 0 的字符串作为密钥。到此,我已经没有其他思路,我知道我还缺少一些关键信息,才能完成伪造。

完成漏洞利用

后来,通过查看 writeup,我得知一条信息:jwt.verity() 在验证 jwt 的合法性时,如果没有传入一个密钥,那么默认不使用任何的密码算法,即使第三个参数设置了密码算法的选项。也就是说,下面的代码:

jwt.verify(token, secret, {algorithm: 'HS256'})

 如果 secret 为 undefined 或者空字符串,实际上与:

jwt.verify(token, undefined, {algorithm: 'none'})

是等价的。

得知这一关键信息,我大致明白如何写代码构造 payload 进行测试。不过,还缺少一环,就是 sid 的校验:

 sid 是我在 jwt 中传入的 secretid 参数,是可控的,但是从判断条件中,它必须满足几点:

1. 必须有值;

2. 判断条件 sid < global.secrets.length && sid >= 0 为 true;

3. globals.secrets[secretid] 返回的结果没有值,即为 undefined。

可以利用 JavaScript 弱类型比较的特性进行绕过,比如 sid 为空字符串。可以测试一下:

 OK,可以伪造 jwt 了:

const jwt = require("jsonwebtoken")

secrets = []
secretid = ''

username = "admin"
password = "123456"

token = jwt.sign({secretid, username, password}, undefined, {algorithm: 'none'})

console.log(token)

将得到的 jwt token 替换 sessionStorage 里的值:

 输入用户名 admin,密码 123456,登录进去,点击 GET FLAG 就可以得到 flag 了。

漏洞原理

前面说到:jwt.verity() 在验证 jwt 的合法性时,如果没有传入一个密钥,那么默认不使用任何的密码算法,即使第三个参数设置了密码算法的选项。

这说法其实并不完整,应该是:jwt.verity() 在验证 jwt 的合法性时,如果没有传入一个密钥,而且 jwt 没有 signature 部分时,那么默认不使用任何的密码算法,即使第三个参数设置了密码算法的选项。

看一下 jsonwebtoken 库的源码就知道了:

// jsonwebtoken/verify.js
...

// jwtString 是客户端提供的 token,secretOrPublicKey 是密钥,options 是选项,callback 是回调函数
module.exports = function (jwtString, secretOrPublicKey, options, callback) {
  
  ...

  if(typeof secretOrPublicKey === 'function') {
    ...
  else {
    getSecret = function(header, secretCallback) {
      return secretCallback(null, secretOrPublicKey);
    };
  }

  return getSecret(header, function(err, secretOrPublicKey) {

    // err 为 null,跳过
    if(err) {
      return done(new JsonWebTokenError('error in secret or public key callback: ' + err.message));
    }
   
    // 判断 token 是否存在第三部分,也就是 signature
    var hasSignature = parts[2].trim() !== '';

    // 没有提供 signature,但提供了密钥,报错
    if (!hasSignature && secretOrPublicKey){
      return done(new JsonWebTokenError('jwt signature is required'));
    }

    // 提供了 signature,但没有提供密钥,报错
    if (hasSignature && !secretOrPublicKey) {
      return done(new JsonWebTokenError('secret or public key must be provided'));
    }

    // 没有提供 signature,并且没有设置 algorithms 选项,设置 algorithms 为 none
    if (!hasSignature && !options.algorithms) {
      options.algorithms = ['none'];     // 在我们的攻击过程中,会走到这一步
    }

    // 上面给 algorithms 赋了值,所以这里跳过
    if (!options.algorithms) {
      options.algorithms = ~secretOrPublicKey.toString().indexOf('BEGIN CERTIFICATE') ||
        ~secretOrPublicKey.toString().indexOf('BEGIN PUBLIC KEY') ? PUB_KEY_ALGS :
        ~secretOrPublicKey.toString().indexOf('BEGIN RSA PUBLIC KEY') ? RSA_KEY_ALGS : HS_ALGS;

    }
        
    // options.algorithms = ['none'] 正好与 token 的第一部分设置的 {algorithm: "none"...} 对应,所以这里跳过
    if (!~options.algorithms.indexOf(decodedToken.header.alg)) {
      return done(new JsonWebTokenError('invalid algorithm'));
    }

    var valid;

    try {
      // 验证 token,decodedToken.header.alg 为 none,secretOrPublicKey 为 undefined,所以不使用任何密码算法进行验证
      valid = jws.verify(jwtString, decodedToken.header.alg, secretOrPublicKey);
    } catch (e) {
      return done(e);
    }

    ...
  });
};

第一,没有提供 signature,但提供了密钥会报错;第二,提供了 signature,但没有提供密钥也报错。所以只有 token 提供 signature,并且提供密钥,或者 token 不提供 signature,并且不提供密钥,上面的代码才会走到 jws.verify() 这一步。在这道题中,我提供的 token 是:

正好满足条件。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值