文章总结: 本文为BlackHatUSA2026议题笔记,聚焦macOS中GCD与XPC竞态条件检测。核心观点是队列配置错误可放大为权限提升漏洞,而非仅依赖危险API搜索。文章系统分析了GCD抽象、QoS影响、优先级反转、dispatch_sync死锁、线程池饱和及XPC目标队列遗漏等风险,并给出静态审查规则与工程缓解建议,强调显式设置串行队列、避免锁内阻塞I/O及建立队列标签审计。
综合评分: 88
文章分类: 代码审计,漏洞分析,红队,安全工具
Black Hat USA 2026:GCD与XPC竞态检测
原创
Max Luo
Max Luo
白帽子罗棋琛
2026年9月24日 08:18
中国香港
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
当队列成为漏洞:逆向 GCD、XPC 竞态与 macOS 检测工程
Black Hat USA 2026 议题笔记:When Queues Become Vulnerabilities: Reverse Engineering GCD, XPC Races, and macOS Detection Engineering
macOS 安全审计常把注意力放在签名、Entitlement、Sandbox 和 XPC 客户端身份校验上,却容易忽略另一个决定性条件:同一服务的两个消息处理器,究竟能不能同时运行。
Grand Central Dispatch(GCD)把任务调度抽象成队列,让开发者不必直接管理每一条线程。抽象降低了开发成本,也会隐藏执行顺序。一段代码在单线程测试中始终正确,不代表它在并发队列、QoS 变化、线程池饱和或同步回调下仍然正确。对特权 XPC 服务而言,一次错误的队列配置可能把普通数据竞争放大成权限提升。
图 1:课件从 macOS 竞态条件检测切入 GCD、XPC 和行为检测
这类问题不能只靠搜索危险 API,也不能只靠崩溃告警。有效的工程方法需要同时覆盖静态审查、运行时遥测、并发压力测试和架构约束。
1、进程不是执行单位,线程与队列才决定顺序
进程提供虚拟地址空间、文件描述符和 Mach Port 等共享资源,真正被调度的是线程。GCD 再在应用与线程之间加入一层队列:代码提交的是 Block/Task,系统决定由哪个工作线程执行。
图 2:Dispatch Queue 保存任务,工作线程从队列中取出并执行
这层抽象容易造成三个误判:
- 创建了两个队列,不等于系统一定创建两条线程;
- 任务按顺序提交,不等于它们按顺序完成;
- Handler 写在同一个函数里,不等于同一时刻只有一个实例在运行。
课件用自动售货机说明并发:两个队列可以交错使用同一台机器;并行则是两台机器同时服务。竞态的本质不是“速度快”,而是多个合法操作的交错顺序破坏了状态不变量。
图 3:两个读—改—写序列发生交错,最终结果取决于调度顺序
例如下面的计数器在语法上没有问题,但 value += 1 并非原子操作:
swift
finalclassUnsafeCounter { private(set)var value =0funcincrement() { value +=1 } } let counter =UnsafeCounter() DispatchQueue.concurrentPerform(iterations: 10_000) { _in counter.increment() } print(counter.value) // 不保证等于 10000
安全审计要寻找的是跨 Handler 共享的状态:全局字典、对象生命周期、临时文件、授权结果缓存、Mach Port、数据库事务和“先检查后使用”的文件路径。只要状态被多个并发入口访问,就必须明确串行化或同步策略。
2、QoS 不只是性能参数,也会改变竞态窗口
GCD 的 Quality of Service(QoS)用于表达任务的重要程度。常见等级包括 userInteractive、userInitiated、utility 和 background,另有 default 与 unspecified。
图 4:不同 QoS 表达 UI、用户触发、长任务和后台任务的调度优先级
很多代码把 QoS 当成“是否省电”的标签。对安全测试而言,它还是操纵交错顺序的变量:
- 提高某条队列的 QoS,可能让“使用”动作抢在“校验结果写回”之前;
- 降低持锁线程的 QoS,可能扩大其他线程等待资源的时间;
- 大量高 QoS 任务会压缩后台清理和状态回收的执行机会;
- 从调用方继承的 QoS,可能让同一段代码在不同入口表现不同。
创建队列时应显式写出用途和 QoS,不要依赖调用上下文猜测:
swift
let stateQueue =DispatchQueue( label: "com.example.agent.state", qos: .utility, attributes: [], autoreleaseFrequency: .workItem, target: nil ) let ioQueue =DispatchQueue( label: "com.example.agent.io", qos: .utility, attributes: .concurrent )
串行 stateQueue 负责维护状态,ioQueue 负责可并行的无状态 I/O。不要为了“提高吞吐”把所有队列改成 .concurrent;如果共享状态仍需要锁,得到的往往只是更复杂的等待图。
3、优先级反转会把短临界区变成长时间阻塞
优先级反转的典型场景包含三条线程:低优先级线程 L 持有锁,高优先级线程 H 等待同一把锁,中优先级线程 M 又持续抢占 L。结果是最高优先级的 H 被优先级更低的 M 间接延迟。
图 5:L 持锁、H 阻塞、M 抢占 L,导致 H 无法按预期获得资源
pthread_mutex 和 os_unfair_lock 能够跟踪锁所有者,内核在部分情况下可以尝试提升所有者优先级。Semaphore、读写锁或自制同步原语未必具备同样的所有者语义,等待方无法把优先级传递给真正需要推进的线程。
图 6:能够识别所有者的锁可能缓解反转,无所有者语义的原语无法保证这一点
课件对“QoS 继承”的措辞是审慎的:它是可能发生的调度补偿,不应被当成正确性的保证。工程上仍需缩短临界区,禁止在锁内做网络、磁盘、XPC 同步调用和用户回调。
下面这种写法尤其危险:
swift
stateLock.lock() defer { stateLock.unlock() } let data =tryData(contentsOf: remoteURL) // 锁内阻塞 I/O state.cache[key] = data notifyClient(data) // 锁内进入外部代码
更稳妥的结构是先在锁外完成慢操作,再用短临界区提交结果,最后在锁外通知:
swift
let data =tryData(contentsOf: remoteURL) stateLock.lock() state.cache[key] = data stateLock.unlock() notifyClient(data)
如果写入前必须确认状态未变化,应引入版本号或不可变快照,而不是把整个 I/O 包进锁里。
4、dispatch_sync 的风险来自等待图,不是 API 本身
同步派发的语义是:调用队列等待目标队列执行完 Block,再继续向下运行。目标队列不同且无环时,这是一种明确的同步点;目标就是当前串行队列时,Block 永远无法开始,形成自等待死锁。
图 7:当前串行队列同步派发给自身时,队列被调用阻塞,目标任务无法启动
最明显的错误是从主线程同步提交到主队列:
swift
funcrefreshUI() { DispatchQueue.main.sync { renderStatus() } }
但真实项目更常见的是间接环:
text
主队列 └─ sync 等待 workerQueue └─ 调用系统或业务 API └─ 回调 sync 等待主队列
代码评审只看单个 sync 调用,很难看出这个环。应为每条串行队列建立稳定 Label,并把同步边记录成图。只要新增边会从目标队列重新走回调用队列,就存在死锁条件。
一个简单的封装可以在 Debug/测试构建中阻止队列自同步:
swift
finalclassCheckedSerialQueue { privatelet queue: DispatchQueueprivatelet key =DispatchSpecificKey<UUID>() privatelet identity =UUID() init(label: String, qos: DispatchQoS= .default) { queue =DispatchQueue(label: label, qos: qos) queue.setSpecific(key: key, value: identity) } funcsync<T>(_work: () throws -> T) rethrows -> T { precondition( DispatchQueue.getSpecific(key: key) != identity, "attempted synchronous dispatch onto the current serial queue" ) returntry queue.sync(execute: work) } }
这个封装只能识别直接自等待,无法发现跨三条队列的环。真正的修复仍应减少同步跨队列调用,并把回调设计为异步结果或结构化并发。
5、线程池饱和会制造稳定的拒绝服务条件
并发队列不等于无限线程。GCD 使用受系统管理的工作线程池。如果向并发队列提交大量会阻塞的任务,工作线程可能全部卡在信号量、同步 I/O 或另一个队列上,真正负责释放资源的任务反而得不到线程。
图 8:大量阻塞型异步任务占满工作线程,其他组件出现资源饥饿
这种故障与普通高 CPU 不同:CPU 可能并不繁忙,线程数和等待数却快速上升;队列延迟持续增长,服务没有立即崩溃,只表现为 XPC 超时、UI 卡住或守护进程无响应。
不要用“提交更多异步任务”解决背压。入口需要显式限流:
swift
actorRequestGate { privatelet limit: Intprivatevar active =0init(limit: Int) { precondition(limit >0) self.limit = limit } functryAcquire() -> Bool { guard active < limit else { returnfalse } active +=1returntrue } funcrelease() { precondition(active >0) active -=1 } }
在 XPC 服务中,超限时应返回明确的忙状态或让客户端退避,而不是无限缓存消息。限额需要按请求成本区分:一个需要解析大型对象、访问 Keychain 或等待网络的请求,不能与一次只读状态查询使用相同并发预算。
6、XPC 目标队列遗漏会让串行假设瞬间失效
历史上的 com.apple.GSSCred 案例说明了队列配置为何是安全边界。CVE-2018-4331 是该特权 XPC 服务中的竞态条件,可导致攻击者在以 root 运行的 GSSCred 进程内执行代码。课件用它说明:预期中的串行消息处理,因为连接没有设置目标队列而落到默认并发队列,多个 Handler 得以同时操作共享状态。
图 9:预期是串行处理,遗漏 Target Queue 后消息被并发执行
这不意味着每个未调用 xpc_connection_set_target_queue() 的服务都可利用。漏洞还取决于 Handler 是否共享可变状态、对象生命周期是否安全、消息能否被低权限客户端并发触发,以及竞态结果是否形成可控的内存破坏或授权绕过。
但对特权服务来说,显式设置队列是必要的可审计约束:
c
staticdispatch_queue_t g_service_queue; staticvoidconfigure_peer(xpc_connection_t peer) { xpc_connection_set_target_queue(peer, g_service_queue); xpc_connection_set_event_handler(peer, ^(xpc_object_t event) { handle_message(peer, event); }); xpc_connection_resume(peer); } intmain(void) { g_service_queue = dispatch_queue_create( "com.example.privileged-xpc.state", DISPATCH_QUEUE_SERIAL ); xpc_main(^(xpc_connection_t peer) { configure_peer(peer); }); }
代码还应在接受连接时校验调用方的 Audit Token、签名要求与 Entitlement。串行队列解决的是并发状态问题,不是身份认证;身份校验也不能修复竞态,两者缺一不可。
7、静态审查要寻找“错误假设”,不只是危险函数
课件归纳了四类静态信号:接受 XPC 连接后没有设置目标队列;在并发环境中假设串行执行;未经说明使用 dispatch_sync;全局可变状态缺少锁或串行队列收口。
图 10:目标队列、串并行语义、同步调用和共享状态是重点审查项
可以把规则分成“必须修复”和“必须解释”两类:
yaml
must_fix:-accepted_xpc_connection_without_target_queue-mutable_global_written_from_multiple_handlers-dispatch_sync_to_statically_identical_serial_queue-client_authorization_split_from_privileged_actionmust_justify:-any_dispatch_sync_in_privileged_service-semaphore_wait_on_global_concurrent_queue-blocking_io_inside_lock-concurrent_handler_accessing_reference_counted_shared_object
正则搜索只能用于初筛。例如:
bash
rg -n \ 'xpc_connection_set_event_handler|xpc_connection_set_target_queue|dispatch_sync|\.sync\s*\{|dispatch_semaphore_wait' \ --glob '*.{c,h,m,mm,swift}' .
审计人员应以“消息入口”为单位向下追踪:连接在哪创建、Target Queue 是什么、Handler 访问了哪些状态、状态保护原语是什么、是否跨队列同步调用、错误路径是否也释放锁或引用。仅统计 dispatch_sync 数量没有安全意义。
8、运行时检测应聚合崩溃、线程抖动和队列积压
竞态未必每次都形成可利用结果。更常见的前兆是偶发崩溃、守护进程重启、线程数尖峰、消息突发和队列排空时间增长。课件建议把这些信号放在一起,而不是只对单次 Crash 告警。
在开发阶段,Xcode 的 Thread Performance Checker 可发现高 QoS 线程等待较低 QoS 线程的情况;Thread Sanitizer 适合发现部分数据竞争,但两者都不是生产检测方案。生产侧应为关键队列埋点:
swift
import OSLog privatelet log =Logger(subsystem: "com.example.agent", category: "queue") funcsubmitTracked( toqueue: DispatchQueue, queueName: String, operation: @escaping@Sendable () -> Void ) { let submitted =ContinuousClock.now queue.async { let delay = submitted.duration(to: .now) log.info("queue_start name=\(queueName, privacy: .public) delay=\(String(describing: delay), privacy: .public)") operation() } }
建议采集以下指标:
queue_submit_total:按队列和消息类型统计提交量;queue_start_delay_ms:从提交到开始执行的延迟;handler_duration_ms:Handler 执行时间;inflight_handlers:并发中的 Handler 数;thread_count与blocked_thread_count;- 特权 XPC 服务的 Crash、Jetsam/Watchdog 和重启次数;
- 客户端、消息类型与拒绝/限流结果。
macOS Unified Log 的现场查询可从进程和时间窗口开始:
bash
log show --last 30m \ --style json \ --predicate '(process == "GSSCred") OR (eventMessage CONTAINS[c] "queue_start")'
不同 macOS 版本的日志可见性与字段会变化,企业采集前要确认隐私设置、日志保留和 Endpoint Security 产品能力。不要在日志中写入 XPC 消息正文、票据或用户秘密。
9、行为模型比单一阈值更能识别并发异常
课件最后把行为信号归纳为:同步程度异常升高、并行度偏离预期、限流行为变化、排空时间变长,以及相对基线的偏移。
图 11:同步、并行度、限流、Drain Time 和基线共同描述队列健康度
单一阈值很容易误报。发布新版本、导入大量证书或设备刚启动时,消息量和线程数都可能合法升高。更实用的是窗口关联:
sql
SELECT host_id, process_name, window_start, max(thread_count) AS peak_threads, percentile_cont(0.99) WITHINGROUP ( ORDERBY queue_start_delay_ms ) AS p99_queue_delay, sum(process_restart_count) AS restarts, sum(xpc_request_count) AS requests FROM macos_service_window WHERE window_start >=CURRENT_TIMESTAMP-INTERVAL'1'HOURGROUPBY host_id, process_name, window_start HAVINGmax(thread_count) >2*avg(baseline_thread_count) ANDpercentile_cont(0.99) WITHINGROUP ( ORDERBY queue_start_delay_ms ) >5*avg(baseline_queue_delay_ms);
生产实现需要把当前窗口与同版本、同硬件架构、同工作负载的历史基线连接起来,示例中的 avg(baseline_*) 只是表达检测意图。
对特权 XPC 服务,可以提高以下组合的优先级:
text
同一低权限客户端短时间突发 XPC 消息 + 特权服务并发 Handler 数异常 + 线程数或队列延迟升高 + 服务崩溃/重启 + 重启后同一客户端继续发送
这组信号不能证明竞态利用,但足以触发隔离、保全 Crash Report、采集进程树与客户端签名信息。竞态事件最容易在自动重启后丢失上下文,检测系统要把崩溃前后的窗口拼接起来。
10、把并发安全纳入特权服务的交付门禁
课件最终给出的路线很清楚:静态审查、遥测和行为检测需要同时存在。
图 12:GCD 机制、优先级反转、同步死锁、GSSCred 案例和检测策略构成完整链条
对于 macOS 安全代理、系统扩展配套服务、更新器和其他特权 XPC Daemon,可以设置一组可验收的发布门禁:
- 每个 XPC Listener 和 Peer Connection 都能追溯到显式 Target Queue;
- 每个共享可变状态只有一种同步所有权,不混用多套锁和队列;
- 所有
dispatch_sync都有等待图说明和测试用例; - 锁内不存在网络 I/O、磁盘慢操作、XPC 调用或外部回调;
- 并发入口具备容量上限、超时和客户端退避协议;
- 压力测试覆盖 QoS 变化、随机延迟、重复消息和并发断连;
- Crash、重启、队列延迟、Handler 并行度和限流率可以关联到版本;
- 客户端身份校验与状态变更位于同一不可分割的授权边界内。
并发测试不能只重复正常请求。应主动扩大竞态窗口:在校验与使用之间注入随机延迟,改变不同队列 QoS,限制可用 CPU 核数,制造 I/O 抖动,同时以多个连接重复提交相互冲突的消息。测试目标不是证明某一次运行正确,而是证明关键不变量不依赖调度运气。
原始资料与延伸阅读:
- Black Hat 官方 Session 页面
- Brandon Azad:CVE-2018-4331 GSSCred 竞态分析
- Apple Developer:xpc_connection_set_target_queue
当队列被当作性能工具时,安全问题常被归结为“偶发卡顿”;当它被当作执行顺序和状态所有权的边界时,代码评审才会问对问题:谁能同时进入、谁拥有状态、谁在等待谁,以及攻击者能否稳定操纵这个顺序。
原始会议材料(仓库内)
- 演讲课件 PDF
开源资料与原始议题 PDF
本文对应的 Markdown 原稿、Black Hat 原始议题 PDF 与配图已整理到 GitHub,可按文章编号查找和下载。
https://github.com/cybermaxluo/black-hat-usa-2026-talks
也可以点击文末“阅读原文”进入仓库。欢迎 Star、提交 Issue 或参与勘误。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:白帽子罗棋琛 Max Luo
Max Luo《Black Hat USA 2026:GCD与XPC竞态检测》