最近看到了一个国外高中生的CTF比赛,翻了一下往年的例题,发现有一道关于jwt session伪造的题比较有意思,记录一下
题目简介中给出了我们题目的地址和后端处理的源码,看看源码先代码审计一下:
const cookieParser = require(‘cookie-parser‘); const express = require(‘express‘); const crypto = require(‘crypto‘); const jwt = require(‘jsonwebtoken‘); const flag = "[redacted]"; let secrets = []; const app = express() app.use(‘/style.css‘, express.static(‘style.css‘)); app.use(‘/favicon.ico‘, express.static(‘favicon.ico‘)); app.use(‘/rick.png‘, express.static(‘rick.png‘)); app.use(cookieParser()) app.use(‘/admin‘,(req, res, next)=>{ res.locals.rolled = true; next(); }) app.use((req, res, next) => { let cookie = req.cookies?req.cookies.session:""; res.locals.flag = false; try { let sid = JSON.parse(Buffer.from(cookie.split(".")[1], ‘base64‘).toString()).secretid; if(sid==undefined||sid>=secrets.length||sid<0){throw "invalid sid"} let decoded = jwt.verify(cookie, secrets[sid]); if(decoded.perms=="admin"){ res.locals.flag = true; } if(decoded.rolled=="yes"){ res.locals.rolled = true; } if(res.locals.rolled) { req.cookies.session = ""; // generate new cookie } } catch (err) { req.cookies.session = ""; } if(!req.cookies.session){ let secret = crypto.randomBytes(32) cookie = jwt.sign({perms:"user",secretid:secrets.length,rolled:res.locals.rolled?"yes":"no"}, secret, {algorithm: "HS256"}); secrets.push(secret); res.cookie(‘session‘,cookie,{maxAge:1000*60*10, httpOnly: true}) req.cookies.session=cookie res.locals.flag = false; } next() }) app.get(‘/admin‘, (req, res) => { res.send("<!DOCTYPE html><head></head><body><script>setTimeout(function(){location.href=‘//goo.gl/zPOD‘},10)</script></body>"); }) app.get(‘/‘, (req, res) => { res.send("<!DOCTYPE html><head><link href=‘style.css‘ rel=‘stylesheet‘ type=‘text/css‘></head><body><h1>hello kind user!</h1><p>your flag is <span style=‘color:red‘>"+(res.locals.flag?flag:"error: insufficient permissions! talk to the <a href=‘/admin‘"+(res.locals.rolled?" class=‘rolled‘":"")+">admin</a> if you want access to the flag")+"</span>.</p><footer><small>This site was made extra secure with signed cookies, with a different randomized secret for every cookie!</small></footer></body>") }) app.listen(3000)
粗略看了一下发现这是一道jwt伪造的题。先来简单讲一下jwt是个什么:
Json web token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC 7519).该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。JWT是由三段信息构成的,将这三段信息文本用符号"."链接一起就构成了Jwt字符串,就像这样:![]()
第一部分我们称它为头部(header),第二部分我们称其为载荷(payload),第三部分是签证(signature).
头部(header)承载两部分信息:
- 声明类型,这里是jwt
- 声明加密的算法 通常使用HS256与RS25
完整的头部就像下面这样的JSON:
在上面的代码中,alg属性表示签名使用的算法,默认为HMAC SHA256(写为HS256);typ属性表示令牌的类型,JWT令牌统一写为JWT。然后对头部进行base64编码即可得到我们的第一段jwt。
载荷(Payload)就是存放有效信息的地方。这个名字像是特指飞机上承载的货品,这些有效信息包含三个部分
- 标准中注册的声明
- 公共的声明
- 私有的声明
标准中注册的声明 (建议但不强制使用) :
- iss: jwt签发者
- sub: jwt所面向的用户
- aud: 接收jwt的一方
- exp: jwt的过期时间,这个过期时间必须要大于签发时间
- nbf: 定义在什么时间之前,该jwt都是不可用的.
- iat: jwt的签发时间
- jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。
公共的声明 :
公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息.但不建议添加敏感信息,因为该部分在客户端可解密.私有的声明 :
私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。完整的载荷就像下面这样的JSON:
然后对载荷进行Base64加密即可得到我们的第二段jwt,与第一段jwt使用“.”连接。
签证信息(signature)构成jwt的第三部分,这个签证信息由三部分组成:
- header (base64后的)
- payload (base64后的)
- secret
这个部分需要base64加密后的header和base64加密后的payload使用
.连接组成的字符串,然后通过header中声明的加密方式进行加盐secret组合加密,然后就构成了jwt的第三部分。
了解过jwt的原理后,我们来总结一下jwt中容易出现的安全问题,其实也就是CTF关于jwt题目中的考法:
1修改算法为none
修改算法有两种修改的方式其中一种就是将算法就该为none。
后端若是支持none算法,header中的alg字段可被修改为none。
去掉JWT中的signature数据(仅剩header + ‘.’ + payload + ‘.’) 然后直接提交到服务端去。
2修改算法RS256为HS256
RS256是非对称加密算法,HS是对称加密算法。
假设jwt内部的函数支持的RS256算法,又同时支持HS256算法
如果已知公钥的话,将算法改成HS256,然后后端就会用这个公钥当作密钥来加密
3信息泄露、密钥泄露
JWT是以base64编码传输的,虽然密钥不可见,但是其数据记本上是明文传输的,如果传输了重要的内容,可以base64解码然后获取其重要的信息。
如果服务端泄露了密钥,用户便可以根据密钥和加密算法来自己伪造生成jwt。
4爆破密钥
如果密钥比较短,并且已知加密算法,通过暴力破解的方式,可以得到其密钥。
仔细审计一下代码,需要关注的点在这里:
let secret = crypto.randomBytes(32) cookie = jwt.sign({perms:"user",secretid:secrets.length,rolled:res.locals.rolled?"yes":"no"}, secret, {algorithm: "HS256"}); secrets.push(secret);
再看看获取Flag的条件:
let sid = JSON.parse(Buffer.from(cookie.split(".")[1], ‘base64‘).toString()).secretid;
if(sid==undefined||sid>=secrets.length||sid<0){throw "invalid sid"}
let decoded = jwt.verify(cookie, secrets[sid]);
if(decoded.perms=="admin"){
res.locals.flag = true;
}
if(decoded.rolled=="yes"){
res.locals.rolled = true;
}
if(res.locals.rolled) {
req.cookies.session = ""; // generate new cookie
}
可以看到要求perms为admin并且rolled为no(否则就会重新生成一个session)时才会返回Flag
这里需要关注的是jwt是使用HS256算法来加密的,并且密钥是随机取了32位的字符串
很明显这里我们无法通过直接伪造session来拿到Flag,因为此处密钥没有泄露并且密钥是较长的随机字符串也很难爆破
因此我们直接将alg设置为none;再来关注一下sid的验证:虽然程序已经检查了sid是否为undefined,但是没有检查sid中的secretid是否为undefined,也就是说当secretid为undefined的时候,sid仍可以通过验证。
通过这个网站我们可以实现jwt的解码与生成:https://jwt.io/
首先获取我们访问时默认的jwt:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwZXJtcyI6InVzZXIiLCJzZWNyZXRpZCI6OTAsInJvbGxlZCI6Im5vIiwiaWF0IjoxNTgzMjk0MTk3fQ.M2OrRjGys_6btzgDXAipdjv4iB5vGovgnWFGQOwRgyo
解密得到:
{ alg: "HS256", typ: "JWT" }. { perms: "user", secretid: 90, rolled: "yes", iat: 1583294197 //这里是jwt生成的时间,发送的时候有无均可 }. [signature] //签名部分,这里我们没有密钥,因此接下来伪造jwt的时候需要删掉这一部分
我们来构造一下获取Flag的明文jwt:
{ alg: "none", //不使用加密方式 typ: "JWT" }. { perms: "admin", secretid: "ye", //这里为了绕过后端的验证需要输入一个无效的值 rolled: "no" //设置为no让后端不再重新分配jwt }.
然后我们生成jwt然后直接删去第三部分的签证得到Payload:
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJwZXJtcyI6ImFkbWluIiwic2VjcmV0aWQiOiJ5ZSIsInJvbGxlZCI6Im5vIn0.
将我们的payload jwt设置为session再次发送请求得到Flag:

虽说是中学生CTF,但是题目质量还是很不错的,除了考察了jwt的伪造,还需要审计绕过一处sid的验证,看了看官网当时有30个人做出来,还是有强人在的
参考链接:
https://www.jianshu.com/p/576dbf44b2ae
https://www.freebuf.com/column/207216.html
[ångstromCTF 2019]Cookie Cutter
原文:https://www.cnblogs.com/yesec/p/12408771.html