摘要

Swift 5.10 引入了严格并发检查,通过 @Sendable 闭包和 Actor 隔离在编译期防止数据竞争。然而,这些安全保证几乎完全依赖于编译器的静态分析,运行时缺乏强制机制。攻击者可利用 UnsafeMutablePointer 将非 Sendable 类型强制转换为 @Sendable 闭包,并将其跨 Actor 传递;在另一个 Actor 上通过 withUnsafeCurrentTask 执行任意代码,破坏原 Actor 的状态,实现数据竞争和类型混淆。更隐蔽的是,利用 TaskLocal 传递裸指针,或通过 GCD 直接调度绕过 @MainActor 隔离,可在服务器端 Swift 应用(如 Vapor)中实现持久化的隐蔽后门。

Sendable与 Actor 隔离

Sendable协议的类型安全承诺

Sendable 是 Swift 并发模型的核心协议之一,用于标记可以安全地跨并发域传递的类型。值类型(如 IntString)、无内部共享状态的 final class 以及显式标记为 @Sendable 的闭包均符合 Sendable。编译器会检查所有跨 Actor 或 Task 传递的值的类型,如果非 Sendable 类型被错误传递,则发出严格并发检查的编译错误或警告。

在底层,@Sendable 闭包会被编译器转换为捕获不可变副本或同步原语保护的上下文,但这一转换完全发生在 SIL(Swift Intermediate Language)层,最终生成的 LLVM IR 中并没有额外的运行时检查。也就是说,一旦绕过编译器的静态检查,运行时将不加区别地执行任何闭包

Actor 隔离的执行语义

Actor 是一种引用类型,通过串行执行队列保护其内部状态。所有对 Actor 可变状态的访问都必须通过 await 异步调用或在 Actor 内部的同步代码中进行。编译器负责保证这一约束:在 Actor 外部,无法直接访问其存储属性或调用未标记 nonisolated 的方法。

运行时实现上,Actor 携带一个内部的 DistributedActor 标识和串行执行器。当任务需要在 Actor 上执行时,会被调度到该执行器上排队。但 Actor 的执行器本身是可替换的,且 GCD 等旧并发 API 可以绕过这一调度机制,直接将任务提交到任意队列执行。这构成了 Actor 隔离的第一道裂缝。

静态分析的边界

Swift 编译器的严格并发检查虽然强大,但存在若干已知的静态分析边界:

  • UnsafeMutablePointer系列 API:操作裸指针时,编译器无法跟踪指针指向的值的类型安全属性,假定开发者自行负责。
  • withUnsafeCurrentTask:允许在任务上下文中访问底层 UnsafeCurrentTask 句柄,其内部包含任务队列和优先级信息,可用于构造非法的跨 Actor 调度。
  • TaskLocal:任务本地值在闭包间通过隐式参数传递,其内容可以是任意类型,编译器不检查存储在 TaskLocal 中的值是否 Sendable
  • Objective-C 互操作:通过 @objc 方法或 DispatchQueue 等 C API 进行调度时,编译器无法应用 Swift 并发规则。

这些边界为运行时逃逸提供了可能。攻击者可以将非 Sendable 类型包装在看似合法的容器中,通过静态分析检查,然后在运行时跨 Actor 共享,导致数据竞争。

从类型混淆到 Actor 状态破坏

将非Sendable闭包伪装为@Sendable

攻击者首先构造一个捕获了非 Sendable 可变状态的闭包,然后通过 unsafeBitCast 或 UnsafeMutablePointer 的 withMemoryRebound 将其类型伪装为 @Sendable。例如:

classNonSendable{
var value: Int = 0
}

let ns = NonSendable()
let closure: () -> Void = {
    ns.value += 1// 捕获非 Sendable 对象
}
// 伪装成 @Sendable 闭包
let sendableClosure: @Sendable () -> Void = unsafeBitCast(closure, to: (@Sendable () -> Void).self)

由于 unsafeBitCast 被标记为 @discardableResult 且本身是 Swift 标准库的操作,编译器不会对其产生并发警告。随后,攻击者可将此伪装的闭包传递给一个 Actor 的方法,该方法接受 @Sendable 闭包参数并在内部执行。因为闭包的 @Sendable 标签已在类型系统中被伪造,编译器放行,而闭包实际捕获的非 Sendable 状态将在 Actor 的执行上下文中被修改,破坏 Actor 的状态隔离。

跨 Actor 状态破坏示例

定义两个 Actor:Bank 管理账户余额,Malicious 在内部利用伪装的闭包篡改 Bank 的私有状态。

actor Bank {
privatevar balance: Int = 1000

funcperformTransaction(_ operation: @Sendable () -> Void) {
        operation()  // 在 Actor 上执行闭包
    }

funcgetBalance() -> Int { balance }
}

actor Malicious {
let bank: Bank
let ns: NonSendable

init(bank: Bank, ns: NonSendable) {
self.bank = bank
self.ns = ns
    }

funcattack() async {
let closure: () -> Void = {
self.ns.value = -9999// 捕获的 ns 将在 Bank 上被修改
        }
// 绕过并发检查
let sendableClosure: @Sendable () -> Void = unsafeBitCast(closure, to: (@Sendable () -> Void).self)
        await bank.performTransaction(sendableClosure)
    }
}

执行攻击后,ns.value 被修改,而修改发生的位置是在 Bank 的串行执行上下文中,打破了 Bank 对其内部隔离的假设。如果 ns 同时被多个线程访问,就会产生数据竞争。在更复杂的场景中,攻击者可以传递捕获了 bank 自身引用的闭包,在 Bank 内部直接访问 bank.balance(因为此时已在 Actor 上下文),从而绕过 private 访问控制。

利用TaskLocal传递裸指针

TaskLocal 是 Swift 用于在线程上下文中隐式传递值的机制。攻击者可以创建一个 TaskLocal<UnsafeMutablePointer<NonSendable>> 并写入一个指向非 Sendable 对象的指针,然后在另一个 Actor 中读取该本地值,从而跨域传递裸指针。此过程中不涉及任何 Sendable 检查,因为 UnsafeMutablePointer 本身是一个值类型且符合 Sendable(作为指针,它被假定为安全的),但它指向的内容却完全不受保护。

enumGlobal{
    @TaskLocalstaticvar nsPointer: UnsafeMutablePointer<NonSendable>?
}
// 在 Actor A 中
let ns = NonSendable()
Global.$nsPointer.withValue(&ns) {
// 切换到 Actor B
    await someActor.doSomething()
}
// 在 Actor B 中
iflet ptr = Global.nsPointer {
    ptr.pointee.value = 42// 跨 Actor 修改非 Sendable 状态
}

这里编译器完全静默,因为 TaskLocal 的值类型是 UnsafeMutablePointer,它是一个符合 Sendable 的类型。但实际上,通过它实现跨 Actor 的可变状态共享,彻底击溃了并发安全。

绕过@MainActor隔离

@MainActor 是 Swift 中保证 UI 操作在主线程执行的注解。攻击者可以通过 GCD 的 DispatchQueue.main.async 直接在闭包中执行任意代码,而无需 await 或显式标记 @MainActor。例如,在 Vapor 服务器后台线程中,攻击者可以插入以下代码:

import Dispatch
DispatchQueue.main.async {
// 此处代码在主线程执行,绕过了 @MainActor 的编译检查
// 可操作 UI,或者释放特定的锁
}

这不会引发并发警告,因为 GCD 是旧 API,编译器不对其内容进行严格并发分析。这种混用使得 Actor 的隔离在运行时可被轻易绕过。

服务器端 Vapor 应用中的跨 Actor 后门

以下代码展示了一个 Vapor 路由,利用上述技巧在后台修改 Actor 的私有状态,实现数据窃取。

import Vapor

actor SecretStore {
privatevar secrets: [String] = ["api_key_123", "token_xyz"]

funcprocess(_ closure: @Sendable () -> Void) {
        closure()
    }

funcreadSecrets() -> [String] {
        secrets
    }
}

let store = SecretStore()
let ns = NonSendable()  // 用于传递引用

routes.get("steal") { req -> Stringin
let closure: () -> Void = {
        ns.value = 1
// 这里无法直接访问 store,但可以通过 unsafeBitCast 获取
let rawPointer = Unmanaged.passUnretained(store).toOpaque()
let stolenStore = Unmanaged<SecretStore>.fromOpaque(rawPointer).takeUnretainedValue()
// 现在在闭包内,且此闭包将在 store 的 Actor 上执行,因此可以直接访问私有属性
Task {
            await stolenStore.process {
// 此时已经在 store 的 Actor 上,可以直接修改 secrets
// 但 secrets 是私有,不能在闭包中直接访问。可以通过另一个非 Sendable 对象间接泄露
// 这里简化:将 secrets 拷贝到 ns 中
                ns.value = stolenStore.readSecrets().count
            }
        }
    }
let sendableClosure: @Sendable () -> Void = unsafeBitCast(closure, to: (@Sendable () -> Void).self)
    await store.process(sendableClosure)
return"Leaked"
}

说明:攻击者将捕获了 store 引用的非 Sendable 闭包强制转换为 @Sendable,然后传递给 store 的方法,后者在 store 的 Actor 上执行闭包。此时闭包内部处于 Actor 的执行上下文,能够访问 store 的私有属性。通过 Unmanaged 将 Actor 引用传递进去,成功实现信息泄露。

检测与防御

运行时监控

  • 动态检测非法跨 Actor 状态访问:使用 LLVM 插桩或 Sanitizer(如 Thread Sanitizer)监控对 Actor 内部状态的非预期访问。虽然 TSan 在 Swift 上支持有限,但在 Linux 服务器端可开启 TSan 检测数据竞争。
  • 监控unsafeBitCast的使用:在代码审查和安全审计中,重点标记所有 unsafeBitCast 调用,尤其是涉及闭包类型和 @Sendable 转换的地方。

强化静态分析

  • 增强 UnsafeMutablePointer 相关的并发检查:编译器应标记任何通过 UnsafeMutablePointer 传递的非 Sendable 值为潜在的并发风险,至少在验证模式下发出警告。
  • 严格模式下的 GCD 集成:提供编译标志,要求旧 GCD API 的闭包也遵守 @Sendable 约束(如 @preconcurrency 属性),阻止隐式绕过。

运行时隔离加强

  • Actor 执行器锁定:禁止通过自定义 DispatchQueue 替换 Actor 的默认执行器,除非显式声明。
  • TaskLocal 的 Sendable 传播:要求 TaskLocal 的值类型在定义时即满足 @Sendable 约束,而非仅在使用的闭包中检查。

结语

Swift 的并发模型建立在编译期的严格类型检查之上,而运行时却选择性地信任了这些静态承诺。unsafeBitCastUnsafeMutablePointer 和 TaskLocal 等原生工具,为攻击者提供了一整套绕过 Sendable 和 Actor 隔离的“沉默通道”。在服务器端 Swift 日益普及的今天,这些漏洞可能使敏感数据在微服务之间无声泄漏。安全的并发语言不仅需要强大的编译期检查,更需要运行时的强制护栏——否则,静态安全的幻象只会让漏洞藏匿得更深。