JVM线程转储脱敏:在不破坏分析的前提下屏蔽敏感信息
几周前,我在 r/java 上发了一篇帖子,主题是如何从一份普通线程转储中检测虚拟线程钉住(pinning)问题。技术讨论本身没什么问题。真正让我记住的评论与钉住无关:“问题不在于生成转储,而在于把生产转储上传到第三方网站是违反规定的。”这不是偏好问题,而是政策规定。而且说这话的人是对的。
我在运营 ThreadMine——一个在线托管的线程转储分析器,所以那条评论描述的正是我自己的产品。本文介绍我为此构建的方案:一个开源的小型 CLI,它在转储文件离开你机器之前、于本地重写文件,同时让分析功能保持可用。它叫 tm-anon,采用 MIT 协议,并且可以与任何分析器配合使用,包括我自己的。有意思的不是工具本身,而是那个约束条件:你不能把所有内容都剥掉,因为分析器需要其中绝大部分信息。
线程转储真正会泄露什么
从一份真实转储中取一行:
包名、类名、方法名、源文件名。再乘以几百个栈帧,你就得到了一张代码库地图:模块名、你对接的供应商(com.acme.payment.gateway.AcquirerClient)、系统哪些部分在和哪些部分通信。线程名更糟,因为人们会把各种信息塞进去。pgto-worker-3 没有危害;但 sync-tenant-acme-prod 或按请求路径命名的线程,就是躺在文本文件里的客户标识。
有两种方言比经典 jstack 输出泄露更多。OpenJ9 的 javacore 会携带完整命令行及其 -D 属性、完整类路径、本地文件路径、列出你类名的监视器表,以及已加载类列表。jcmd Thread.dump_to_file 的 JSON 方言(JDK 21+)在每个线程组下有一个 container 字段,保存拥有该线程组的执行器或 StructuredTaskScope 的 toString(),例如 com.acme.batch.LedgerScope@4f2b1a。这是少数几个你的类会在栈帧之外自报名字的地方;如果只从栈帧角度思考,很容易漏掉它。
为什么“全部打码”行不通
线程转储分析器——无论是哪一款——都不读你的代码。它读的是结构和公开名称。那些关键检测器依赖 java.util.concurrent.ThreadPoolExecutor.getTask 区分空闲 worker 和卡住的 worker,依赖 http-nio-8080-exec- 与 ForkJoinPool-1-worker- 对线程池线程分组,依赖锁地址构建死锁图,依赖线程状态,还依赖线程池线程的 -N 后缀统计共享同一前缀的 worker 数量。若把这些替换成不透明 token,饥饿检测、线程池分组、死锁分析会立刻全部失效。
因此,在阅读检测器代码后得出的规则很简短:JDK 或知名框架放进转储里的内容逐字节保留;属于你自己的内容全部变成 token。允许原样保留的清单(allowlist)是仓库中的一个 JSON 文件,每一类条目旁都写着理由,这样评审者可以不同意某一条目,然后重新构建并发布。这份清单就是整个设计的核心,其余只是配套工程。
名字如何变成 token
每个标识符都用一个 256 位密钥做 HMAC-SHA256 哈希;密钥存放在本地 vault 文件中,并取哈希前 40 位作为 token。相同密钥、相同名字,在任何转储中都会得到同一个 token,而且永远如此。这个特性让打码后的多转储比较仍然有效:周一观察的线程,周二仍是同一个 token。
使用带密钥的 HMAC 而不是普通哈希,不是风格选择。Java 标识符是可以被猜测的。SHA-256("com.acme.OrderService") 用字典攻击几秒就能破解,因为攻击者可以哈希候选名并逐一比对。换成带密钥的哈希后,不持有 vault 密钥的人连验证猜测都做不到。vault 还保存反向映射,tm-anon init --encrypt 会用口令将其封存(PBKDF2,60 万次迭代,然后 AES-256-GCM,两者都直接来自 JDK)。因此,即使笔记本镜像被偷,没有口令也毫无用处。
token 语法带有形态:包片段是 p...x...,类是 C...x...,方法是 m...x...,线程是 t...x...。之所以选这种形态,是为了兼容分析器自身的正则表达式。每个包片段都生成独立 token,因此 package.Class.method( 仍然可解析,火焰图也仍能按包分组。线程池线程的数字后缀会保留,于是 pgto-worker-1 变成 t426f3xd05a4-1,分析器仍能看到一个有 N 个 worker 的线程池。中间的 x 让 token 永远不会看起来像十六进制地址或请求 ID——而这两种形状确实是某个检测器会识别的内容。
使用流程:以一个从未运行过它的人的角度
你为每个项目创建一次 vault:
然后对转储打码。这会在原文件旁边生成一个新文件:
verify 这一行正是我会让评审者关注的地方。在写入任何内容之前,mask 会把自己的输出交给一个独立的验证器;验证器不是信任重写器,而是从打码后的文本中重新推导每个标识符。只要还有任何不是 token、不是允许列表中的名字、也不是纯结构的内容存活,它就拒绝写入文件。因此,半打码的转储不可能意外离开机器——因为它永远不会被写出来。
同一个栈帧在打码前后各是什么样子:
JDK 栈帧、锁地址、线程状态、cpu= 和 tid= 字段以及空行都原样保留,因为空行是分隔线程的界线;分析器一旦丢失空行,就会丢失线程数量。分析器本来就会忽略、但会顺带泄露名字的行,比如 Locked ownable synchronizers,会被移除,并替换成标记,以免结构发生位移。任何没有规则能识别的行都会被脱敏处理,绝不会原样放行。
你上传 .anon.txt,运行分析,下载报告,然后在本地还原名字:
unmask 把输入当作不透明文本处理,并重写它找到的每一个 token,因此它既能处理 HTML 报告、导出 JSON、CSV,也能处理 LLM 针对你的转储写出的段落。token 在普通叙述文本中同样会被还原回来,这也是语法故意设计得很有辨识度的原因:“瓶颈是 Cfcbfdx33dfc”会变回“瓶颈是 LedgerService”。
打码之后,分析依然有效吗
这正是我在 Reddit 上回复时无法回答的问题,所以我实际测量了一下。我取出测试语料库中的转储(当时有 17 份),把每一份分别以原始版和打码版运行分析器,再比较输出:每个样例检测到的问题集合相同、严重程度相同、健康评分也相同。有两个边界情况表现不同,我把它们写在了 README 中,而不是藏起来。例如,某个应用层栈帧今天碰巧匹配到一条检测模式(像 Consumer.receive 这样没有包名的片段),一旦 token 化就不再匹配。因此,诚实的说法是“分析等价”,不是“字节级一致”。
语料库也让这个工具在用户报告之前,先抓到了自己最严重的一个 bug。JDK 24 改变了 jcmd Thread.dump_to_file 的文本方言:锁行的写法与旧版 JDK 不一样了。过去“尖括号里的内容是地址,原样保留”这条规则,在我看过的每一份转储上都成立,却在最新 JDK 上悄悄把一个类名原样保留了下来。这个问题是在为该方言构建测试语料时暴露的;现在有一个测试会对每个固定样例执行打码,再对结果运行验证器,因此它不可能不被察觉地卷土重来。我宁愿讲出这个故事,也不愿假装第一版就是对的。
安全团队实际可以验证什么
“我们保证这个工具不会上传任何内容”这句话,对当初禁止上传的那些人来说一文不值。他们能采纳的是可以在一下午之内验证的断言,因此仓库围绕三个此类断言来设计。
jar 没有网络代码,并且有一个测试强制保证这一点:测试会扫描生产源码、编译后的字节码常量池和 pom.xml,查找 java.net、socket channel、javax.net、jdk.net 以及任何 HTTP 依赖,只要发现一个就直接让构建失败。整个项目只有一个依赖,即测试作用域下的 JUnit,因此没有传递性供应链需要审计。至于决定哪些内容原样保留的 allowlist,是一个可读的 JSON 文件,而不是埋在代码里的启发式逻辑。
威胁模型文档写的是“这里列出该工具不能保护你免受什么”。打码后的转储仍然会透露你使用了哪些框架、线程池的形状、包树的深度;用同一个 vault 打码的不同转储,按设计可以相互关联。任何持有 vault 文件的人都能还原一切。这是可由所有者逆转的假名化(pseudonymization),不是不可逆的匿名化;文档中明确写了这一点,因为否则安全评审员也会自己发现。
当前进展
0.4.0 版本能处理整个 HotSpot 家族(JDK 8 到 25 的 jstack、jcmd Thread.print、Thread.dump_to_file 的文本与 JSON 两种形式、ThreadMXBean/VisualVM 输出、虚拟线程);对 OpenJ9 javacore 采用剥离模式——整段移除,而不是逐行打码;也支持 Azul Zing 和 GraalVM native-image 转储。凡是无法分类的内容都会被拒绝,而不是产出半打码结果。发布形式包括一个需要 Java 21 的 jar,以及 Linux、macOS、Windows 原生二进制文件,方便那些不想在保存转储的机器上安装 JVM 的人。
再次明确披露:我是 ThreadMine 的创始人,这个工具就是为了给该分析器提供输入而构建的;介绍两者如何配合的页面在这里。CLI 本身位于 github.com/masiochjv/threadmine-anonymizer,MIT 协议,可与任意分析器配合使用。如果你的政策仍然连打码后的转储也不允许上传,我真的很想知道需要达成什么条件;r/java 上那个讨论串是这件事的起点,至今仍是我在这个问题上得到过的最好反馈。
本文《Masking a JVM thread dump without breaking the analysis》最初发表于 foojay。