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

+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
}
}