iOS 越狱检测原理
- [1. 什么是越狱](#1. 什么是越狱)
- [2. 策略一:URL Scheme 检测](#2. 策略一:URL Scheme 检测)
- [3. 策略二:可疑文件是否存在](#3. 策略二:可疑文件是否存在)
-
- [通过 FileManager\.default\.fileExists\(atPath: path\)](#通过 FileManager.default.fileExistsatPath: path)
- [通过 stat 方法](#通过 stat 方法)
- [通过 `fopen()` 方法](#通过
fopen()方法)
- [通过 `access()` 方法](#通过
access()方法) - [4. 策略三:受限目录是否可写](#4. 策略三:受限目录是否可写)
- [5. 策略四:`fork()` 是否可用](#5. 策略四:
fork()是否可用) - [6. 策略五:异常符号链接](#6. 策略五:异常符号链接)
- [7. 策略六:进程里是否加载了可疑动态库](#7. 策略六:进程里是否加载了可疑动态库)
- [8. 策略七:可疑 Objective\-C 类是否存在](#8. 策略七:可疑 Objective-C 类是否存在)
1. 什么是越狱
从安全机制角度看,iOS 越狱的本质是:
-
绕过 Apple 对代码签名的强制约束
-
打破 App Sandbox 对应用的访问限制
-
允许未授权代码注入系统或应用进程
-
允许对原本只读或受保护的系统区域进行修改
-
让普通 App 能观察到一些正常情况下不该出现的系统状态
所以越狱检测的本质并不是去调用系统一个官方接口,因为没有这样的官方 API。
而是寻找在正常 iPhone上不应该出现,但在越狱设备上更容易出现的异常证据
2. 策略一:URL Scheme 检测
iOS 上一个 App 可以注册自己的 URL Scheme。比如某个 App 注册了 myapp://,系统就能通过这个 scheme 把请求路由给它。
越狱工具或越狱生态 App 也常常会注册自己的 Scheme,例如:
-
undecimus://,对应 unc0ver 生态 -
sileo://,对应 Sileo -
zbra://,对应 Zebra -
filza://,对应 Filza 文件管理器
如果当前 App 调用 UIApplication.shared.canOpenURL(url) 并返回 true,说明设备上可能安装了对应 App。
swift
private static func checkURLSchemes() -> CheckResult {
let urlSchemes = [
"undecimus://",
"sileo://",
"zbra://",
"filza://"
]
return canOpenUrlFromList(urlSchemes: urlSchemes)
}
注意:要检测这类 URL Scheme,需要在 Info.plist 里加入:
xml
<key>LSApplicationQueriesSchemes</key>
<array>
<string>undecimus</string>
<string>sileo</string>
<string>zbra</string>
<string>filza</string>
</array>
这是因为 iOS 出于隐私考虑,对 canOpenURL 的查询做了限制。如果不在白名单里声明,canOpenURL 可能拿不到真实结果,导致检测逻辑失效。
3. 策略二:可疑文件是否存在
因为反检测工具可能会 hook 高层 API,让你的 App 看起来文件不存在,所以需要使用更多的检测方法,这样攻击者就需要覆盖更多层,绕过成本更高
通过 FileManager.default.fileExists(atPath: path)
Bash
恶意越狱相关文件:
/var/mobile/Library/Preferences/ABPattern
/usr/lib/ABDYLD.dylib
/usr/lib/ABSubLoader.dylib
/usr/sbin/frida-server
/etc/apt/sources.list.d/electra.list
/etc/apt/sources.list.d/sileo.sources
/.bootstrapped_electra
/usr/lib/libjailbreak.dylib
/jb/lzma
/.cydia_no_stash
/.installed_unc0ver
/jb/offsets.plist
/usr/share/jailbreak/injectme.plist
/etc/apt/undecimus/undecimus.list
/var/lib/dpkg/info/mobilesubstrate.md5sums
/Library/MobileSubstrate/MobileSubstrate.dylib
/jb/jailbreakd.plist
/jb/amfid_payload.dylib
/jb/libjailbreak.dylib
/usr/libexec/cydia/firmware.sh
/var/lib/cydia
/etc/apt
/private/var/lib/apt
/var/log/apt
/Applications/Cydia.app
/private/var/stash
/private/var/lib/apt/
/private/var/lib/cydia
/private/var/cache/apt/
/private/var/log/syslog
/private/var/tmp/cydia.log
/Applications/Icy.app
/Applications/MxTube.app
/Applications/RockApp.app
/Applications/blackra1n.app
/Applications/SBSettings.app
/Applications/FakeCarrier.app
/Applications/WinterBoard.app
/Applications/IntelliScreen.app
/private/var/mobile/Library/SBSettings/Themes
/Library/MobileSubstrate/CydiaSubstrate.dylib
/System/Library/LaunchDaemons/com.ikey.bbot.plist
/Library/MobileSubstrate/DynamicLibraries/Veency.plist
/Library/MobileSubstrate/DynamicLibraries/LiveClock.plist
/System/Library/LaunchDaemons/com.saurik.Cydia.Startup.plist
/Applications/Sileo.app
/var/binpack
/Library/PreferenceBundles/LibertyPref.bundle
/Library/PreferenceBundles/ShadowPreferences.bundle
/Library/PreferenceBundles/ABypassPrefs.bundle
/Library/PreferenceBundles/FlyJBPrefs.bundle
/Library/PreferenceBundles/Cephei.bundle
/Library/PreferenceBundles/SubstitutePrefs.bundle
/Library/PreferenceBundles/libhbangprefs.bundle
/usr/lib/libhooker.dylib
/usr/lib/libsubstitute.dylib
/usr/lib/substrate
/usr/lib/TweakInject
/var/binpack/Applications/loader.app
/Applications/FlyJB.app
/Applications/Zebra.app
/Library/BawAppie/ABypass
/Library/MobileSubstrate/DynamicLibraries/SSLKillSwitch2.plist
/Library/MobileSubstrate/DynamicLibraries/PreferenceLoader.plist
/Library/MobileSubstrate/DynamicLibraries/PreferenceLoader.dylib
/Library/MobileSubstrate/DynamicLibraries
/var/mobile/Library/Preferences/me.jjolano.shadow.plist
从一个普通 App 的视角看,这些路径如果可见那就十分可疑:
/bin/bash
/usr/sbin/sshd
/usr/libexec/ssh-keysign
/bin/sh
/etc/ssh/sshd_config
/usr/libexec/sftp-server
/usr/bin/ssh
不是只写:
swift
FileManager.default.fileExists(atPath: path)
而是在失败后继续尝试:
-
stat() -
fopen() -
access()
也就是:
swift
if {
...
} else if let result = FileChecker.checkExistenceOfSuspiciousFilesViaStat(path: path) {
...
} else if let result = FileChecker.checkExistenceOfSuspiciousFilesViaFOpen(path: path, mode: .readable) {
...
} else if let result = FileChecker.checkExistenceOfSuspiciousFilesViaAccess(path: path, mode: .readable) {
...
}
通过 stat 方法
stat() 是 POSIX 接口,用来获取文件元数据。如果它成功返回,通常说明路径对应的文件或目录真实存在
swift
var statbuf: stat = stat()
let resultCode = stat((path as NSString).fileSystemRepresentation, &statbuf)
if resultCode == 0 {
return (false, "Suspicious file exists: \\(path)")
}
-
0表示成功 -
非
0表示失败
通过 fopen() 方法
fopen() 直接尝试以读模式打开文件:
swift
if let filePointer: UnsafeMutablePointer<FILE> = fopen(path, mode) {
fclose(filePointer)
return (false, "Suspicious file exists: \\(path)")
}
如果能打开,说明:文件不仅存在,而且当前进程对它至少具备一定访问能力
通过 access() 方法
access() 检查调用进程是否具备某种访问权限,例如:
-
R_OK读权限 -
W_OK写权限
如果 access() 成功返回 0,说明从系统权限角度看,这个路径是可访问的
swift
static func checkExistenceOfSuspiciousFilesViaAccess(
path: String,
mode: FileMode
) -> CheckResult? {
let resultCode = access(
(path as NSString).fileSystemRepresentation,
FileMode.writable == mode ? W_OK : R_OK
)
if resultCode == 0 {
return (false, "Suspicious file exists: \(path)")
} else {
return nil
}
}
4. 策略三:受限目录是否可写
-
先判断根文件系统是不是只读
-
再尝试真的往受限目录写文件
swift
/
/root/
/private/
/jb/
// 现代越狱尤其是 rootless 方案,会把一些越狱相关内容放在 /jb/ 路径下
第一层:检查文件系统是否只读
建议使用三种方式,防止单一方式被绕过:
statvfs()
statvfs() 返回文件系统统计信息,里面的 f_flag 可用于检查是否带有 ST_RDONLY 标志。
swift
static func checkRestrictedPathIsReadonlyViaStatvfs(
path: String,
encoding: String.Encoding = .utf8
) -> Bool? {
guard let path: [CChar] = path.cString(using: encoding) else {
assertionFailure("Failed to create a cString with path=\(path) encoding=\(encoding)")
return nil
}
var statBuffer = statvfs()
let resultCode: Int32 = statvfs(path, &statBuffer)
if resultCode == 0 {
return Int32(statBuffer.f_flag) & ST_RDONLY != 0
} else {
return nil
}
}
statfs()
statfs() 同样能返回挂载信息
swift
private static func getMountedVolumeInfoViaStatfs(
path: String,
encoding: String.Encoding = .utf8
) -> MountedVolumeInfo? {
guard let path: [CChar] = path.cString(using: encoding) else {
assertionFailure("Failed to create a cString with path=\(path) encoding=\(encoding)")
return nil
}
var statBuffer = statfs()
/**
Upon successful completion, the value 0 is returned; otherwise the
value -1 is returned and the global variable errno is set to indicate
the error.
*/
let resultCode: Int32 = statfs(path, &statBuffer)
if resultCode == 0 {
let mntFromName: String = withUnsafePointer(to: statBuffer.f_mntfromname) { ptr -> String in
return String(cString: UnsafeRawPointer(ptr).assumingMemoryBound(to: CChar.self))
}
let mntOnName: String = withUnsafePointer(to: statBuffer.f_mntonname) { ptr -> String in
return String(cString: UnsafeRawPointer(ptr).assumingMemoryBound(to: CChar.self))
}
return MountedVolumeInfo(fileSystemName: mntFromName,
directoryName: mntOnName,
isRoot: (Int32(statBuffer.f_flags) & MNT_ROOTFS) != 0,
isReadOnly: (Int32(statBuffer.f_flags) & MNT_RDONLY) != 0)
} else {
return nil
}
}
getfsstat()
getfsstat() 可以列出所有挂载的文件系统,再从中找到匹配的卷,看看它的只读标志
1、先获取所有挂载卷
swift
private static func getMountedVolumesViaGetfsstat() -> [MountedVolumeInfo]? {
// If buf is NULL, getfsstat() returns just the number of mounted file systems.
let count: Int32 = getfsstat(nil, 0, MNT_NOWAIT)
guard count >= 0 else {
assertionFailure("getfsstat() failed to return the number of mounted file systems.")
return nil
}
var statBuffer: [statfs] = .init(repeating: .init(), count: Int(count))
let size: Int = MemoryLayout<statfs>.size * statBuffer.count
/**
Upon successful completion, the number of statfs structures is
returned. Otherwise, -1 is returned and the global variable errno is
set to indicate the error.
*/
let resultCode: Int32 = getfsstat(&statBuffer, Int32(size), MNT_NOWAIT)
if resultCode > -1 {
if count != resultCode {
assertionFailure("Unexpected a resultCode=\(resultCode), was expecting=\(count).")
}
var result: [MountedVolumeInfo] = []
for entry: statfs in statBuffer {
let mntFromName: String = withUnsafePointer(to: entry.f_mntfromname) { ptr -> String in
return String(cString: UnsafeRawPointer(ptr).assumingMemoryBound(to: CChar.self))
}
let mntOnName: String = withUnsafePointer(to: entry.f_mntonname) { ptr -> String in
return String(cString: UnsafeRawPointer(ptr).assumingMemoryBound(to: CChar.self))
}
let info = MountedVolumeInfo(fileSystemName: mntFromName,
directoryName: mntOnName,
isRoot: (Int32(entry.f_flags) & MNT_ROOTFS) != 0,
isReadOnly: (Int32(entry.f_flags) & MNT_RDONLY) != 0)
result.append(info)
}
if count != result.count {
assertionFailure("Unexpected filesystems count=\(result.count), was expecting=\(count).")
}
return result
} else {
assertionFailure(
"getfsstat() failed. resultCode=\(resultCode), expected count=\(count) filesystems."
)
return nil
}
}
2、再从所有挂载卷里找目标挂载点
swift
private static func getMountedVolumesViaGetfsstat(withName name: String) -> MountedVolumeInfo? {
if let list = getMountedVolumesViaGetfsstat() {
if list.count == 0 {
assertionFailure("Expected to a non-empty list of mounted volumes.")
} else {
return list.first(where: { $0.directoryName == name || $0.fileSystemName == name })
}
} else {
assertionFailure("Expected a non-nil list of mounted volumes.")
}
return nil
}
第二层:直接尝试写文件
swift
let pathWithSomeRandom = path + UUID().uuidString
try "AmIJailbroken?".write(
toFile: pathWithSomeRandom,
atomically: true,
encoding: String.Encoding.utf8
)
try FileManager.default.removeItem(atPath: pathWithSomeRandom)
return (false, "Wrote to restricted path: \\(path)")
逻辑是:
-
生成一个随机文件名
-
尝试在受限目录里写入内容
-
如果写成功,再删掉,避免留下垃圾
-
只要写成功,就说明沙盒或文件系统约束已经明显异常
5. 策略四:fork() 是否可用
在传统 Unix 世界里,fork() 非常正常。但 iOS 第三方 App 环境不是一个开放 Unix 终端,它是被严格沙盒化和平台化的。如果你的 App 能成功 fork():这就是非常典型的越狱信号
swift
let pointerToFork = UnsafeMutableRawPointer(bitPattern: -2)
let forkPtr = dlsym(pointerToFork, "fork")
typealias ForkType = @convention(c) () -> pid_t
let fork = unsafeBitCast(forkPtr, to: ForkType.self)
let forkResult = fork()
先通过:
swift
dlsym(pointerToFork, "fork")
动态拿到 fork 符号地址,再转成函数调用。
这样有几个好处:
-
降低直接静态调用带来的编译或审计问题
-
通过运行时解析方式调用底层符号
-
避免某些简单静态匹配
6. 策略五:异常符号链接
在越狱环境里,为了兼容旧目录结构、调整系统布局、支持包管理器,某些目录可能被重定向成符号链接,某些关键目录如果变成了非标准的符号链接,往往说明系统布局被改造过。
检测路径包括:
Plain
/Applications
/Library/Ringtones
/Library/Wallpaper
/usr/include
/usr/libexec
/usr/share
/var/lib/undecimus/apt
代码中调用:
swift
FileManager.default.destinationOfSymbolicLink(atPath: path)
如果这个路径是符号链接,并且能解析出目标地址,就会被视为异常。
7. 策略六:进程里是否加载了可疑动态库
检查当前 App 进程的地址空间里,是否已经被加载进了不该出现的动态库(dylib)或镜像(image)
dyld 是 Apple 平台的动态链接器。程序启动时或运行时加载动态库,都会经过它。
-
_dyld_image_count()可以拿到当前进程一共加载了多少个镜像。 -
_dyld_get_image_name(index)可以拿到某个镜像的路径名。
typescript
private static func checkDYLD() -> CheckResult {
let suspiciousLibraries: Set<String> = [
"systemhook.dylib",
"roothideinit.dylib",
"SubstrateLoader.dylib",
"SSLKillSwitch2.dylib",
"SSLKillSwitch.dylib",
"MobileSubstrate.dylib",
"TweakInject.dylib",
"CydiaSubstrate",
"cynject",
"CustomWidgetIcons",
"PreferenceLoader",
"RocketBootstrap",
"WeeLoader",
"/.file",
"libhooker",
"SubstrateInserter",
"SubstrateBootstrap",
"ABypass",
"FlyJB",
"Substitute",
"Cephei",
"Electra",
"AppSyncUnified-FrontBoard.dylib",
"Shadow",
"FridaGadget",
"frida",
"libcycript"
]
for index in 0..<_dyld_image_count() {
let imageName = String(cString: _dyld_get_image_name(index))
for library in suspiciousLibraries where imageName.localizedCaseInsensitiveContains(library) {
return (false, "Suspicious library loaded: \(imageName)")
}
}
return (true, "")
}
这种检测不仅说明设备可能越狱,而且你的 App 进程本身可能已经被插入了额外逻辑。
通常意味着:
-
设备大概率越狱
-
App 可能正在被 hook、注入、抓包或绕过校验
注意:"包含匹配"而不是"完全相等"
代码用了:
swift
imageName.localizedCaseInsensitiveContains(library)
这样做是因为攻击工具经常会改名、加前缀、加随机串。例如:
-
frida-somerandom.dylib -
某些 tweak 被放在不同路径下
8. 策略七:可疑 Objective-C 类是否存在
Shadow 是一类用于绕过越狱检测的工具。此处检测与 Shadow 相关的 Objective-C 运行时痕迹
swift
private static func checkSuspiciousObjCClasses() -> CheckResult {
if let shadowRulesetClass = objc_getClass("ShadowRuleset") as? NSObject.Type {
let selector = Selector(("internalDictionary"))
if class_getInstanceMethod(shadowRulesetClass, selector) != nil {
return (false, "Shadow anti-anti-jailbreak detector detected :-)")
}
}
return (true, "")
}