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:
commit
1ea76ab005
89 files changed
+16485
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user