iOS 越狱检测原理

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

逻辑是:

  1. 生成一个随机文件名

  2. 尝试在受限目录里写入内容

  3. 如果写成功,再删掉,避免留下垃圾

  4. 只要写成功,就说明沙盒或文件系统约束已经明显异常

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, "")
}
相关推荐
谙弆悕博士20 小时前
系统集成项目管理工程师教程(第3版)笔记——第2章:信息技术发展
笔记·计算机网络·信息安全·学习方法·业界资讯·软考·系统分析师
JoyCong199820 小时前
打破远程协助的安全信任困局,ToDesk AI审计功能自动操作留痕
网络·人工智能·科技·安全·电脑·远程工作
kp000001 天前
大模型输出造成信息泄露类型
人工智能·网络安全·信息安全·ai安全
yunwei371 天前
实证研究:AI Agent 规则需要上下文与分层强制执行
linux·安全·ai编程
kp000001 天前
System Prompt Leakage(系统提示词泄露)及安全防御
网络·安全·网络安全·信息安全·prompt·ai安全
X7x51 天前
攻防对抗的结构化思维:深度拆解网络杀伤链(Kill Chain)与现代防御演进
网络安全·网络攻击模型·安全架构·网络杀伤链·kill chain
kp000001 天前
AI大模型,如何区分善意提问与Prompt注入攻击
人工智能·网络安全·信息安全·prompt·ai安全
数据知道1 天前
网络地址转换(NAT)与安全:映射原理、打洞与内网暴露风险
网络·安全·智能路由器·内网安全
kali-Myon1 天前
深入 MySQL 内核:从临时哈希表分配机制详解 Floor 报错注入核心原理
数据库·sql·mysql·安全·web