这是一道 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 是:
正好满足条件。