首页 游戏 软件 排行 专题

实测 JWT 鉴权:伪造一个令牌只改了两段 base64

来源: 本站整理 日期:2026/11/4 3:30:43

上个月给一个内部系统接了 JWT 鉴权,代码评审的时候同事问了我一句:这个令牌签了名,是不是就没法改了?我当时挺有底气,说签了名就改不了,密钥在我们自己手里。后来上线前做安全自测,我把生产环境里的令牌捞出来,改了其中两段 base64 就重新拼了回去,往网关一放接口返回 200,还把我账号下的订单号、收货地址全读了出来。那天我才认真把 JWT 的验签流程读了一遍,发现我们写的验签代码几乎把所有能省的步骤都省了。

顺带说一句,接口服务最后是要放在自己的机器上跑的,机器规格和线路直接影响鉴权网关的响应时间:vipserver(全球云服务器) 覆盖多个区域的节点可以横向对比,压测时看延迟和带宽比较直观;把令牌校验放在反向代理层做第一道拦截,也能少压给业务服务一部分请求。

JWT 令牌三段式结构示意图:头部、载荷与签名依次排列

一、令牌长什么样:其实只有三段 base64

JWT 不是加密,是签名。一个标准令牌长这样:xxxxx.yyyyy.zzzzz,三段之间用点号分隔,每一段都是 base64url 编码。解开第一段是头部,写着签名算法,比如 "alg":"HS256";第二段是载荷,里面放用户 id、角色、过期时间这些业务字段;第三段是签名,服务端拿密钥对前两段重新算一遍得到的结果。

这里有三个特别容易搞混的点,我一开始全踩了:

第一,base64 不是加密。第二段谁都能解开看,我试的时候用一行命令就把自己的手机号、角色 admin 全读出来了。也就是说,载荷里绝对不能放密码、身份证号这种敏感信息,能放的就只是「身份标识」,敏感数据要另找地方存。

第二,签名保证的是「没被改过」,不是「看不见」。签名算法分两类,HS256 是用同一个密钥既签名又验签,RS256 是私钥签名、公钥验签。两种算法安全性差别很大,但只要服务端验签的代码写错,差别就等于零。

第三,头部里的 alg 是客户端可以随便写的字段。这一个字段,就是我们这次事故的入口。

二、服务端到底应该怎么验一步

把正确的流程拆开,其实就六步:

1. 取出头部里的算法名,但不是直接拿它来决定用什么算法验,而是去服务端的白名单里核对,配置里写了只认 RS256,那就只走 RS256 这条路;

2. 按 kid 从服务端的密钥表里取出公钥或共享密钥,这一步只能查表,不能拿 kid 去拼文件路径或者发网络请求;

3. 把头和载荷按原样拼回去,用密钥重算一遍签名;

4. 用常量时间比较函数比对新旧签名,不要用简单的字符串相等判断;

5. 再查令牌的时效和归属:exp 有没有过期、nbf 是不是还没生效、iss 是不是我们签发方、aud 里有没有当前这个服务;

6. 最后才轮到业务权限判断。

我们原来的代码只做了第 1 步的一半和第 3 步,第 5 步整个跳过了。也就是说,攻击者只要能让服务用错误的算法验签,令牌的有效期、签发方、受众全都不用管。

机房中工程师在服务器前验证加密数据与签名校验流程

三、我们实测出来的四个问题

问题一:算法由客户端说了算(alg 混淆)。我们用的库支持通过配置切换算法,接入的时候图省事,直接读令牌头部里的 alg 去分支。攻击者把头部里的 RS256 改成 none,库在某些版本里会直接跳过签名校验;改成 HS256 更狠服务端拿着公钥去算 HMAC,而公钥是公开的,攻击者用同一把公钥算出的 HMAC,服务端验得出来。我们把算法写死在服务端配置里、并且显式拒绝 none 之后,这条路才堵上。

问题二:kid 当成路径用了。为了支持多租户切换密钥,我们在验签前会读一下 kid 对应的密钥文件。攻击者把 kid 设成 ../../etc/passwd 或者一个内网地址,服务端就去读了。改成内存里的字典查表,只认我们登记过的 key id,问题消失。

问题三:HS256 的密钥太弱。我们最早用的是一句中文口令当密钥。这种密钥在离线爆破面前几乎没有抵抗力攻击者拿到一个自己签的令牌,就能拿它当「已知明文」反复尝试猜密钥,猜中一次就能伪造任何人的令牌。换成 32 字节以上随机生成的高熵密钥之后,这块才算踏实。

问题四:只验签,不验时效和归属。我们服务里既有给 App 用的令牌,也有给内部服务用的令牌,两边密钥不一样,但当时没有区分。结果另一个服务签发的令牌拿来也能用,我们自己签发但已经过期的令牌也能用,压根不检查过期时间。补上签发方、受众和过期时间校验,并且给服务器做时钟同步,允许 60 秒左右的误差,就已经够用了。

昏暗灯光下散落密钥卡片与笔记本电脑,象征密钥泄露风险

四、令牌放在哪儿,关系到能不能被偷

很多前端项目图方便,把令牌塞进 localStorage,脚本一行就能读走,一旦页面里有个第三方脚本或者一个没转义的输入点,令牌就等于送人。换成 Cookie 也不是万灵药,Cookie 会被浏览器自动带上去,CSRF 的问题就来了。现在比较常见的折中是:访问令牌放内存里(刷新页面就没了,配合刷新令牌重新换),刷新令牌放 HttpOnly 加 Secure 的 Cookie 里,同时限制同一个刷新令牌只能换一次,换过之后旧的立刻作废。这样即使页面被注入了脚本,能偷到的也只是短命的访问令牌,损失可控。

还有个容易忽略的点:日志里别打完整令牌。我们排障的时候在网关访问日志里看到过完整的 Authorization 头,那份日志的读取权限比生产库还宽,等于令牌有效期一过之前,谁看过日志谁就能用这个身份。

五、我们最后改成的样子

整理成一份可以直接抄的清单,我们照着改了一遍,改完之后自己再按文章开头的方式造了一个假令牌,接口老老实实返回了 401。

1. 算法白名单写死在服务端配置里,头部里的 alg 只用来比对、不用来决策;

2. 拒绝 none,拒绝非对称算法降级成对称算法;

3. kid 只查内存映射表,不拼路径、不发请求;

4. 密钥用密码学安全随机数生成,长度足够,轮换时保留上一把让旧令牌自然过期;

5. 校验过期时间、签发方、受众,服务器时间做同步;

6. 访问令牌短有效期,刷新令牌一次性轮换;

7. 登出时把刷新令牌作废,服务端再对访问令牌加一层短时黑名单;

8. 全程 HTTPS,令牌走请求头,页面里不落地;

9. 令牌在日志、监控、异常上报里统一打码;

10. 上线前自己伪造一次令牌做回归测试,别等别人来试。

调试这类接口的时候,我一般会用 Postman 这类接口调试工具把令牌单独拎出来看把头部、载荷、签名三段拆开逐段对比,比在日志里看一整串 Base64 直观得多,也更容易发现「哪一段被人改过」。

浏览器与手机终端之间的加密会话连线示意登录态流转

回头看,那次自测其实花了一个下午,但它暴露出来的问题比线上跑了两个月的业务日志还多。签名不是安全本身,验签的那段代码才是。令牌是三段 base64,真正挡住伪造的,是服务端对算法、密钥、时效这三件事的较真。

玩家留言 跟帖评论
查看更多评论
相关文章
猜你喜欢
同类下载