# 什么是 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 开头的。

image-20241129010454828

上图中,左边是编码后的数据,右边是原始数据

# JWT 的结构

JWT 由三部分组成,每一部分都经过 Base64Url 编码,中间用 . 分隔:

  1. Header(头部)
    • Header 通常由两部分组成: alg (签名算法)和 typ (令牌类型)。最常见的签名算法是 HMAC SHA256。
    • 示例: {"alg": "HS256", "typ": "JWT"}
  2. Payload(载荷)
    • Payload 部分包含声明(Claims)。声明是关于实体(通常是用户)和其他数据的元数据,也是 JWT 的主要有效内容。
    • 示例: {"name": "admin", "iat": 1732809600}
  3. Signature(签名)
    • 使用 密钥 (通常是服务器端保密的)和指定的 签名算法 (例如 HMAC SHA256)对 Header 和 Payload 进行签名。签名确保 JWT 的内容没有被篡改。
    • 签名生成过程: HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secretKey)
    • 由上面的生成算法我们可以得知,只要 headerpayload 改变了,最后的 signature 也随之改变,所以 signatue 字段主要起到签名的作用,接收方可以验证 JWT 的完整性,并确保数据是由合法的服务端生成的。

# JWT 的工作原理

  1. 用户登录,服务器生成 JWT*
  • 用户在客户端提交其用户名和密码。

  • 服务器验证用户的凭证(例如查数据库验证密码是否正确)。

  • 如果凭证正确,服务器生成一个 JWT,通常包括用户的 ID、角色、权限等信息,并将此 JWT 返回给客户端。这个 JWT 包含了关于用户身份和授权的数据。

    例如,生成的 JWT 可能包含以下信息:

    • sub (subject):用户 ID
    • role (角色):用户角色(如 admin、user 等)
    • exp (过期时间):JWT 的有效期限
  1. 客户端存储 JWT
  • 客户端(浏览器或移动应用)接收到 JWT 后,通常将其存储在本地存储(如 localStoragesessionStorage )中,或者在 HTTP 请求中将其存储为 Cookie(最好设置为 HTTP-only 和 Secure 属性,以提高安全性)。
  1. 客户端发起请求,携带 JWT
  • 当客户端需要访问受保护的资源时,它会在请求的 Authorization 头部携带 JWT。通常使用 Bearer 模式:

    Authorization: Bearer <JWT>
  1. 服务器验证 JWT*
  • 服务器接收到请求后,会从 Authorization 头部提取 JWT。
  • 服务器使用预设的密钥(或者公钥,如果使用非对称加密的话)验证 JWT 的签名是否正确,以确保请求未被篡改。如果签名验证成功,服务器还会检查 JWT 中的 exp 等声明,确保 JWT 没有过期。
  • 补充一下,验证过程其实是客户端发来的 signature 与服务器自己用密钥重新计算得到的 signature 进行比较的过程,并不依赖于 “加解密” 的操作,而是 “ 签名验证 ”。非对称加密的话,就用私钥进行签名,公钥来验证。
  1. 服务器响应请求
  • 如果 JWT 验证通过且有效,服务器将允许访问受保护资源并返回相应的数据(例如用户信息、API 数据等)。
  • 如果验证失败或 JWT 无效(如签名错误、过期等),服务器将返回 401 Unauthorized 错误,要求用户重新登录。

# JWT 的优点

  • 无状态:JWT 包含了所有用户的身份信息,因此服务器不需要存储会话状态,减少了服务器负担。
  • 跨域支持:JWT 是自包含的,可以在不同的系统和服务之间进行传输,特别适用于跨域请求。
  • 灵活性:JWT 可以承载多种自定义数据,适合用于分布式系统中不同服务之间的认证与授权。所以 JWT 还可用于 SSO 登录(单点登录)。

# JWT 存在的安全问题

# 签名算法

JWT 的 header 部分包含一个 alg 字段,它指定了签名的算法,例如 HS256 (HMAC SHA-256)、 RS256 (RSA 签名)等。

# none 算法漏洞

如果 alg 字段被恶意修改为 noneJWT 将被认为是未签名的,从而使得任何人都可以篡改 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"
}

image-20241130232906158

不只是 /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 密钥对

  1. 生成私钥:首先,你需要生成一个 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
  2. 导出公钥:一旦生成了私钥,你可以从私钥中提取公钥。可以使用以下命令:

    openssl rsa -in private_key.pem -pubout -out public_key.pem

    这会生成一个未加密的公钥文件 public_key.pem

# 步骤 2:提取 ne

要从公钥中提取出 RSA 公钥的模数 ( n ) 和公钥指数 ( e ),你可以使用以下命令:

openssl rsa -in private_key.pem -pubout -text

这个命令将显示 RSA 公钥的详细信息,其中包含 ne 的值。例如,输出结果可能如下:

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

在你提取出 ne 后,可以将它们放入 jwks.json 文件。 ne 必须使用 Base64 URL 编码格式。

1. Base64 URL 编码 ne

使用 OpenSSL 将 ne 编码为 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

然后,你就可以将这些编码后的 ne 放入 jwks.json 文件中。

2. 生成 jwks.json 示例

假设你已经提取并编码了 ne ,可以生成如下格式的 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"
}
更新于 阅读次数