静默 HMAC 密钥污染:揭示 Burp JWT Editor 扩展中的逻辑缺陷
Burp 的 JWT Editor 扩展里一个静默 bug 让靶场怎么都打不通,作者花十五小时追到密钥被污染的真正原因,最后把它变成了上游仓库里的一个 issue。
中文
复制

一个失败的 Web Security Academy 靶场,如何引出一场针对隐藏 bug 的根因分析
JWT Editor 入围了 PortSwigger 2026 年 Burp Suite 扩展大奖的 “最佳认证与访问控制” 奖项。而下面这个故事,讲的是如何在它内部挖出一个 静默 bug。
事情出问题时,人的第一反应通常是先看自己:我哪里做错了?我漏了哪一步?要走到认真怀疑“错的根本不是我,而是工具”这一步,需要跨过不少心理障碍。这有点像开发者坚称自己的代码坏了,是因为 VS Code 本身有问题。尤其当身边所有人都说不是工具的错,你自己的眼睛看到的也和他们说的一样时,更是如此。但有些老话,该信还是得信:
排除掉所有不可能之后,剩下的无论多么难以置信,那都是真相。
- 阿瑟·柯南·道尔(夏洛克·福尔摩斯)
下面这个故事,讲的是一个“本不该失败”的训练靶场,如何演变成一场 十五个小时 的调查、一个静默 bug,以及一个针对截至今日 Burp Suite BApp Store 中 人气排名第一的扩展 提交的 GitHub issue。

JWT Editor 在 BApp Store 中人气排名第一
一些背景:什么是 JWT 算法混淆?
在进入故事之前,先补一下背后的理论。JWT 算法混淆是一类漏洞,根源在于某些后端库实现令牌校验的方式。有些实现会写出这样的代码:
publicKey = ;
token = request.getCookie("session");
verify(token, publicKey);
问题在于:如果服务器收到的令牌是用 HS256 这类 对称 算法签名的,而不是它预期的非对称算法 RS256,那么某些库中那个通用的 verify() 方法会毫不犹豫地把公钥(按定义就是公开的、任何人都知道的)当作 HMAC 密钥来用。攻击者只要拿到这个公钥,就能用它以 HS256 签出自己的令牌,而服务器会照单全收。
如果你想看完整的技术剖析,PortSwigger 自己关于算法混淆的这篇文章讲得很到位。我在这里只做个概述,因为这正是我掉进这个坑的全部原因。
实验环境,以及最初 50 分钟的自我怀疑
理论看完之后,我转向了实操实验:JWT authentication bypass via algorithm confusion。
预期解法很直接:从实验环境的 /jwks.json 端点拿到暴露的公钥,转成 PEM,做 Base64 编码,拿它当 HMAC 密钥,把头部的 alg 从 RS256 改成 HS256,把 payload 里的 sub 从 wiener 改成 administrator,给伪造的 token 签名,登录,然后删掉 carlos 的账号。实验完成。
但并没有。我每一步都严格照做,一直到发送伪造 token 那一步,可它从来没被接受过。无论我重复多少次,都拿不到 administrator 权限。自己折腾了大约 50 分钟后,我猜问题出在最后签名之前,于是打开了官方通关教程。结果和我做的完全一样,一字不差。于是我告诉自己:也许某个步骤的_意图_我理解错了,尽管字面操作是对的。
接着我去翻社区的通关视频。奇怪的地方来了:三四个人,步骤一样,顺序一样,没有任何隐藏技巧。底下的评论全是感谢和确认成功的。那为什么到我这就不行?
到这一步,我本可以关掉实验,标记成“看过解法”,然后走人。但这种事我始终过不去。“它就是不行”对我来说从来不算一个能接受的解释。东西不工作,一定有原因,只是你还没找到。
排除显而易见的可能:逐个隔离变量
于是我决定换个更有纪律的做法:不靠猜,一次只隔离一个变量。
第一个变量:这个实验实例是不是坏了?
我关掉实验,等了大约 15 分钟让它断开。如果你用过 PortSwigger Academy 的实验,就知道它们不是静态的;内部状态(包括 /jwks.json 处的公钥)会在新实例上重新生成,甚至你已经用过的凭据过一阵子也会失效,逼你重做前面的步骤才能回到原来的位置。
短暂休息、喝了一杯黑咖啡之后,我开了一个全新的实例,把每一步都仔细重做了一遍。还是失败。好吧,这就排除了“这个特定实验实例出了问题”的可能。问题不在实验环境。
第二个变量:暴露出来的公钥本身是不是错的?
我想,也许 PortSwigger 最近改了实验在 /jwks.json 暴露密钥的方式,而“官方”做法已经过时了。于是我决定换一条完全不同的路子解这个实验,用一个独立的工具:rsa_sign2n(jwt_forgery.py 的简化 fork),通过 Docker 运行:
docker run --rm -it portswigger/sig2n
这个工具接收两个用同一把 RSA 密钥签名的 JWT,从中推导出密钥材料,输出:
- 一个 Base64 编码的 PEM 密钥,同时提供 X.509 和 PKCS1 两种格式。
- 用推导出的每把密钥各签一个伪造 JWT。
我知道这个实验用的是 X.509 格式的密钥,于是复制了该格式的公钥,进入 JWT Editor。新建一个 Symmetric Key,把值粘贴到 k 参数里,保存,把 wiener 改成 administrator,把头部的 alg 换成 HS256,签名,发送请求。
成功了。没有 unauthorized 响应。伪造的 token 被接受了。carlos 被删掉了。实验通过。
那次改变一切的对比
现在我手里有了实打实的东西:一把_能用_的公钥(来自 rsa_sign2n),和一把_不能用_的公钥(按官方步骤得到的)。为了确认,我又把原来的方法跑了一遍,不出所料,还是失败。
把两个 Base64 字符串并排放在一起看,它们之间确实有肉眼可见的差异。我最初的猜测是实验的 /jwks.json 端点提供的密钥本身就是坏的。但当我把它们都 Base64 解码回 PEM 格式:
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA..
它们_一模一样_。同样的字符,同样的顺序,毫无差别。我最初的猜测错了。但两者之间显然还是_有_什么东西不一样:某种在 Burp 界面里看不见、人眼捕捉不到的东西。
我嘴角微微上扬,打开 Burp Comparer,把两个 PEM 版本(还没做 Base64 编码之前的)都粘进去,切到 Hex 视图。大概四个小时之后,差异终于现了形,清清楚楚,无可辩驳。

Burp Comparer 中并排的十六进制对比,高亮显示 rsa_sign2n(正常)与 JWT Editor(出错)之间多出的 0D 字节
两者之中有一个塞满了多余的字节0D。回到 Decoder 的 Hex 视图,我看到0D在每一行的末尾都恰好出现一次。
注意:0D是回车符(Carriage Return,\r)的十六进制值,而0A是换行符(Line Feed,\n)的十六进制值。
插一段:为什么不同操作系统的换行符会不一样?
说来有点好笑:一个 2026 年才出现的 bug,根源却是几十年前为某款硬件做出的决定,而那款硬件在我们大多数人出生之前就已经停产了。
反向逆向工程
找到原因之后,我平静了不少。喝完咖啡,出门透气大约半小时(连续坐了将近四个小时,身体都僵了),回来时脑子清醒了许多。
我回到实验室,但这次换了个身份:我已经知道问题_是什么_,现在只需要逆向追查它_究竟在哪里_被引入,一直回溯到我把公钥粘贴进 Burp Decoder、还没做任何 Base64 编码的那一刻。
第一步: 直接从/jwks.json复制密钥,粘贴进 Decoder 的 Hex 视图。结果:没有多余字节。
第二步: 用 JWT Editor 把同一把密钥转成 PEM,再把_它_粘贴进 Decoder 的 Hex 视图。结果:字节出现了。
罪魁祸首是 JWT Editor。但污染发生在复制时,还是粘贴时?答案出人意料:都不是。它发生在任何复制粘贴_之前_。回到 JWT Editor,生成一把全新的随机 RSA 密钥,再转成 PEM——那些字节已经自动在那里了。我用最简单的方式确认了这一点:把光标放到任意一行末尾,按 Backspace。第一次按下并没有删掉可见字符,要按第二次才行。那第一次看不见的删除,就是隐藏的\r。
为了确认现有的复制方式都没有把 \r 剥掉,我把它们挨个试了一遍:Ctrl+C、右键复制,以及扩展自带的 “Copy Public Key as PEM” 按钮。粘进 Decoder 之后,结果完全一致:多出来的那 0D 个字节始终都在。

把 JWT Editor 的 RSA 密钥转成 PEM 时多出 0D 字节的端到端演示
进入源码
我去了 BApp Store 上这个扩展的页面,顺着链接找到 PortSwigger 的 Github 仓库,再追到维护者自己的仓库。
最后定位到了出问题的那个文件:PEMUtils.java。
相关方法:
public static String pemObjectToString(PemObject pemObject) throws IOException {
StringWriter stringWriter = new StringWriter();
PemWriter pemWriter = new PemWriter(stringWriter);
pemWriter.writeObject(pemObject);
pemWriter.close();
stringWriter.close();
return stringWriter.toString();
}
- 环境说明: 我发现这个问题时所用的环境是:Windows 11 Pro,版本 25H2(OS Build 26200.8875)、Burp Suite Community Edition v2026.7.2、JWT Editor 2.6.1(2026 年 4 月 23 日更新)。特意写出来,是因为 Burp 和它的扩展更新都很频繁;这反映的是_发现该 bug 当时_的状态,未必是你读到这篇文章时的版本。
修复本身很小,风险也低:
public static String pemObjectToString(PemObject pemObject) throws IOException {
StringWriter stringWriter = new StringWriter();
PemWriter pemWriter = new PemWriter(stringWriter);
pemWriter.writeObject(pemObject);
pemWriter.close();
stringWriter.close();
// Normalize line endings for consistent cross-platform output
}
值得一提的是,这一行在所有平台上都完全安全。.replace() 只在匹配到内容时才起作用。在 Linux 和 macOS 上,输出本来就只有 \n,没有东西可替换,字符串原样返回。
为什么一直没人发现
做 Web 安全和漏洞赏金的人大多用 Linux 或 macOS,而不是原生 Windows——不管是渗透测试发行版、WSL 还是 Mac。在这两种系统上,这个 bug 根本不存在,因为它们的换行符本来就是 \n。我看过的那些解题视频,作者用的都是 JWT Editor 扩展,评论区还有人感谢他们给出了一个“直接就能跑通”的演示——这些人几乎可以肯定都来自 Linux 或 macOS。就算有人在 Windows 上没做出来,多半也会以为只是靶场的问题或者自己哪里弄错了。即便真意识到是个 bug,也不会为了一个简单的靶场花几个小时去查,何况问题还这么隐蔽,大概率就直接跳过了。
手动绕过的方法确实存在,但是……
有个很简单的绕过办法:把导出的 PEM 粘贴到外部文本编辑器里再复制回来,\r 就会被去掉,因为大多数标准文本应用在这一来一回中不会保留它。但除非你已经怀疑这个 bug 存在,否则没有任何理由把密钥倒腾到外部编辑器里走一圈。
提交报告
在确认了根本原因、有了可用的修复方案和可复现的演示之后,我整理了一份完整报告,直接在主维护者的仓库上开了 issue #248。
从第一次在实验环境里动手,到提交这个 issue,前后大约花了 十个小时 的专注工作,还不算中间那些短暂的休息。
自己动手验证修复
等维护者回复期间,我决定不让修复停留在纸面上。2026 年 8 月 7 日,我把原扩展的 JAR 文件载入 Recaf——一个可以直接编辑已编译 Java 字节码的工具——把报告里那一行修复直接应用到编译后的 class 上。
打好补丁后,我把 JAR 导出为一个单独的测试构建(重命名为 “JWT Editor (Test Build)”,避免和原版混淆),作为新扩展载入 Burp。成功了:生成新的 RSA 密钥并转换为 PEM 时不再多出 0D 字节,扩展的其他功能也完全正常。

在 JWT Editor (Test Build) 中验证修复:生成的密钥在 Decoder Hex 视图中不含 0D 字节
确认修复有效后,我向主维护者的仓库提交了 pull request #249,内容就是同样的那一行改动,方便审查并合入官方代码库。
另外,我在 Kali Linux 上测试了这个打过补丁的构建,确认 LF 规范化在各种环境下都能正常工作,绝不会让 macOS 或 Linux 用户用不了这个扩展。
在 Recaf 里改字节码、跑测试构建、整理 pull request,这些加起来又花了我 五个小时。
维护者的首次回复与讨论
2026 年 8 月 21 日,我收到了维护者的首次回复。经过一番详细讨论,情况如下:
- A) 如果完全照文档操作,官方实验环境对 Windows 用户依然是坏的。维护者拒绝合并我的 pull request #249,现在我也认同他的理由(详见下文)。
- B) 工具里的 HMAC Key Confusion 攻击功能后来做了改进,现在能正确处理这种场景,Windows 用户也不例外。这条路径做实验更可靠(同样见下文)。
维护者和我最终达成一致:这其实不算扩展本身的 bug。“Copy as PEM” 本就是一个通用的导出功能,因此按平台使用不同的换行符是合理的。它当初并不是为“把导出的文本当作原始密钥字节”这种工作流设计的,而这次攻击恰恰就是这么用的。PR 被拒的原因就在这里。
更好、更新的实验与攻击方法
维护者后来把对这种场景的支持直接做进了 HMAC Key Confusion 攻击功能,随 2026 年 9 月 4 日发布的 2.6.2 版本一起上线。

JWT Editor 2.6.2 release
更新后更可靠的操作流程是:
- 访问
/jwks.json端点并复制 JSON - 在 JWT Editor 中点击 “Import JWK Set”(右下角)。
- 粘贴 JSON 并点击 Import。
- 在 Repeater 中拿到一个有效的 JWT,然后编辑
sub字段。 - 点击 Attack,选择 “HMAC Key Confusion.”
- 选中你刚导入的密钥。
- 根据后端服务器的平台及其 PEM 密钥的存储方式,从四种换行符选项中选一个:
- Linux/macOS (
0x0A) - Linux/macOS (
0x0A) – 末尾无换行 - Windows (
0x0D0A) - Windows (
0x0D0A) – 末尾无换行
(现实世界中的后端大多跑在 Linux 上,PortSwigger 的靶场也是如此,所以通常应该从第一个选项入手。话虽如此,即便后端是 Linux,如果第一个选项不奏效,也值得再试试另一个 Linux/macOS 选项,因为并非每台服务器处理 PEM 密钥的方式都完全一致。)
- 点击 OK。此时伪造的 token 已经有了合法签名。

JWT Editor 2.6.2 版本中的 HMAC Key Confusion 攻击对话框
- 重要提示: 如果你在右下角找不到 “Import JWK Set” 按钮,请确认 Burp Suite 中已启用 Scaling 选项,并把 Burp 的显示字号保持在 18 以下。

Burp Suite 的缩放选项
这一点很关键,因为字号过大,或者 Windows 显示设置里的缩放比例高于 100%,都会导致 “Import JWK Set” 按钮在 Burp Suite 界面中被截断,如下图所示。

Burp Suite 界面中被截断的 “Import JWK Set” 按钮
最后还有一点值得一提: 我刚开始追这个问题时,进入网络安全领域才 83 天。而我会去追,只是因为“它发生是因为它发生”这种说法从来没法让我满意。
联系 PortSwigger
写这篇文章时我前面没提,但一碰到这个 bug,我就给 PortSwigger 发了邮件,地址是 support@portswigger.net。他们回复后把我引向了维护者的仓库,而我在前一天就已经在那里提交了 Issue #248。可即便事情已经解决,这个 bug 依然存在于靶场的官方解法以及 PortSwigger 网站上面向 Windows 用户的理论说明中,于是我又发了第二封邮件。以下是他们的回复:

PortSwigger 对后续邮件的回复
如果他们那边有任何改动,无论是理论说明、靶场官方解法,还是后续发给我的邮件,我都会相应更新这篇文章。
时间线
- 2026 年 8 月 3 日: 就该漏洞向 PortSwigger 发送了第一封邮件。
- 2026 年 8 月 3 日: 在实验环境中逐一隔离变量,定位到根本原因。
- 2026 年 8 月 3 日: 向维护者的仓库提交了 GitHub Issue #248,并附上详细的 PoC。
- 2026 年 8 月 4 日: PortSwigger 回复,让我去找维护者的仓库——可前一天我已经在那里开了 Issue #248。
- 2026 年 8 月 7 日: 用 Recaf 在本地修补字节码,在自建的测试版本上验证修复。
- 2026 年 8 月 7 日: 提交了 pull request #249,包含跨平台换行符归一化的修复。
- 2026 年 8 月 21 日: 收到维护者的首次回复。
- 2026 年 8 月 21 日 — 9 月 1 日: 与维护者讨论。
- 2026 年 9 月 4 日: JWT Editor 2.6.2 发布。
- 2026 年 9 月 4 日: 扩展更新后再次致信 PortSwigger,指出实验题解法和文档仍未更新。
- 2026 年 9 月 7 日: 收到 PortSwigger 对跟进邮件的回复。
静默的 bug 不会自己报警,你得掀开引擎盖去看。
来源: HackerNoon← 返回首页