SecMap - JWT

SecMap 系列之 JWT

SecMap 系列停止更新有一段时间了,年初立的 Flag 不能倒!

介绍

JWT 的理解

JWT(Json Web Token) 是一个非常轻量级的规范。它本质上是一个 token,这个 token 我们理解为 访问资源的凭据 即可,即它是一种基于 Token 的会话管理方案。JWT 一个很重要的特点就是,如果要想确认它是否有效,我们只需要看 JWT 本身的内容就可以验证了。

由于 JWT 的使用场景主要还是在认证上,所以本文就不多啰嗦其他场景了。

那在 JWT 之前,我们是怎么做访问资源凭据的验证呢?答案是 cookie-session 机制。对于 cookie-session 这一套我们是比较熟悉的,那 JWT 认证的流程是什么样的呢?

别着急,先来看一个常见的误区。

首先,JWT 标准(见资料 1)中是这么说的:

1
2
3
4
5
...
The claims in a JWT are encoded as a JSON object
that is used as the payload of a JSON Web Signature (JWS)
structure or as the plaintext of a JSON Web Encryption (JWE) structure
...

所以,你可以这么说,一个 JWT 要么是一个 JWE,要么是一个 JWS,而我们常说的 JWT 其实特指的是 JWS。

所以为了严谨,下文将严格区分它们。如果提到了 JWT 的话,那就是指的 JWS + JWS。

那么什么是 JWS、JWE 呢?

JWS 的理解

JWS(JSON Web Signature)它其实就是一个 JSON,由 3 个字段组成,每个字段都需要经过 url 编码 + Base64Url 编码。它们之间用 . 连接:

1
2
3
base64.urlsafe_b64encode(header) + "." + 
base64.urlsafe_b64encode(payload) + "." +
base64.urlsafe_b64encode(signture)
  1. header: 记录 JWT 本身的一些信息,包含以下键
    1. typ: token 类型,这个值是写死的,就是 JWT,它的作用在于出现嵌套的时候,可以识别出哪一层的 json 是个 JWS
    2. alg: 签名算法(比如 HMAC 类: HS256等、RSASSA 类: RS256、ECDSA 类ES256等、none)。相信各位在小学六年级就知道了,这里的签名算法都是要用到密钥的。
    3. cty: 可选,比较少见,如果这个 JWS 包含另一个 JWS 的话(注意与 typ 的区别),它就需要置为 JWT
    4. jwk: 可选,JSON Web Key, 当签名算法所用的密钥有很多个的时候(比如有一个统一提供 JWT 认证的服务,有很多应用接入),服务端在校验 JWT 的完整性时不知道要用哪一个私钥进行验证,有了这个键之后就知道之前颁发 JWT 的时候用的是哪个公钥了。这里的 jwk 需要按照资料 5 的规范,也是一个 json,里面有它自己的键,感兴趣的话可以自行查看 rfc,这里就不赘述了。
    5. kid: 可选,jwk 的编号,如果签名算法所用的密钥很多的话(同上面的情况),可以通过这个标识来判断/查询用的是哪一个密钥。它与 jwk 有类似的作用。如果同时出现 kid 和 jwk 那么 kid 的含义是标识用的是哪个 jwk。
    6. jku/x5u/x5c/x5t/x5t#S256/crit: 这些用得少就不赘述了,见 rfc 文档即可。
  2. payload: JWT 标准将 Payload 中的键称为 JWT 声明(claims),有以下 3 类:
    1. JWT 标准预定义了一些声明,不强制但建议使用,叫 registered claims,包含:
      1. iss: Issuer, 签发人
      2. sub: Subject, 主题
      3. aud: Audience, 使用对象
      4. exp: Expiration Time, 到期时间,到达或者超过到期时间的应当拒绝处理
      5. nbf: Not Before, 在此时间之前的应当拒绝处理
      6. iat: Issued At, 签发时间
      7. jti: JWT ID, JWT 的唯一标识符
    2. 如果你想自定义一个类似 registered claims 这种申明,有两种选择,要么去 IANA JSON Web Token Registry 中注册(见资料 2),要么取一个不太会被用到的名字(防止重复),比如加上你的域名前缀。这种叫 public claim。我也没懂为啥会有这种需求,我的理解是这玩意自定义之后,可能是给一整个组织去使用的,算是一种定制化的 JWT 规范。
    3. 最后,大多数键都是开发自定义的(比如身份信息等等),这个叫 private claims,需要注意的是这里的键名不要覆盖了上面两种键名,比如 exp 就是过期时间,不能把它当做用户名来用。当然如果服务端和客户端约定好了,非要这么干,那也不是不行,只是很不符合规范。
  3. signture: 根据指定的算法,用密钥对 header + payload 进行签名,用于校验 JWS,避免 伪造/篡改

最后有必要说明的是,我们常用的 JWS 其实是紧凑模式。相比之下还有一种通用模式和紧凑通用模式,它们的区别是什么呢?

  • 紧凑模式是经过 Base64Url 编码的,它只有一个数字签名,用来保护 header+paylaod
  • 通用模式提供了单独保护某一个 header 键的能力,但是它不经过 Base64Url 编码:
  • 紧凑通用模式就是在通用模式上增加了一步 Base64Url 编码

JWE 的理解

由于 JWS 只对整个 json 做了签名,其中 paylaod 还是明文的,Base64Url 解开就行。那如果我不想让别人看里面的内容怎么办呢?就可以用 JWE。

JWE(JSON Web Encryption)也是一个 json,由 5 个字段组成,每个字段都需要经过 url 编码 + Base64Url 编码。它们之间用 . 连接:

1
2
3
4
5
base64.urlsafe_b64encode(header) + "." + 
base64.urlsafe_b64encode(JEK) + "." +
base64.urlsafe_b64encode(JIV) + "." +
base64.urlsafe_b64encode(Ciphertext) + "." +
base64.urlsafe_b64encode(Tag)
  1. header: 与 JWS 基本一致,但是有几个键不一样
    1. alg: 算法名称,该算法用于下面的 JEK
    2. enc: 算法名称,用于加密 payload
    3. zip: 可选,在加密前压缩数据的算法
  2. JEK: JWE Encryption Key,它是由随机生成的 CEK(Content Encryption Key) 通过加密得到的,至于是哪个加密算法,就是由 alg 指定的。这个 CEK 后面要用于加密 payload。
  3. JIV: JWE Initialization Vector,初始的 IV,有些加密方式需要这玩意
  4. Ciphertext: 对 payload 加密后的数据。
  5. Tag: 就是 Authentication Tag,加密算法产生的附加数据,用于保护密文的完整性

与 JWS 类似,JWE 也有三种模式:紧凑模式、通用模式和紧凑通用模式。

最后举个例子来看一下 JWE 的加密过程,我觉得一图胜千言(紧凑模式):

JWT 认证流程

  1. 用户向服务器提交用户名和密码,服务器验证是否正确
  2. 如果校验正确,服务器创建一个 JWT,发回给客户端(JWE 的话会有解密过程)
  3. 浏览器获取到经过签名的 JWT,然后在之后的每个请求中都会附带 JWT
  4. 服务器在每收到一次请求都会校验 JWT,检查 JWT 签名,确保这个是自己签发的(如果是 JWE 的话会有解密过程)。
  5. 没有问题的话,返回响应

非常好理解,其实就是服务端在验证身份之后,会给你发一个 token,然后之后你所有的请求都带上这个 token,服务端就知道是你了。

优缺点

那么 JWT 什么好处呢?

  1. 非常明显的一点就是,它的可扩展性很好。现在很多应用是分布式部署的,如果是基于 session 去做的认证,那么就需要做数据共享(至少得保证 session 是一致的),做法可以是存在数据库或者缓存队列(比如 redis)里面。但是 JWT 不需要,因为手握密钥的服务器只要有 JWT 就可以验证它是否合法。
  2. 无状态 —— JWT 不在服务端存储任何状态。RESTful API 的原则之一就是无状态。这里的“无状态”,很多人有不同的说法,我觉得意会一下就好了。论文的原文是 “such that each request from client to server must contain all of the information necessary to understand the request, and cannot take advantage of any stored context on the server.”。所以我们会发现 JWT 是很符合 RESTful 要求的
  3. 抗 csrf —— 毕竟连 cookie 都没有了对吧。
  4. JWT 的 payload 中可以简单存储一些常用信息,取起来比较方便。

那么 JWT 什么坏处呢?

  1. 通信的开销大。如果 JWT 的 payload 里存放了大量的数据,那么整个 JWT 就会很长(别忘了它还要经过 base64Url 编码)。而单个 cookie 是有大小限制的,一般是 4k 左右,所以 cookie 很可能放不下。因此 JWT 一般是放在 Header 里面发送,存储的话会放在 local storage 里面。而对比之前的 cookie-session,SessionId 要短的多。
  2. JWT 一旦签发,无法修改。这对应以下 3 个问题:
    1. 数据有改动时会出现不一致的问题。假设 JWT 中存储了用户名,当用户将姓名修改之后,JWT 里的用户名就会和数据库里保存的用户名不一致。这个时候一个很直接的想法就是我们重新签发一个 JWT 就行,但是旧的 JWT 还没过期呢,这个旧的 JWT 依旧可以用于登录,那登录后服务端从 JWT 中拿到的信息就是过时的。那么怎么把旧的 JWT 弄失效呢?这就变成了下面这个问题。
    2. 要把一个 JWT 变成失效状态,只能等它到期。比如退出登录这种场景,cookie-session 只需要服务端删掉相关的 session 就行;而原生的 JWT 是无法实现这个功能的。如果你非得用 JWT 实现这个功能,那么常见的有以下几种方案:
      1. 一种方案是用户在点击退出的时候,客户端配合删除本地存储的 JWT。这种方案原理上行得通,但是服务端对这个过程是不可控的,只能祈祷客户端只有一个并且它成功删除了 Cookie。
      2. 另一种方案是设置有效期较短的 JWT,但是这又会导致用户需要频繁登录(体验上的问题)。并且就算 JWT 有效期再短,距离用户点击“退出登录”和它失效之间肯定也是有一个窗口期的。
      3. 另另一种方案是额外部署一个组件用于存储状态,比如在 redis 中设置一个黑名单,签发了新的 JWT 之后就把旧的就加入黑名单,避免被再次使用,最后等到期了再在 redis 中删除即可。但这不就是有状态吗?它违背了 JWT 的初衷,并且和基于 cookie-session 的方案已经相差无几了。
      4. 另另另一种是基于 refresh token 的方案,我感觉是对方案 3 的优化。这种方案就是在客户端登录之后,额外颁发一个 Refresh token(有效时间长),来后续获取 JWT(有效时间短)。当客户端访问需要认证的接口时,先携带 JWT 发起访问,服务端校验是否过期,如果没有,那么鉴权通过后,返回成功的响应;如果 JWT 过期或者鉴权失败,则返回失败的响应,客户端这时需要使用 Refresh Token 来申请新的 JWT,如果 Refresh Token 没有过期,服务端鉴权通过后向客户端下发新的 JWT,客户端后续用这个新的 JWT 就可以了;如果 Refresh Token 也过期了那就引导用户去重新登录来获取新的 Refresh Token。由于 Refresh Token 不会在客户端请求业务接口时验证,只有在申请新的 JWT 时才会验证,所以相比方案 3,降低了服务端各个组件在响应时间上的压力。当登出或禁用用户时,只需要将 Refresh Token 删除,用户就会在 JWT 过期后,由于无法获取到新的 Access Token 而再也无法访问。这样的方式虽然还是会有一定的窗口期(需要等 Access Token 过期,不过相比方案 2 已经缓解了很多),但是结合方案 1,还算实用了。
    3. 本文的重点内容:当 JWT 使用不当的时候,存在的安全问题。

综上,我对 JWT 的看法是,没有银弹。你不能期望它解决所有认证问题。在使用 JWT 的时候,是否适合用它是需要仔细思考的,每种方案都有它的好、坏与适配的场景,我们在合适的场景下选择合适的方案就行了,不要执着于使用 JWT。

攻击思路

由于 JWE 的使用场景实在是太少了,我也没见过,所以下面就说 JWS 吧,等我遇到了再补上。

JWS 的安全性全靠签名,所以大部分攻击场景都是针对签名的。

信息泄露

JWS 的 header 和 payload 都只经过简单的编码,所以不应该存放敏感数据。根据我的经验,现在其实还是有不少人认为 base64 是“加密算法”,如果是这么认为的那就很有可能将敏感信息放在里面,导致信息泄露。

爆破签名密钥

只要你拥有密钥,那么你就可以构造任意内容的合法的 JWS。所以最直接的思路就是拿到一个 JWS 之后爆破它的签名密钥,这个过程还是离线的,所以速度会比较快。

所以密钥空间一定要足够大。

签名算法置为 none

上面提到 header 中有个键是 alg,用于标识 signature 的签名算法。而其中有一个值是 none,它是 JWT 规范中强制要求实现的(...only HMAC SHA-256 ("HS256") and "none" MUST be implemented by conforming JWT implementations...)。其实我不太理解为什么要加这个值。

所以在自己实现 JWT 的时候,如果没有注意到这种特殊情况(可能有些库的实现也有问题,不过目前我还没找到),攻击者就可以设置 alg 为 none,这样 paylaod 就可以随便伪造了。

比如:

1
2
3
payload = {
"name": "Tr0y",
}

它的 JWS 是 eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJuYW1lIjoiVHIweSJ9.

我们将 name 改为 admin 也可以通过验证。

那我们应该怎么处理这种情况呢?如果我们认为所有算法是 none 的 JWS 都是不合法的,那么按照 rfc 文档的规定,这样粗暴地处理其实是不符合标准的,虽然我也感觉真的有这个需求的人应该特别特别少。

我们可以参考 Python 的第三方库 pyJWT 的实现:

默认情况下,只要你的算法是 none,验签的时候直接一棍子打死。但是你可以加上一个参数来使用,这里附上 pyJWT 作者的吐槽:

可以说是很豹笑了。我觉得这个做法不但比较安全,还符合 rfc 规范(可以让你这么用,但是你必须额外设置一个参数),值得我们学习。

签名算法从非对称类型改为对称类型

这种情况本质上是服务端没有校验算法与密钥是否属于同一种类型的组合。

场景:服务端只使用了 HS256 来做签名算法。那么自然就是用私钥签名,公钥验签。

假设条件:服务端没有校验收到的 JWS header 里的算法是不是自己采用的算法,而是直接采用 JWS 里的算法来验签,且认为算法应该是 RS256(脑子里想的是要用 RS256 算法,手上却用了 JWS 里的算法,至于密钥?那当然用的是 RS256 的公钥了)。

那么攻击者可以构造一个 JWS,算法为 HS256,利用公开的公钥作为签名密钥,那么就可以通过验签了。因为服务端会把算法设定为 HS256,而此时验证签名的密钥就是公钥。

那么怎么避免这个问题呢?

  • 校验收到的 JWS 中的算法自己有没有使用,如果没有使用的话直接干掉。
  • 如果没有多种算法的话,不要采用 JWS 里的算法
  • 如果有多种算法(我觉得这种情况下出这个漏洞的可能性较低,因为肯定是有多个密钥的),那么密钥一定要保证与算法是一一配对的(这个大多数情况下也不会有问题)

另外,有些库是禁止 HMAC 使用非对称加密的密钥的,比如 pyJWT:

1
2
3
4
jwt.exceptions.InvalidKeyError: 
The specified key is an asymmetric key
or x509 certificate
and should not be used as an HMAC secret.

kid 设计问题

这种大多出现在 CTF 上。

当 JWS 的密钥很多的时候,可以通过 kid 来确定使用哪个密钥。如果 kid 的相关逻辑存在问题的话,就会出现安全问题。

任意文件上传 + 可通过 kid 指定特定路径下的密钥

例子:2017 HITB CTF: Pasty

SQL 注入

例子:2021 年网鼎杯的玄武组 Web 题: js_on

资料


其实 JWT 相关的知识点还有一些
但是不太常用
(看 rfc 文档真的是太痛苦了…)
橘友们可以慢慢研究
我就先看到这


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!