JDK 21 虚拟线程模型下 Web 应用会话上下文串扰漏洞的挖掘与利用
JDK 21 将虚拟线程(Virtual Thread,JEP 444)从预览转为正式特性,Tomcat 10.1.10 之后、Jetty 12、Spring Boot 3.2 都提供了一行配置切换到虚拟线程执行器的能力。对长期以 thread-per-request 模型构建的 Java Web 应用而言,这是十余年来线程模型层面最显著的一次改动。虚拟线程承诺“编程模型不变、吞吐显著提升”,但它隐式地打破了一个长期默认成立的契约——“线程在一次请求生命周期内拥有稳定且唯一的身份”。围绕该契约写下的框架代码、自研中间件、日志组件、多租户隔离层,在切换执行器后会出现一类此前不存在的越权路径:两个本应隔离的请求,在同一时间窗内读到了对方的会话上下文。
一、根因:虚拟线程的默认名称是空串
平台线程时代,Thread.getName() 几乎不会为空——http-nio-8080-exec-1、ForkJoinPool.commonPool-worker-3 这些名字由线程工厂在创建时写入。
JDK 21 的虚拟线程不同:Thread.ofVirtual() 与 Executors.newVirtualThreadPerTaskExecutor() 创建的虚拟线程,默认名称是空字符串 ""(除非显式调用 name(...))。Tomcat 切到虚拟线程模式后内部直接使用 newVirtualThreadPerTaskExecutor(),不为线程命名;Spring Boot 3.2 在 spring.threads.virtual.enabled=true 下适配的也是这种空名形态。
一个关键的对照事实:Thread.threadId()(JDK 19 引入,取代 getId())在虚拟线程下仍保证进程内单调递增、互不重复;但 64 个并发未命名虚拟线程的 getName() 只有一个唯一值(空串)。这直接决定了:以 getName() 为键的隔离表,在虚拟线程下会退化为全局共享。
二、为什么会“串”:代码把线程名当隔离键
历史上 MDC 实现、部分 APM 探针、自研多租户中间件大量出现这样的写法:用 Thread.currentThread().getName() 作为键,在一个共享 Map 中存取“当前请求/租户”的上下文。平台线程时代,线程名唯一且与请求一一对应,这等价于“按线程隔离”;虚拟线程时代,所有未命名虚拟线程的 getName() 都返回同一个 "",这段代码就退化为“所有虚拟线程共享同一条目”。
串扰的典型时序(概念描述):
- 平台线程接收请求,提取租户标识(如
X-Tenant头); - 创建一个未命名虚拟线程(名为
""),在其中把租户身份“绑定”到以线程名为键的共享表; - 虚拟线程因模拟 I/O 而挂起,CPU 调度其他虚拟线程;
- 另一个虚拟线程执行绑定,覆盖了同一个键(
"")的值; - 原虚拟线程唤醒后读取上下文,拿到的竟是被覆盖后的值——即别人的租户身份。
并发压测下,这种“请求声称身份”与“实际返回身份”不一致的比例会稳定在一个可观区间。把虚拟线程逻辑移除、让业务直接跑在平台工作线程上(线程名形如 http-nio-8080-exec-N 唯一),串扰窗口随即关闭——这排除了“业务代码本身写错”,把根因锁定在“使用了未命名虚拟线程且以其名称为隔离键”这一事实上。
三、隐蔽性:日志里看不出来
一个容易被忽视的危险点是:这类串扰在访问日志中几乎不留痕迹。每一行都可能是正常的 200 状态码、正常大小的响应体、正常延迟,没有任何字段会暴露“该请求返回了别人租户的数据”。即便把租户头加入日志 pattern,日志里记录的也只是“请求声称的身份”而非“实际返回的身份”,二者不一致这一事实仍然无从体现。
从攻击面看,若企业内部接口常在响应中下发短期凭据(预签 URL、临时 STS Token、会话级 CSRF Token、服务签名),捕获到这些凭据就可能在有效期内以受害者身份继续请求,把“读越权”升级为“写越权”——整条链路不依赖任何客户端漏洞,全由服务端单方完成。
四、修复方案
- 不要把线程名当隔离键。最直接的是改用正确的机制传递上下文:优先使用
ThreadLocal(虚拟线程下仍按线程正确隔离)或ScopedValue(JDK 为新并发模型提供的与虚拟线程友好的上下文载体),而非“共享表 + 线程名”的手工方案。若必须区分线程,用threadId()而非getName()。 - 强制为虚拟线程命名。若保留以名称为键的旧代码,最小变更是在创建虚拟线程时显式指定递增名称前缀,使每个线程拥有如
order-vt-0/1/2的唯一名称,重新拉开隔离粒度。但这只是“恢复旧假设”的兼容手段,新代码仍应回到 ThreadLocal/ScopedValue。 - 静态扫描接入 CI。为防止历史代码与未来引入的第三方依赖再次踩中这一模式,可在 CI 加入轻量字节码扫描,定位“先调用
Thread.getName()、紧接着把结果用作Map.put/get/remove键”的可疑路径(用指令间距阈值控制误关联),命中后由人工二次确认,存在命中即令流水线失败。
五、结语
虚拟线程没有在 JVM 层面破坏任何既有的线程安全语义:ThreadLocal 仍按线程隔离,synchronized 仍按对象互斥,类加载器、模块系统、安全管理器一律未变。位移发生在开发者长期以来基于“线程名稳定唯一”这一隐含假设所写的代码。换句话说,这不是一个“新漏洞类型”,而是一个“旧假设失效”——升级执行器前,应专项排查一切把线程身份(尤其 Thread.getName())当作隔离/路由键的逻辑,并用真正与虚拟线程兼容的上下文传递机制重写它们。
本文内容仅用于安全研究与工程加固之目的,所涉实验均在本地自有环境完成。请在授权范围内开展测试,遵守《中华人民共和国网络安全法》及相关法律法规。