ios太封闭了,能收集的信息十分有限,下面列出遇到过的常见信息~
指纹信息收集
硬件信息
内存
同型号的总内存是固定值,看代码:
import Foundation
ProcessInfo.processInfo.physicalMemory
还可以通过host_statistics64获取更详细的内存使用情况(活跃、空闲、联动等):
import Darwin
func getMemoryUsage() -> (used: UInt64, free: UInt64) {
var stats = vm_statistics64()
var count = mach_msg_type_number_t(MemoryLayout<vm_statistics64>.size / MemoryLayout<integer_t>.size)
let result = withUnsafeMutablePointer(to: &stats) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &count)
}
}
guard result == KERN_SUCCESS else { return (0, 0) }
let pageSize = UInt64(vm_kernel_page_size)
let used = (UInt64(stats.active_count) + UInt64(stats.inactive_count) + UInt64(stats.wire_count)) * pageSize
let free = UInt64(stats.free_count) * pageSize
return (used, free)
}
磁盘
同型号的总磁盘空间是固定的,正常用户的可用空间应该符合使用规律,获取代码如下:
import Foundation
let attributes = try FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory())
let totalSpace = attributes[.systemSize] as? Int64 ?? 0
let freeSpace = attributes[.systemFreeSize] as? Int64 ?? 0
let usedSpace = totalSpace - freeSpace
CPU
同型号CPU核心数和指令集架构一样,而且可以看使用率,代码如下:
import Foundation
let processInfo = ProcessInfo.processInfo
let processorCount = processInfo.processorCount
var architecture = "Unknown"
#if arch(arm64)
architecture = "arm64"
#elseif arch(x86_64)
architecture = "x86_64" // 像之前intel的Simulator
#endif
屏幕
包括固定的屏幕大小,像素比等和与用户相关亮度信息,如下:
import UIKit
screen = UIScreen.main
screen.nativeBounds.size // 物理像素
screen.bounds.size // 逻辑点
screen.nativeScale // 缩放因子
screen.brightness // 亮度
// 屏幕 DPI (估算)
let dpi = screen.nativeScale * (UIDevice.current.userInterfaceIdiom == .pad ? 132 : 163)
型号
这里不是指硬件,但同型号硬件一致:
import UIKit
let device = UIDevice.current
device.name
device.model
device.systemName
device.systemVersion
还可以通过sysctlbyname("hw.machine")获取精确机器标识符(如"iPhone14,2"):
var size = 0
sysctlbyname("hw.machine", nil, &size, nil, 0)
var machine = [CChar](repeating: 0, count: size)
sysctlbyname("hw.machine", &machine, &size, nil, 0)
let identifier = String(cString: machine) // e.g. "iPhone14,2"
GPU 信息
通过Metal API获取GPU详细信息,同型号设备GPU名称和支持的GPU家族是固定的,可用于辅助设备指纹和检测虚拟化环境(云手机GPU名称常为ParavirtualizedDevice等异常值):
import Metal
guard let device = MTLCreateSystemDefaultDevice() else { return }
device.name // GPU 名称,如"Apple A15 GPU"
device.hasUnifiedMemory // 是否统一内存架构
device.recommendedMaxWorkingSetSize // 推荐最大工作集(字节)
device.maxThreadsPerThreadgroup // 最大线程组维度
// 支持的GPU家族探测
let families: [(MTLGPUFamily, String)] = [
(.apple1, "Apple1"), (.apple2, "Apple2"), (.apple3, "Apple3"),
(.apple4, "Apple4"), (.apple5, "Apple5"), (.apple6, "Apple6"),
(.apple7, "Apple7"), (.apple8, "Apple8")
]
for (family, label) in families {
if device.supportsFamily(family) {
print("Supports: \(label)")
}
}
sysctlbyname 硬件原语
通过sysctlbyname可获取大量底层硬件参数,这些值在同型号设备上固定,可作为高可信度指纹:
// 物理核心数 & 逻辑核心数
sysctlbyname("hw.physicalcpu", ...) // e.g. 6
sysctlbyname("hw.logicalcpu", ...) // e.g. 6
// CPU 频率 & 总线频率
sysctlbyname("hw.cpufrequency_max", ...) // Hz
sysctlbyname("hw.busfrequency", ...) // Hz
// CPU 类型(cputype)
sysctlbyname("hw.cputype", ...) // e.g. 0x100000C (ARM64)
// 缓存大小
sysctlbyname("hw.l1icachesize", ...) // L1 指令缓存(字节)
sysctlbyname("hw.l1dcachesize", ...) // L1 数据缓存(字节)
sysctlbyname("hw.l2cachesize", ...) // L2 缓存(字节)
sysctlbyname("hw.l3cachesize", ...) // L3 缓存(字节,Apple Silicon 通常无独立 L3)
// 序列号 (Serial Number) - 高风险,现代 iOS 已受限
sysctlbyname("kern.osversion", ...) // 可辅助推断
// 内存页大小 & CPU 品牌
sysctlbyname("hw.pagesize", ...) // 通常 16384(arm64)
sysctlbyname("machdep.cpu.brand_string", ...) // CPU 品牌名称,如 "Apple A15 GPU"
字体列表指纹
系统可用字体列表在同型号/同版本设备上一致,但用户安装自定义字体后会改变(虽然很少有人会安装字体)。将字体列表MD5哈希作为设备指纹辅助维度:
import UIKit
import CommonCrypto
let fontFamilies = UIFont.familyNames.sorted()
let fontString = fontFamilies.joined(separator: ",")
let fontData = fontString.data(using: .utf8)!
var digest = [UInt8](repeating: 0, count: Int(CC_MD5_DIGEST_LENGTH))
_ = fontData.withUnsafeBytes { CC_MD5($0.baseAddress, CC_LONG(fontData.count), &digest) }
let fontListMD5 = digest.map { String(format: "%02x", $0) }.joined()
// 同机型 + 同系统版本 + 未安装自定义字体 → 哈希一致
// 安装了 AnyFont 等工具后 → 哈希变化,可检测环境修改
最大帧率
UIScreen.maximumFramesPerSecond返回设备支持的最大刷新率。ProMotion设备(iPhone 13 Pro+)为120,普通设备为60。可辅助验证设备型号声明真实性:
let maxFPS = UIScreen.main.maximumFramesPerSecond
// iPhone 13 Pro / 14 Pro / 15 Pro / 16 Pro: 120
// iPhone SE / iPhone 13 / 14 / 15 标准版: 60
// 云手机/模拟器: 可能返回 60 或 0
网络信息
运营商信息
获取SIM卡的运营商,国家代码等:
import CoreTelephony
let networkInfo = CTTelephonyNetworkInfo()
if let carrier = networkInfo.serviceSubscriberCellularProviders?.first?.value {
print("Carrier Name: \(carrier.carrierName ?? "N/A")")
print("Mobile Country Code: \(carrier.mobileCountryCode ?? "N/A")")
print("Mobile Network Code: \(carrier.mobileNetworkCode ?? "N/A")")
print("ISO Country Code: \(carrier.isoCountryCode ?? "N/A")")
}
蜂窝网络制式
通过CTTelephonyNetworkInfo.serviceCurrentRadioAccessTechnology可获取当前蜂窝网络制式(5G/4G/3G/2G),云手机通常无蜂窝信息(为Wi-Fi或以太网连接):
let info = CTTelephonyNetworkInfo()
if let radioDict = info.serviceCurrentRadioAccessTechnology,
let tech = radioDict.values.first {
switch tech {
case CTRadioAccessTechnologyNRNSA, CTRadioAccessTechnologyNR:
print("5G")
case CTRadioAccessTechnologyLTE:
print("4G LTE")
case CTRadioAccessTechnologyWCDMA, CTRadioAccessTechnologyHSDPA:
print("3G")
case CTRadioAccessTechnologyEdge, CTRadioAccessTechnologyGPRS:
print("2G")
default:
print(tech)
}
}
WiFi 信息(SSID/BSSID)
通过CNCopyCurrentNetworkInfo获取当前WiFi的SSID和BSSID(需要Access WiFi Information entitlement):
import SystemConfiguration.CaptiveNetwork
if let interfaces = CNCopySupportedInterfaces() as? [String],
let ifName = interfaces.first,
let info = CNCopyCurrentNetworkInfo(ifName as CFString) as? [String: Any] {
let ssid = info[kCNNetworkInfoKeySSID as String] as? String
let bssid = info[kCNNetworkInfoKeyBSSID as String] as? String
}
网络接口枚举
通过getifaddrs可枚举所有网络接口,获取IP地址、子网掩码、广播地址、MTU和流量统计(rx/tx bytes),云手机通常只有1个活跃接口且无pdp_ip(蜂窝)接口:
var ifaddr: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&ifaddr) == 0 else { return }
defer { freeifaddrs(ifaddr) }
var cursor = ifaddr
while let pointer = cursor {
let iface = pointer.pointee
let name = String(cString: iface.ifa_name) // en0, pdp_ip0, utun0 ...
let isUp = (iface.ifa_flags & UInt32(IFF_UP)) != 0
// 从 ifa_data (if_data 结构) 提取 MTU 和流量统计
if let dataPtr = iface.ifa_data {
dataPtr.withMemoryRebound(to: if_data.self, capacity: 1) { ifData in
let mtu = ifData.pointee.ifi_mtu
let rxBytes = ifData.pointee.ifi_ibytes
let txBytes = ifData.pointee.ifi_obytes
}
}
cursor = iface.ifa_next
}
DNS 服务器列表
读取/etc/resolv.conf(iOS沙盒内可访问)获取系统DNS服务器列表:
if let content = try? String(contentsOfFile: "/etc/resolv.conf", encoding: .utf8) {
for line in content.components(separatedBy: .newlines) {
let parts = line.trimmingCharacters(in: .whitespaces).components(separatedBy: .whitespaces)
if parts.first == "nameserver", parts.count >= 2 {
print("DNS: \(parts[1])")
}
}
}
默认网关(UDP connect trick)
iOS沙盒内无法直接读取路由表,但可通过UDP connect trick获取默认网关估算值:
let sock = socket(AF_INET, SOCK_DGRAM, 0)
defer { close(sock) }
var remote = sockaddr_in()
remote.sin_family = sa_family_t(AF_INET)
remote.sin_port = UInt16(53).bigEndian
inet_pton(AF_INET, "8.8.8.8", &remote.sin_addr)
// connect 不实际发送数据,仅触发路由选择
withUnsafePointer(to: &remote) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
Darwin.connect(sock, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
// getsockname 获取本机出口 IP,末段改为 .1 作为网关估算
var local = sockaddr_in()
var len = socklen_t(MemoryLayout<sockaddr_in>.size)
withUnsafeMutablePointer(to: &local) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
getsockname(sock, $0, &len)
}
}
TCP 连接数
通过sysctl获取当前系统TCP连接数(异常高可能提示被注入或中间人):
var count: Int32 = 0
var size = MemoryLayout<Int32>.size
var mib: [Int32] = [CTL_NET, PF_INET, IPPROTO_TCP, 8] // TCPCTL_PCBCOUNT
sysctl(&mib, u_int(mib.count), &count, &size, nil, 0)
print("TCP connections: \(count)")
网络可达性(SCNetworkReachability)
通过SCNetworkReachabilityGetFlags获取可达性标志位,可判断是否通过蜂窝/WiFi可达、是否需要VPN按需连接等:
import SystemConfiguration
var flags: SCNetworkReachabilityFlags = []
if let reachability = SCNetworkReachabilityCreateWithName(nil, "0.0.0.0") {
SCNetworkReachabilityGetFlags(reachability, &flags)
}
let isReachable = flags.contains(.reachable)
let isWWAN = flags.contains(.isWWAN) // 蜂窝
let connectionOnDemand = flags.contains(.connectionOnDemand) // VPN 按需连接
SIM 卡数量
通过CTTelephonyNetworkInfo.serviceSubscriberCellularProviders获取SIM卡数量,双卡设备返回2,无SIM返回0:
import CoreTelephony
let networkInfo = CTTelephonyNetworkInfo()
let simCount = networkInfo.serviceSubscriberCellularProviders?.count ?? 0
// 双卡 iPhone: 2(物理 SIM + eSIM)
// 单卡 iPhone SE: 1
// 云手机/模拟器: 通常为 0(无基带硬件)
文件与应用信息
这里它能检测很多文件的创建/修改/访问时间和inode等信息,如:
/System/Library/CoreServices/SystemVersion.plist
/var
/etc/group
/etc/hosts
/System/Library/Caches/com.apple.dyld/dyld_shared_cache_arm64e
# ...
下面是获取代码:
import Foundation
func getFileStatInfo(for filePath: String) {
var fileStat = stat() // C 结构体
if stat(filePath, &fileStat) == 0 { // stat() 函数成功返回0,失败返回-1
let inode = fileStat.st_ino
print("Inode: \(inode)")
let atime = fileStat.st_atimespec // at - 访问时间 (文件内容最后被读取)
let accessDate = Date(timeIntervalSince1970: TimeInterval(atime.tv_sec) + TimeInterval(atime.tv_nsec) / 1_000_000_000)
print("Access Time (at): \(accessDate)")
let mtime = fileStat.st_mtimespec // mt - 修改时间 (文件内容最后被修改)
let modificationDate = Date(timeIntervalSince1970: TimeInterval(mtime.tv_sec) + TimeInterval(mtime.tv_nsec) / 1_000_000_000)
print("Modify Time (mt): \(modificationDate)")
let ctime = fileStat.st_ctimespec // ct - 更改时间 (文件元数据最后被修改,如权限、所有者等)
let changeDate = Date(timeIntervalSince1970: TimeInterval(ctime.tv_sec) + TimeInterval(ctime.tv_nsec) / 1_000_000_000)
print("Change Time (ct): \(changeDate)")
let btime = fileStat.st_birthtimespec // bt - 创建时间
let birthDate = Date(timeIntervalSince1970: TimeInterval(btime.tv_sec) + TimeInterval(btime.tv_nsec) / 1_000_000_000)
print("Birth Time (bt): \(birthDate)")
} else {
print("Error getting stat info: \(String(cString: strerror(errno)))")
}
}
安装的应用
ios下无法直接获取应用列表,不过还是可以用url scheme探测部分特定(注册过URL Scheme)的app是否存在,但这需要提前将所有要探测的URL Schema添加到Info.plist的LSApplicationQueriesSchemes之中,如:
<key>LSApplicationQueriesSchemes</key>
<array>
<string>weixin</string>
<string>alipays</string>
</array>
之后就可以用如下代码探测了:
import UIKit
func isAppInstalled(scheme: String) -> Bool {
// 确保 scheme 后面有 "://"
let formattedScheme = scheme.hasSuffix("://") ? scheme : "\(scheme)://"
if let url = URL(string: formattedScheme) {
return UIApplication.shared.canOpenURL(url)
}
return false
}
if isAppInstalled(scheme: "weixin") {
print("WeChat is installed.")
} else {
print("WeChat is not installed.")
}
当前应用信息
// 应用显示名
Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String
//安装时间
import Foundation
let documentsURL = try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
let attributes = try FileManager.default.attributesOfItem(atPath: documentsURL.path)
if let creationDate = attributes[.creationDate] as? Date {
print("App installed around: \(creationDate)")
}
//版本 plist版本等
系统信息
系统运行时间
本次开机后运行多久了:
import Foundation
let uptime = ProcessInfo.processInfo.systemUptime
系统启动时间 (Boot Time)
通过sysctl获取内核记录的开机精确时间戳。bootTime与uptime的组合可以作为设备稳定性的标识:
func getBootTime() -> Date? {
var tv = timeval()
var size = MemoryLayout<timeval>.size
var mib: [Int32] = [CTL_KERN, KERN_BOOTTIME]
let result = sysctl(&mib, 2, &tv, &size, nil, 0)
if result == 0 {
return Date(timeIntervalSince1970: TimeInterval(tv.tv_sec) + TimeInterval(tv.tv_usec) / 1_000_000)
}
return nil
}
系统和内核版本
import UIKit
struct SystemVersionInfo {
let systemVersion: String
let kernelVersion: String
}
func getSystemVersionInfo() -> SystemVersionInfo {
let device = UIDevice.current
// 获取内核版本需要使用 uname
var systemInfo = utsname()
uname(&systemInfo)
let kernelVersion = withUnsafePointer(to: &systemInfo.release) {
$0.withMemoryRebound(to: CChar.self, capacity: 1) {
String(cString: $0)
}
}
// 获取 OS Build 版本 (如 21A360)
var size = 0
sysctlbyname("kern.osversion", nil, &size, nil, 0)
var osversion = [CChar](repeating: 0, count: size)
sysctlbyname("kern.osversion", &osversion, &size, nil, 0)
let buildVersion = String(cString: osversion)
return SystemVersionInfo(
systemVersion: device.systemVersion,
kernelVersion: kernelVersion
)
}
本地化信息
语言、时区、货币等设置反映了用户的真实归属地,也是设备指纹的重要组成部分:
import Foundation
let locale = Locale.current
let localeIdentifier = locale.identifier // 如 "zh_CN"
let preferredLanguages = Locale.preferredLanguages // 用户偏好语言列表
let timeZone = TimeZone.current.identifier // 如 "Asia/Shanghai"
let currencyCode = locale.currency?.identifier ?? "N/A"
let currencySymbol = locale.currencySymbol ?? "N/A"
区域
包括时区和语言:
import Foundation
let locale = Locale.current
print("Locale Identifier: \(locale.identifier)")
print("Preferred Languages: \(Locale.preferredLanguages.joined(separator: ", "))")
print("Time Zone: \(TimeZone.current.identifier)")
设备主机名
通过sysctlbyname("kern.hostname")获取设备主机名。用户自定义的设备名称会反映在主机名中,可作为辅助指纹:
var size: size_t = 0
sysctlbyname("kern.hostname", nil, &size, nil, 0)
var hostname = [CChar](repeating: 0, count: size)
sysctlbyname("kern.hostname", &hostname, &size, nil, 0)
let name = String(cString: hostname)
// 如 "iPhone-de-Wang" → 包含用户名信息
// 云手机/虚拟机: 可能返回默认值如 "localhost" 或随机生成
唯一标识符
IDFV
IDFA(Identifier for Vendor)是个无需权限就能获取的ID,对同一个开发者(同一个Team ID)的所有App,在同一台设备上获取到的IDFV都是相同的,但若同一开发者的所有APP都被卸载下次安装又会生成一个新的,获取代码如下:
import UIKit
func getIDFV() -> String? {
return UIDevice.current.identifierForVendor?.uuidString
}
if let idfv = getIDFV() {
print("Identifier for Vendor (IDFV): \(idfv)")
}
IDFA
IDFA(Identifier for Advertisers)这是专门为广告商追踪用户行为而设计的标识符,所有APP获取的都是一样的,但是用户可以在设置 -> 隐私与安全性 -> 跟踪中重置IDFA,或者完全关闭允许App请求跟踪,这样就拿不到它了。
微信多级持久化策略(IDFA Cache):
为了防止IDFA被重置或由于系统限制无法获取,成熟的App会采用多级持久化方案:
1. ASIdentifierManager系统API(实时获取)
2. MemoryMappedKV(如MMKV缓存文件)
3. MMKeychain明文存储
4. MMKeychain加密存储(decryptIDFA:)
要获取它需要在Info.plist中添加NSUserTrackingUsageDescription来说明目的,用户可根据说明决定是否允许,获取代码如下:
import AdSupport
import AppTrackingTransparency
func requestIDFA() {
ATTrackingManager.requestTrackingAuthorization { status in // 请求用户授权
switch status {
case .authorized:
let idfa = ASIdentifierManager.shared().advertisingIdentifier.uuidString // 用户授权,可以获取IDFA
print("Identifier for Advertisers (IDFA): \(idfa)")
case .denied, .restricted, .notDetermined:
print("IDFA not available.") // 用户拒绝、受限或未决定
@unknown default:
print("Unknown status.")
}
}
}
自定义+KeyChain存储
就是自己去生成一个ID,把它存在keychain里,这样即使应用被卸载默认也不会删除keychain,下次安装后依然能读取到,比如可以:
import Foundation
import Security
public final class KeychainHelper {
private static let service = "com.xiaobeta.deviceservice"
private static let account = "deviceIdentifier"
public static func getOrCreateDeviceUUID() -> String {
// 优先从 Keychain 中读取
if let uuid = searchDeviceUUID() {
return uuid
}
// 如果 Keychain 中没有,则创建一个新的并保存
let newUUID = UUID().uuidString
saveDeviceUUID(uuid: newUUID)
return newUUID
}
// MARK: - Private Core Functions
private static func saveDeviceUUID(uuid: String) {
guard let data = uuid.data(using: .utf8) else { return }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
// 先尝试删除旧的项目,忽略结果(因为它可能本就不存在)
SecItemDelete(query as CFDictionary)
// 添加新的项目
var newItem = query
newItem[kSecValueData as String] = data
SecItemAdd(newItem as CFDictionary, nil)
}
/// 从 Keychain 中查找设备UUID。
private static func searchDeviceUUID() -> String? {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: kCFBooleanTrue!,
kSecMatchLimit as String: kSecMatchLimitOne
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess,
let data = item as? Data,
let uuid = String(data: data, encoding: .utf8)
else {
return nil
}
return uuid
}
// 5. Keychain Access Group 提取 Team ID (京东方案)
// 通过向 Keychain 写入临时条目并读取其 Access Group 属性,可提取出 Apple 开发者的 Team ID (如 "TQZTTUQ9ZE")
// 用于验证 App 是否被第三方重签名(重签名后的 Team ID 会改变)
// 6. 动态本地指纹票据 (Local Ticket)
// 用于标识单次会话或请求,防止重放攻击。生成算法通常结合了多个运行时随机因子:
// - 当前线程指针地址 (pthread_self())
// - 高精度系统时间戳
// - malloc 分配的堆内存地址
// - 特定系统目录的创建时间
// - 对以上内容进行 SHA-256 哈希
}
DeviceCheck
这个有点不讲武德,苹果官方虽然不提供device id,但为了让厂商打击作弊提供了DC机制,该机制由Apple官方为每个团队提供两比特空间,开发者可定义4种状态,该机制由TrustZone去做签名,且签名密钥是硬编码的设备证书,所以用它来封设备简直是绝绝子:
import Foundation
import DeviceCheck
class DeviceCheckManager {
// Your server endpoint where you'll send the token
private let yourServerURL = URL(string: "https://your.server.com/verifyDevice")!
/// Generates a DeviceCheck token and sends it to your server for validation.
func validateDevice() {
// 1. First, check if the current device supports DeviceCheck.
guard DCDevice.current.isSupported else {
print("DeviceCheck is not supported on this device.")
// Handle the case for unsupported devices (e.g., older OS, simulator)
return
}
// 2. Generate the single-use token. This is an asynchronous call.
DCDevice.current.generateToken { (tokenData, error) in
guard let tokenData = tokenData else {
if let error = error {
print("Error generating DeviceCheck token: \(error.localizedDescription)")
}
return
}
// 3. The token is binary data. Convert it to a Base64 string to send as JSON.
let tokenString = tokenData.base64EncodedString()
print("Generated DeviceCheck Token: \(tokenString)")
// 4. Send the token to your server.
self.sendTokenToServer(token: tokenString)
}
}
/// A placeholder function for sending the token to your server.
private func sendTokenToServer(token: String) {
var request = URLRequest(url: yourServerURL)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body: [String: Any] = ["device_token": token]
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
// Example of a network call
URLSession.shared.dataTask(with: request) { (data, response, error) in
// Handle the server's response here.
// Your server would reply indicating if the user gets the promotion, etc.
print("Server response received.")
}.resume()
}
}
// --- 使用示例 ---
let deviceCheckManager = DeviceCheckManager()
// Call this when you need to check the device, e.g., when a user tries to access a promotion.
// deviceCheckManager.validateDevice()
服务端收到token后向apple去读些数据:
def query_device_bits(device_token: str):
"""Queries Apple's server for the current state of the two bits."""
auth_token = generate_auth_token()
headers = {"Authorization": f"Bearer {auth_token}"}
payload = {
"device_token": device_token,
"transaction_id": str(uuid.uuid4()),
"timestamp": int(time.time() * 1000) # Milliseconds
}
try:
response = requests.post(QUERY_URL, json=payload, headers=headers)
response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx)
# Response body is JSON: {"bit0":false,"bit1":false,"last_update_time":"2022-10"}
return response.json()
except requests.exceptions.RequestException as e:
print(f"Error querying device bits: {e}")
if e.response:
print(f"Response body: {e.response.text}")
return None
def update_device_bits(device_token: str, bit0: bool, bit1: bool):
"""Updates the state of the two bits on Apple's server."""
auth_token = generate_auth_token()
headers = {"Authorization": f"Bearer {auth_token}"}
payload = {
"device_token": device_token,
"transaction_id": str(uuid.uuid4()),
"timestamp": int(time.time() * 1000),
"bit0": bit0,
"bit1": bit1
}
try:
response = requests.post(UPDATE_URL, json=payload, headers=headers)
# A successful update returns a 200 OK with an empty body.
response.raise_for_status()
print("Successfully updated device bits.")
return True
except requests.exceptions.RequestException as e:
print(f"Error updating device bits: {e}")
if e.response:
print(f"Response body: {e.response.text}")
return False
注:苹果对隐私保护很看重,所以直接的device id老早就被禁了,直接忽略
跨应用设备标识(UTDID 方案)
在同一厂商的多个App之间共享一个设备级唯一标识,是风控系统进行跨App关联分析的重要手段。阿里安全SDK中的UTDID(Universal Token Device ID)即采用Keychain共享 + UIPasteboard备份的双通道策略:
import Security
import UIKit
/// 跨应用设备标识管理器 — UTDID 双通道方案
class CrossAppDeviceID {
// MARK: - Keychain 共享(主通道)
// 使用 Keychain Access Group 实现同开发者团队下多个 App 的 ID 共享
// 需要在 Entitlements 中配置 keychain-access-groups
private static let keychainService = "com.vendor.utdid"
private static let keychainAccount = "device_utdid"
// Access Group 格式: $(AppIdentifierPrefix)com.vendor.shared
private static let accessGroup = "TEAMID.com.vendor.shared"
static func getOrCreateFromKeychain() -> String? {
// 读取
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: keychainAccount,
kSecAttrAccessGroup as String: accessGroup,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status == errSecSuccess, let data = item as? Data,
let utdid = String(data: data, encoding: .utf8) {
return utdid
}
// 不存在则生成并写入
let newID = generateUTDID()
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: keychainAccount,
kSecAttrAccessGroup as String: accessGroup,
kSecValueData as String: newID.data(using: .utf8)!,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
]
SecItemAdd(addQuery as CFDictionary, nil)
return newID
}
// MARK: - UIPasteboard 备份(备用通道)
// 当 Keychain 不可用(如跨团队 App)时,使用命名粘贴板作为备选
// 注意:iOS 16+ 对粘贴板读取有权限弹窗,此方案逐渐失效
private static let pasteboardName = "com.vendor.utdid.pb"
static func getFromPasteboard() -> String? {
let pb = UIPasteboard(name: UIPasteboard.Name(pasteboardName), create: false)
return pb?.string
}
static func saveToPasteboard(_ utdid: String) {
let pb = UIPasteboard(name: UIPasteboard.Name(pasteboardName), create: true)
pb?.string = utdid
}
// MARK: - 双通道策略
static func resolveDeviceID() -> String {
// 优先 Keychain
if let id = getOrCreateFromKeychain() {
saveToPasteboard(id) // 同步到 Pasteboard 备份
return id
}
// Keychain 失败则尝试 Pasteboard
if let id = getFromPasteboard() {
return id
}
// 全部失败则新建
let newID = generateUTDID()
saveToPasteboard(newID)
return newID
}
// MARK: - UTDID 生成算法
// 阿里 UTDID 格式: 24 字节 = 时间戳(8) + 随机数(12) + HMAC签名(4)
private static func generateUTDID() -> String {
var bytes = [UInt8](repeating: 0, count: 24)
// 前 8 字节: 时间戳
var timestamp = UInt64(Date().timeIntervalSince1970 * 1000)
withUnsafeBytes(of: ×tamp) { ptr in
for i in 0..<8 { bytes[i] = ptr[i] }
}
// 中间 12 字节: 安全随机数
_ = SecRandomCopyBytes(kSecRandomDefault, 12, &bytes[8])
// 后 4 字节: HMAC-SHA256 截断(用于校验完整性)
// 实际实现中使用硬编码密钥进行 HMAC 签名
let checksum = bytes[0..<20].reduce(0, { $0 &+ UInt32($1) })
withUnsafeBytes(of: checksum) { ptr in
for i in 0..<4 { bytes[20 + i] = ptr[i] }
}
return Data(bytes).base64EncodedString()
}
}
方案局限性:iOS逐步收紧跨应用数据共享能力——Keychain共享仅限同一开发者团队(Team ID)、UIPasteboard从iOS 16开始读取需用户确认弹窗、通用剪贴板在iOS 14+有Transparency提示。因此现代方案更多依赖服务端ID Mapping(如通过IDFA + IP + 设备特征组合进行概率匹配)。
多组 Keychain UUID 冗余持久化
为提高设备标识的持久化鲁棒性(防止用户清除某一存储位置),可以在Keychain中使用多组不同的service/account组合存储同一UUID的副本,同时将备份写入UserDefaults和文件系统:
/// 多组 Keychain UUID 冗余持久化方案(参考腾讯 Turing SDK 5 组 UUID 设计)
class RobustDeviceID {
// 5 组独立的 Keychain 存储配置
private static let keychainSlots: [(service: String, account: String)] = [
("com.app.device.primary", "uuid_main"),
("com.app.device.backup1", "uuid_alt1"),
("com.app.device.backup2", "uuid_alt2"),
("com.app.security.token", "device_id"),
("com.app.analytics.did", "tracker_id")
]
/// 三级持久化: Keychain(5组) → UserDefaults → 文件系统
static func resolveDeviceID() -> String {
// 1. 尝试从任一 Keychain slot 读取
for slot in keychainSlots {
if let uuid = readFromKeychain(service: slot.service, account: slot.account) {
// 找到后同步到所有缺失的 slot
syncToAllSlots(uuid)
return uuid
}
}
// 2. Keychain 全部丢失(如设备恢复/迁移),尝试 UserDefaults
if let uuid = UserDefaults.standard.string(forKey: "PK_DeviceUUID") {
syncToAllSlots(uuid)
return uuid
}
// 3. 最后尝试文件系统
let filePath = NSSearchPathForDirectoriesInDomains(.libraryDirectory, .userDomainMask, true)[0]
+ "/.device_cache"
if let data = try? Data(contentsOf: URL(fileURLWithPath: filePath)),
let uuid = String(data: data, encoding: .utf8) {
syncToAllSlots(uuid)
return uuid
}
// 4. 全部丢失则新生成
let newUUID = UUID().uuidString
syncToAllSlots(newUUID)
return newUUID
}
private static func syncToAllSlots(_ uuid: String) {
for slot in keychainSlots {
writeToKeychain(uuid, service: slot.service, account: slot.account)
}
UserDefaults.standard.set(uuid, forKey: "PK_DeviceUUID")
// 写入文件作为最后备份
}
private static func readFromKeychain(service: String, account: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var item: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let data = item as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
private static func writeToKeychain(_ uuid: String, service: String, account: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
SecItemDelete(query as CFDictionary)
var addQuery = query
addQuery[kSecValueData as String] = uuid.data(using: .utf8)!
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
SecItemAdd(addQuery as CFDictionary, nil)
}
}
此方案确保即使用户通过设置清除了部分数据,只要任一通道存活即可恢复完整标识。腾讯Turing SDK在实践中使用5组不同service/account的Keychain条目,每次读取时尝试所有组合直到成功(最多重试100次)。
iCloud 账户标识(ubiquityIdentityToken)
NSFileManager.ubiquityIdentityToken可用于判断当前设备是否登录了iCloud账户,以及跨App判断是否为同一iCloud用户(无需请求任何权限):
import Foundation
/// iCloud 账户标识检测
func getICloudIdentity() -> String {
guard let token = FileManager.default.ubiquityIdentityToken else {
return "NONE" // 未登录 iCloud 或 iCloud Drive 未启用
}
// token 是 id<NSCoding> 类型,可以序列化为 Data
if let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true) {
return "ARCH:" + data.base64EncodedString()
}
// 备选:直接描述
let desc = "\(token)"
if desc.isEmpty { return "EMPTY" }
return "RAW:" + Data(desc.utf8).base64EncodedString()
}
// 用途:
// 1. 判断设备是否登录 iCloud(未登录 = 可疑,正常用户绑定率 > 90%)
// 2. 同一 iCloud 账户在不同 App 中返回相同 token → 跨 App 用户关联
// 3. token 变化 = 用户切换了 iCloud 账户(设备环境变更信号)
注意:此接口在iOS 7+可用,无需申请任何权限,但iOS 15+在某些情况下可能返回nil(需开启iCloud Drive)。返回值不是明文账户信息,而是一个不透明token,仅可用于相等性比较。
活动与行为信息
定位
定位当然很重要,除了IP定位,这里更关注系统提供的接口,ios下有精确定位和模糊定位两种,都需要在Info.plist中添加NSLocationWhenInUseUsageDescription或NSLocationAlwaysAndWhenInUseUsageDescription(后台定位)说明目的,用户允许后可获取:
import CoreLocation
class LocationManager: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
private var completion: ((CLLocation?) -> Void)?
override init() {
super.init()
manager.delegate = self
}
func requestLocation(completion: @escaping (CLLocation?) -> Void) {
self.completion = completion
manager.requestWhenInUseAuthorization() // 请求“使用期间”授权
manager.requestLocation() // 请求单次定位
}
// MARK: - CLLocationManagerDelegate
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
completion?(locations.first)
completion = nil // 防止重复回调
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
print("Failed to get location: \(error)")
completion?(nil)
completion = nil
}
// 授权状态变化
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
if manager.authorizationStatus == .authorizedWhenInUse || manager.authorizationStatus == .authorizedAlways {
manager.requestLocation()
}
}
}
let locationManager = LocationManager()
func fetchLocation() {
locationManager.requestLocation { location in
if let location = location {
print("Latitude: \(location.coordinate.latitude)")
print("Longitude: \(location.coordinate.longitude)")
} else {
print("Could not retrieve location.")
}
}
}
传感器
传感器数据看是不是人在活动,不像安卓那么宽松,在ios上需要在Info.plist中添加NSMotionUsageDescription说明目的来请求运动数据,用户允许后才可获取:
import CoreMotion
let motionManager = CMMotionManager()
func startMonitoringMotion() {
// 1. 加速计
if motionManager.isAccelerometerAvailable {
motionManager.accelerometerUpdateInterval = 1.0 // 更新频率
motionManager.startAccelerometerUpdates(to: .main) { (data, error) in
if let acceleration = data?.acceleration {
print("Accelerometer: x=\(acceleration.x), y=\(acceleration.y), z=\(acceleration.z)")
}
}
}
// 2. 陀螺仪
if motionManager.isGyroAvailable {
motionManager.gyroUpdateInterval = 1.0
motionManager.startGyroUpdates(to: .main) { (data, error) in
if let rotationRate = data?.rotationRate {
print("Gyroscope: x=\(rotationRate.x), y=\(rotationRate.y), z=\(rotationRate.z)")
}
}
}
}
func stopMonitoringMotion() {
motionManager.stopAccelerometerUpdates()
motionManager.stopGyroUpdates()
}
startMonitoringMotion()
// ...
stopMonitoringMotion()
磁力计(指南针硬件)
磁力计可检测地球磁场(约25~65 μT),模拟器和云手机通常返回零值。通过CMMotionManager.startMagnetometerUpdates获取:
if motionManager.isMagnetometerAvailable {
motionManager.magnetometerUpdateInterval = 1.0
motionManager.startMagnetometerUpdates(to: .main) { (data, error) in
if let field = data?.magneticField {
let magnitude = sqrt(field.x*field.x + field.y*field.y + field.z*field.z)
// 真实设备:magnitude 通常在 25~65 μT 范围
// 模拟器:magnitude = 0
}
}
}
气压计(高度计)
CMAltimeter可获取相对高度和气压值(正常大气压70~110 kPa),模拟器无此传感器:
import CoreMotion
let altimeter = CMAltimeter()
if CMAltimeter.isRelativeAltitudeAvailable() {
altimeter.startRelativeAltitudeUpdates(to: .main) { (data, error) in
if let data = data {
let pressure = data.pressure.doubleValue // kPa,海平面≈101.325
let relativeAltitude = data.relativeAltitude.doubleValue // 相对变化(米)
}
}
}
设备姿态(DeviceMotion)
通过CMMotionManager.startDeviceMotionUpdates可获取融合后的设备姿态(pitch/roll/yaw)、重力向量和用户加速度,模拟器返回全零:
if motionManager.isDeviceMotionAvailable {
motionManager.deviceMotionUpdateInterval = 1.0
motionManager.startDeviceMotionUpdates(to: .main) { (motion, error) in
if let m = motion {
// 姿态
let pitch = m.attitude.pitch // 俯仰角(弧度)
let roll = m.attitude.roll // 横滚角
let yaw = m.attitude.yaw // 偏航角
// 重力向量
let gx = m.gravity.x, gy = m.gravity.y, gz = m.gravity.z
// 去重力后的用户加速度
let ax = m.userAcceleration.x, ay = m.userAcceleration.y, az = m.userAcceleration.z
}
}
}
指南针方向角(CLHeading)
通过CLLocationManager.startUpdatingHeading获取磁北/真北方向角和磁场强度,强指纹信号(模拟器无法生成):
import CoreLocation
// 在 CLLocationManagerDelegate 中:
func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
let magneticHeading = newHeading.magneticHeading // 磁北方向角(0~360°)
let trueHeading = newHeading.trueHeading // 真北方向角(需要定位权限)
let accuracy = newHeading.headingAccuracy // 精度(负值=未校准)
let magnitude = sqrt(newHeading.x*newHeading.x + newHeading.y*newHeading.y + newHeading.z*newHeading.z)
// 地球磁场强度约 25~65 μT
}
电池信息
含充放电状态和电量信息,直接可获取:
import UIKit
struct BatteryInfo {
let level: Float // 0.0 to 1.0, -1.0 if unknown
let state: UIDevice.BatteryState // unplugged, charging, full, unknown
}
func getBatteryInfo() -> BatteryInfo? {
let device = UIDevice.current
// 关键:必须先启用监控
guard device.isBatteryMonitoringEnabled else {
device.isBatteryMonitoringEnabled = true
// 首次启用后,可能需要短暂延迟才能获取到准确值
return nil
}
return BatteryInfo(level: device.batteryLevel, state: device.batteryState)
}
if let battery = getBatteryInfo() {
print("Battery Level: \(Int(battery.level * 100))%")
switch battery.state {
case .unplugged: print("Battery State: Unplugged")
case .charging: print("Battery State: Charging")
case .full: print("Battery State: Full")
case .unknown: print("Battery State: Unknown")
@unknown default: break
}
}
系统音量
通过AVAudioSession获取当前系统输出音量,作为用户行为信号(真实用户会调整音量,自动化脚本通常不会):
import AVFoundation
let volume = AVAudioSession.sharedInstance().outputVolume
// 范围 0.0 ~ 1.0,可转为整数 0~100 上报
// 云手机通常为默认值(0.5),且长时间不变
低电量模式
ProcessInfo.processInfo.isLowPowerModeEnabled指示用户是否启用了低电量模式。这是真实用户行为的辅助信号:
let isLowPower = ProcessInfo.processInfo.isLowPowerModeEnabled
// 可监听 .NSProcessInfoPowerStateDidChange 通知追踪变化
// 云手机通常始终为 false(无用户主动操作)
USB 连接状态
通过电池充电状态间接判断USB连接。USB连接是调试的前提条件之一:
UIDevice.current.isBatteryMonitoringEnabled = true
let isUSBConnected = UIDevice.current.batteryState == .charging
|| UIDevice.current.batteryState == .full
// batteryState == .charging + 非无线充电 ≈ USB 连接
// 辅助信号:结合 isDebug 提升调试器检测置信度
截屏录屏
对于截屏,ios会在截屏的同时发出一个通知事件,我们可以在视图被加载时,设置一个事件观察者去监听:
private func setupScreenshotObserver() {
NotificationCenter.default.addObserver(
self,
selector: #selector(didTakeScreenshot),
name: UIApplication.userDidTakeScreenshotNotification,
object: nil
)
}
对于录屏,本身是没有通知的,不过当有录屏/屏幕镜像等时,它会设置UIScreen.main.isCaptured=true,我们可以观察这个属性变化来监听:
private func setupScreenRecordingObserver() {
NotificationCenter.default.addObserver(
self,
selector: #selector(screenCaptureStatusDidChange), // 监听变化
name: UIScreen.capturedDidChangeNotification,
object: nil
)
}
@objc private func screenCaptureStatusDidChange() {
print("屏幕捕获状态发生变化!")
checkScreenCaptureStatus()
}
private func checkScreenCaptureStatus() { // 初始时需要主动调用它,因为有可能在进入程序前就开始录屏了
let isCaptured = UIScreen.main.isCaptured
print("当前屏幕是否被捕获: \(isCaptured)")
}
上面都是检测是否有截屏录屏操作,若想要阻止截屏和录屏,可以在视图最上层添加一个隐藏的isSecureTextEntry,当出现它时ios会将整个屏幕变黑!
触摸行为采集
触摸行为是一种生物特征指纹,通过采集用户触摸屏幕时的物理参数(力度、接触面积、坐标轨迹等),可以区分真人操作与自动化脚本,也可用于用户身份的行为画像。腾讯优图TuringShield SDK中即有此类采集逻辑:
import UIKit
/// 触摸行为采集器 — 用于风控行为指纹
class TouchBehaviorCollector {
struct TouchSample {
let timestamp: TimeInterval
let phase: UITouch.Phase
let location: CGPoint
let force: CGFloat // 3D Touch 力度(0~6.67)
let majorRadius: CGFloat // 接触椭圆长轴半径(mm)
let majorRadiusTolerance: CGFloat
}
private var samples: [TouchSample] = []
/// 在 UIWindow 或目标 View 的 hitTest / sendEvent 中调用
func collectTouches(_ touches: Set<UITouch>, in view: UIView) {
for touch in touches {
let sample = TouchSample(
timestamp: touch.timestamp,
phase: touch.phase,
location: touch.location(in: view),
force: touch.force, // 需要设备支持 3D Touch / Haptic Touch
majorRadius: touch.majorRadius, // 手指接触面积
majorRadiusTolerance: touch.majorRadiusTolerance
)
samples.append(sample)
}
}
/// 提取行为特征向量
func extractFeatures() -> [String: Any] {
guard samples.count > 1 else { return [:] }
let forces = samples.map { $0.force }
let radii = samples.map { $0.majorRadius }
let intervals = zip(samples.dropFirst(), samples).map { $0.timestamp - $1.timestamp }
return [
"touch_count": samples.count,
"avg_force": forces.reduce(0, +) / CGFloat(forces.count),
"max_force": forces.max() ?? 0,
"avg_major_radius": radii.reduce(0, +) / CGFloat(radii.count),
"avg_interval_ms": intervals.isEmpty ? 0 : intervals.reduce(0, +) / Double(intervals.count) * 1000,
"force_variance": variance(forces),
"radius_variance": variance(radii)
]
}
private func variance(_ values: [CGFloat]) -> CGFloat {
let mean = values.reduce(0, +) / CGFloat(values.count)
return values.map { ($0 - mean) * ($0 - mean) }.reduce(0, +) / CGFloat(values.count)
}
}
关键参数说明:UITouch.force在支持3D Touch的设备上范围0~6.67(maximumPossibleForce),majorRadius反映手指接触面积,真人触摸通常在5~15mm范围且有自然抖动,自动化点击工具(如Xcode UI Test、Appium)产生的force通常为0且majorRadius固定不变。
辅助功能
辅助功能(如 VoiceOver、切换控制、辅助触控)常被自动化工具(如脚本精灵、云手机操作)利用,可以从以下几个维度进行检测和防御:
1.检测辅助功能是否开启,iOS提供了UIAccessibility框架,可以直接查询常用辅助功能的运行状态:
#import <UIKit/UIKit.h>
// 检测 VoiceOver 是否正在运行
BOOL isVoiceOverRunning = UIAccessibilityIsVoiceOverRunning();
// 检测“切换控制”(Switch Control)是否开启 (常用于自动化脚本)
BOOL isSwitchControlRunning = UIAccessibilityIsSwitchControlRunning();
// 检测“辅助触控”(AssistiveTouch)是否开启, Apple没有直接提供AssistiveTouch的公开API,但可以通过监控状态改变来推断
// 监听 VoiceOver 状态变化
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(accessibilityStatusChanged)
name:UIAccessibilityVoiceOverStatusDidChangeNotification
object:nil];
// 监听“切换控制”状态变化
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(accessibilityStatusChanged)
name:UIAccessibilitySwitchControlStatusDidChangeNotification
object:nil];
2.当辅助功能(如VoiceOver)读取界面元素时,它会请求界面元素的accessibilityLabel或accessibilityValue,可以重写关键UI控件(如输入框、余额标签)的 accessibilityElement相关方法,如果这些方法被频繁调用,说明可能有外部工具在尝试“读取”内容,不过更好的方式是创建一个对用户不可见(或极小)但设置了 isAccessibilityElement = YES的View。如果这个 View 接收到了点击或聚焦事件,通常是自动化脚本在扫描界面
3.而对于“模拟操作” (写/操作),可以根据触摸特征分析,物理点击通常会有UITouchTypeDirect类型,且带有压力(Force)、半径(MajorRadius)等物理属性,而辅助功能模拟生成的点击事件可能缺失这些物理细节,或者其type属于UITouchTypeIndirect(间接点击)
生物识别增强
文档中原有的Face ID / Touch ID可用性检测之外,还应关注:
生物特征变更检测(domainStateData)
LAContext.evaluatedPolicyDomainState在每次指纹/面容增删后会改变,可用于检测用户生物特征被修改(如攻击者添加了自己的指纹):
import LocalAuthentication
let context = LAContext()
context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
if let stateData = context.evaluatedPolicyDomainState {
let hash = stateData.map { String(format: "%02hhx", $0) }.joined()
// 对比上次存储的 hash,变化 = 生物特征被修改
}
设备密码检测
通过.deviceOwnerAuthentication策略检测设备是否设置了锁屏密码:
let context = LAContext()
var error: NSError?
let hasPasscode = context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error)
// true = 设备已设置密码(包括生物识别解锁失败后的备用密码)
进程自身信息
采集当前进程自身的运行时元数据,这些字段很难被Hook框架整体伪造,是高可信度的环境真实性信号。
可执行文件路径一致性
通过proc_pidpath获取进程的实际磁盘路径,与Bundle.main.executablePath对比。重打包/注入的IPA两者路径不一致:
let myPID = getpid()
let kProcPidPathinfoMaxSize: Int = 4096
typealias ProcPidpathFn = @convention(c) (Int32, UnsafeMutableRawPointer, UInt32) -> Int32
if let sym = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "proc_pidpath") {
let fn = unsafeBitCast(sym, to: ProcPidpathFn.self)
var pathBuf = [CChar](repeating: 0, count: kProcPidPathinfoMaxSize)
let ret = fn(myPID, &pathBuf, UInt32(kProcPidPathinfoMaxSize))
let executablePath = ret > 0 ? String(cString: pathBuf) : nil
// 与 Bundle 路径对比
let bundlePath = Bundle.main.executablePath
let pathsMatch = (executablePath == bundlePath) // 不一致 = 重打包嫌疑
}
进程资源信息(task_info)
通过Mach task API获取常驻内存、虚拟内存和线程数:
var basicInfo = task_basic_info()
var count = mach_msg_type_number_t(MemoryLayout<task_basic_info>.size / MemoryLayout<natural_t>.size)
let kr = withUnsafeMutablePointer(to: &basicInfo) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
task_info(mach_task_self_, task_flavor_t(TASK_BASIC_INFO), $0, &count)
}
}
if kr == KERN_SUCCESS {
let residentMemory = basicInfo.resident_size // 常驻内存(字节)
let virtualMemory = basicInfo.virtual_size // 虚拟内存
}
// 线程数
var threadList: thread_act_array_t? = nil
var threadCount: mach_msg_type_number_t = 0
task_threads(mach_task_self_, &threadList, &threadCount)
// threadCount = 当前线程数
进程启动时间
通过sysctl KERN_PROC获取进程启动时间戳:
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()]
var kinfo = kinfo_proc()
var size = MemoryLayout<kinfo_proc>.size
sysctl(&mib, u_int(mib.count), &kinfo, &size, nil, 0)
let tv = kinfo.kp_proc.p_starttime
let startTime = Date(timeIntervalSince1970: TimeInterval(tv.tv_sec) + TimeInterval(tv.tv_usec) / 1_000_000)
CPU 使用率
通过thread_basic_info聚合所有线程的CPU使用率:
var threadList: thread_act_array_t? = nil
var threadCount: mach_msg_type_number_t = 0
task_threads(mach_task_self_, &threadList, &threadCount)
var totalCPU: Double = 0
for i in 0..<Int(threadCount) {
var threadInfo = thread_basic_info()
var infoCount = mach_msg_type_number_t(THREAD_INFO_MAX)
withUnsafeMutablePointer(to: &threadInfo) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(infoCount)) {
thread_info(threadList![i], thread_flavor_t(THREAD_BASIC_INFO), $0, &infoCount)
}
}
if (threadInfo.flags & TH_FLAGS_IDLE) == 0 {
totalCPU += Double(threadInfo.cpu_usage) / Double(TH_USAGE_SCALE) * 100.0
}
}
系统完整性信号
代码签名状态(csops)
通过csops()系统调用获取代码签名标志位,可检测临时签名(盗版)、调试允许位、强化运行时等:
var csFlags: UInt32 = 0
typealias CsopsFn = @convention(c) (pid_t, UInt32, UnsafeMutableRawPointer, Int) -> Int32
if let sym = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "csops") {
let fn = unsafeBitCast(sym, to: CsopsFn.self)
fn(getpid(), 0 /* CS_OPS_STATUS */, &csFlags, MemoryLayout<UInt32>.size)
}
let isAdHocSigned = (csFlags & 0x0000_0002) != 0 // CS_ADHOC — 临时签名(开发/盗版)
let isDebuggingAllowed = (csFlags & 0x1000_0000) != 0 // CS_DEBUGGED
let isHardened = (csFlags & 0x0001_0000) != 0 // CS_RUNTIME — 强化运行时
let entitlementsValid = (csFlags & 0x0002_0000) != 0 // CS_ENTITLEMENTS_VALIDATED
可执行文件完整性
通过stat()获取可执行文件大小和ctime,重打包/二进制修改后会变化:
if let execPath = Bundle.main.executablePath {
var st = stat()
if stat(execPath, &st) == 0 {
let fileSize = st.st_size // 文件大小
let ctime = st.st_ctimespec // 元数据最后修改时间
}
}
数据保护等级
检查应用Documents目录的NSFileProtectionKey,正常App应有数据保护:
if let docsURL = try? FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false) {
let attrs = try? FileManager.default.attributesOfItem(atPath: docsURL.path)
let protectionClass = attrs?[.protectionKey] as? String
// 正常值: NSFileProtectionComplete / CompleteUnlessOpen / CompleteUntilFirstUserAuthentication
}
网络指纹增强
通过主动探测采集网络层延迟和特征,这些值在真实设备、模拟器、云手机之间有显著差异。
DNS 解析延迟
使用getaddrinfo解析apple.com,测量延迟。真实设备通常10~200ms,模拟器/云手机可能极快或极慢:
let start = mach_absolute_time()
var hints = addrinfo()
hints.ai_family = AF_UNSPEC
hints.ai_socktype = SOCK_STREAM
var res: UnsafeMutablePointer<addrinfo>? = nil
let ret = getaddrinfo("apple.com", nil, &hints, &res)
let elapsed = mach_absolute_time() - start
if ret == 0 { freeaddrinfo(res!) }
var info = mach_timebase_info_data_t()
mach_timebase_info(&info)
let dnsMs = Double(elapsed) * Double(info.numer) / Double(info.denom) / 1_000_000
TCP 握手延迟
非阻塞connect到1.1.1.1:443测量TCP三次握手延迟:
let sock = socket(AF_INET, SOCK_STREAM, 0)
fcntl(sock, F_SETFL, fcntl(sock, F_GETFL, 0) | O_NONBLOCK)
var addr = sockaddr_in()
addr.sin_family = sa_family_t(AF_INET)
addr.sin_port = UInt16(443).bigEndian
inet_pton(AF_INET, "1.1.1.1", &addr.sin_addr)
let start = mach_absolute_time()
// connect + select(timeout=1s) 等待完成
// 完成后 elapsed = mach_absolute_time() - start → 转换为 ms
Socket TTL
本机socket默认TTL(通常64),VPN/隧道可能改变此值:
let sock = socket(AF_INET, SOCK_STREAM, 0)
var ttl: Int32 = 0
var len = socklen_t(MemoryLayout<Int32>.size)
getsockopt(sock, IPPROTO_IP, IP_TTL, &ttl, &len)
// 正常值: 64 (iOS/macOS), VPN 可能为 128 或其他
close(sock)
出口端口范围
通过bind(0) + getsockname获取操作系统分配的临时端口号,可体现系统端口分配策略:
let sock = socket(AF_INET, SOCK_DGRAM, 0)
var addr = sockaddr_in()
addr.sin_family = sa_family_t(AF_INET)
addr.sin_port = 0
addr.sin_addr.s_addr = INADDR_ANY
// bind → getsockname → 获取分配的端口号
// 归到 1024 端口段:如 49152-50175
IPv6 可用性 & 多接口检测
云手机通常只有1个网络接口且无IPv6,真实设备通常有多个接口(en0/pdp_ip0/awdl0等):
// IPv6: 遍历 getifaddrs 查找非环回 AF_INET6 地址
// 多接口: 统计活跃非环回接口数量,>= 2 则为多接口设备
网络代理与抓包检测
系统代理检测
检测是否使用了代理。除了HTTP代理,还应检测HTTPS和SOCKS代理(抓包工具常用SOCKS):
// CFNetworkCopySystemProxySettings() 读取系统代理配置
// 检测 HTTP/HTTPS/SOCKS 三类代理是否启用
// HTTPEnable + HTTPProxy + HTTPPort
// HTTPSEnable + HTTPSProxy + HTTPSPort
// SOCKSEnable + SOCKSProxy + SOCKSPort
// Charles/Burp Suite/Surge 等抓包工具会在系统层注册代理
// 如
import SystemConfiguration
func getProxyInfo() -> (http: Bool, https: Bool, socks: Bool) {
guard let settings = CFNetworkCopySystemProxySettings()?.takeRetainedValue() as? [String: Any] else {
return (false, false, false)
}
// HTTP 代理
let httpEnabled = (settings[kCFNetworkProxiesHTTPEnable as String] as? NSNumber)?.boolValue ?? false
let httpHost = settings[kCFNetworkProxiesHTTPProxy as String] as? String
let httpPort = settings[kCFNetworkProxiesHTTPPort as String] as? Int
// HTTPS 代理(iOS 用字符串 key,kCFNetworkProxiesHTTPS* 不可用)
let httpsEnabled = (settings["HTTPSEnable"] as? NSNumber)?.boolValue ?? false
let httpsHost = settings["HTTPSProxy"] as? String
let httpsPort = settings["HTTPSPort"] as? Int
// SOCKS 代理
let socksEnabled = (settings["SOCKSEnable"] as? NSNumber)?.boolValue ?? false
let socksHost = settings["SOCKSProxy"] as? String
let socksPort = settings["SOCKSPort"] as? Int
return (httpEnabled, httpsEnabled, socksEnabled)
}
VPN/隧道接口检测
// 探针1: getifaddrs() 枚举网络接口名
let vpnPrefixes = ["utun", "tap", "tun", "ppp", "ipsec"]
// utun — macOS/iOS 内置 VPN(WireGuard/L2TP/IKEv2)
// tap — OpenVPN 虚拟以太网
// ppp — PPTP/L2TP 拨号 VPN
// ipsec — IPSec 隧道
// Charles/Surge 透明代理也会产生 utun 接口
import Foundation
func isVPNConnected() -> Bool {
var ifaddr: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&ifaddr) == 0 else { return false }
defer { freeifaddrs(ifaddr) }
var cursor = ifaddr
while let pointer = cursor {
let interface = pointer.pointee
let name = String(cString: interface.ifa_name)
if name.starts(with: "utun") || name.starts(with: "ppp") {
if (interface.ifa_flags & UInt32(IFF_UP)) != 0 {
return true
}
}
cursor = interface.ifa_next
}
return false
}
// 探针2: AF_SYSTEM utun socket 内核探测(绕过 getifaddrs hook)
let sock = socket(32 /* AF_SYSTEM */, SOCK_DGRAM, 2 /* SYSPROTO_CONTROL */)
// 验证内核 utun 控制接口是否可用,与 getifaddrs 互补
// 探针3: NWPathMonitor(iOS 12+ Network.framework)
// NWInterface.InterfaceType.other 是 Apple 官方为 VPN/隧道保留的接口类型
// 相比 getifaddrs 枚举名称,此方法基于系统语义更准确
import Network
let monitor = NWPathMonitor()
monitor.pathUpdateHandler = { path in
var vpnIfaceNames: [String] = []
for iface in path.availableInterfaces {
// .other 类型 = VPN/隧道接口(系统内部 utun 不会出现在此列表中)
if iface.type == .other {
vpnIfaceNames.append(iface.name)
}
// 兜底: ppp 前缀接口(L2TP/PPPoE)
if iface.name.starts(with: "ppp") {
vpnIfaceNames.append(iface.name)
}
}
let isVPN = !vpnIfaceNames.isEmpty
}
monitor.start(queue: DispatchQueue.global())
// 优势: 不依赖接口命名规则,由系统明确标记隧道接口
// 同时可获取连接类型: .wifi / .cellular / .wiredEthernet / .other
调试器 & Hook框架检测
基础单探针实现(P_TRACED)
最简单的调试器检测:通过sysctl读取进程标志,检查P_TRACED位:
import Foundation
func isDebuggerAttached() -> Bool {
var info = kinfo_proc()
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()]
var size = MemoryLayout<kinfo_proc>.stride
let junk = sysctl(&mib, UInt32(mib.count), &info, &size, nil, 0)
if junk == 0 {
return (info.kp_proc.p_flag & P_TRACED) != 0
}
return false
}
sysctl p_stat == SSTOP (断点检测)
通过sysctl检查进程状态,若为SSTOP (3)则说明进程当前正被调试器断点挂起:
func isProcessStopped() -> Bool {
var info = kinfo_proc()
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()]
var size = MemoryLayout<kinfo_proc>.stride
let result = sysctl(&mib, UInt32(mib.count), &info, &size, nil, 0)
if result == 0 {
return Int32(info.kp_proc.p_stat) == SSTOP // SSTOP = 3
}
return false
}
ptrace PT_DENY_ATTACH
typealias PtraceFunc = @convention(c) (Int32, pid_t, caddr_t?, Int32) -> Int32
let ptraceFunc = unsafeBitCast(dlsym(UnsafeMutableRawPointer(bitPattern: -2), "ptrace"), to: PtraceFunc.self)
let ret = ptraceFunc(31 /* PT_DENY_ATTACH */, 0, nil, 0)
// ret != 0 且 errno == ENOTSUP/EPERM = 已被调试器附加
越狱工具端口扫描
通过TCP connect探测本地回环地址上的已知越狱/调试工具端口:
import Darwin
/// 端口扫描检测 — 仅探测 loopback 127.0.0.1
func scanJailbreakToolPorts() -> [Int32] {
let knownPorts: [(port: Int32, tool: String)] = [
(22, "SSH/OpenSSH (越狱 sshd)"),
(44, "Checkra1n (越狱工具内置服务)"),
(4444, "Cycript (动态调试 REPL)"),
(8022, "SSH 备用端口 (某些越狱默认)"),
(27042, "frida-server (默认监听)"),
(27043, "frida-server (辅助端口)"),
(46952, "XXT 自动化框架"),
]
var detected: [Int32] = []
for (port, _) in knownPorts {
let sock = socket(AF_INET, SOCK_STREAM, 0)
guard sock >= 0 else { continue }
defer { close(sock) }
var addr = sockaddr_in()
addr.sin_family = sa_family_t(AF_INET)
addr.sin_port = UInt16(port).bigEndian
addr.sin_addr.s_addr = inet_addr("127.0.0.1")
// 设置非阻塞 + 超时(避免长时间等待)
var timeout = timeval(tv_sec: 0, tv_usec: 100_000) // 100ms
setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout<timeval>.size))
let result = withUnsafePointer(to: &addr) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
connect(sock, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
if result == 0 {
detected.append(port) // 端口开放 = 越狱工具正在运行
}
}
return detected
}
Frida 动态注入检测
下面多种方式可交叉验证:
// 1. TCP 27042 端口探测(frida-server 默认监听)
// 2. vm_region_recurse_64 扫描进程内存中 "LIBFRIDA"/"frida-agent" 魔数
// 3. sysctl KERN_PROC_ALL 扫描 frida-server 进程名
// 4. _dyld_get_image_name 扫描 FridaGadget/frida-agent 动态库
// 5. access()/stat() 检测 /tmp/frida-* IPC socket 文件
// 6. dlsym(RTLD_NEXT, "access") 绕过 Hook 直接检测 Frida 文件
父进程检测(getppid)
正常App的父进程应为launchd(PID=1),由调试器启动则ppid != 1,同时可以对比getppid()与mac_syscall(SYS_getppid)的返回值,普通Hook框架(如fishhook)只能Hook用户态libc中的getppid函数,而mac_syscall是直接发出的内核调用,如果两者返回值不同,说明libc中的getppid被篡改了:
// libc getppid() vs syscall(SYS_getppid) 直接内核调用
// 两者不一致 = getppid 被 hook
isatty + ioctl 终端检测
let isStdoutTTY = isatty(STDOUT_FILENO) != 0
// 正常 App: stdout 非 TTY; 调试器环境: 连接到 TTY
var winSize = winsize()
ioctl(STDOUT_FILENO, TIOCGWINSZ, &winSize)
// ret == 0 = 有终端窗口 = 调试器环境
USB 充电状态辅助判断
UIDevice.batteryState为 .charging时结合其他探针提高置信度(advisory信号)。
VPN/隧道检测与系统代理检测详见「网络代理与抓包检测」章节。
重打包检测
涵盖Bundle ID / 签名 / APNS / App Attest等服务端联动方案,以及本地增强探针。
基础方案概览
重打包有多种检测方式,下面分别说明。 1.根据签名和Bundle Identifier 先说bundle id,直接查就好了,但有可能会被hook,所以要用多种方式查询:
Bundle.main.bundleIdentifier
再看签名(SignerIdentity):
正式App Store发布的包infoDictionary中不含SignerIdentity键。若该键存在,说明应用被开发者证书重新签名:
if let info = Bundle.main.infoDictionary, info["SignerIdentity"] != nil {
// ⚠️ 发现重签名迹象 (SignerIdentity 存在)
}
再看mobileprovision:
guard let profilePath = Bundle.main.path(forResource: "embedded", ofType: "mobileprovision") else {
return false // App Store 版本没有这个文件,此方法主要用于非App Store渠道
}
do {
let profileData = try String(contentsOfFile: profilePath, encoding: .ascii)
// 这是一个简化的检查,仅查找Team ID字符串是否存在
// 更严格的检查需要完整解析这个plist文件
return profileData.contains(originalTeamID)
} catch {
return false
}
2.根据APNS APNS与App的Bundle Identifier和签名证书是强绑定的,一个合法的App实例在注册推送通知时,会从Apple服务器获取一个针对该App和该设备的唯一Push Token,服务器可以使用APNS证书向这个Push Token发送通知。如果App被重打包,它的Bundle Identifier和签名都变了,因此它从Apple获取的Push Token将与APNS证书不匹配,服务器尝试向这个无效的token发送通知时将会失败:
import UIKit
import UserNotifications
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
registerForPushNotifications()
return true
}
func registerForPushNotifications() { // 请求推送通知权限
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
print("Permission granted: \(granted)")
guard granted else { return }
DispatchQueue.main.async { // 获取权限后,在主线程注册
UIApplication.shared.registerForRemoteNotifications()
}
}
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { // 成功获取到 Push Token 后的回调
let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined() // 将二进制的 token 转换成十六进制字符串,以便发送给服务器
print("Successfully registered for notifications. Device Token: \(tokenString)")
sendTokenToServerForValidation(token: tokenString) // 将 token 发送到开发者服务器进行验证
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { // 注册失败的回调
print("Failed to register for notifications: \(error.localizedDescription)")
}
private func sendTokenToServerForValidation(token: String) { // 发送 Token 到服务器的辅助函数
guard let url = URL(string: "https://ios-fp.betamao.com/validate-token") else { return } // 开发者的服务器地址
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body = ["device_token": token]
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
URLSession.shared.dataTask(with: request).resume()
}
}
服务端再尝试推送静默消息即可(再进一步可以让客户端将推送的Nonce上报),推送也有两种方式,这里直接以p8证书去推:
apns_client = APNsClient(
team_id=TEAM_ID,
auth_key_id=KEY_ID,
auth_key_filepath=KEY_FILE_PATH, # .p8 签名文件
topic=APP_BUNDLE_ID,
use_sandbox=False # True为开发环境
)
@app.route('/validate-token', methods=['POST'])
def validate_token():
"""接收客户端发来的 device_token 并尝试发送静默推送进行验证"""
data = request.get_json()
if not data or 'device_token' not in data:
return jsonify({"status": "error", "message": "Missing device_token"}), 400
device_token = data['device_token']
print(f"Received token for validation: {device_token}")
payload = Payload(content_available=True) # 构造一个静默推送 (silent push):content-available=1, 但没有 alert, sound, badge
try:
apns_client.send_notification(device_token, payload) # 尝试向这个 token 发送推送
print(f"SUCCESS: Token {device_token} is valid for bundle ID {APP_BUNDLE_ID}.") # 如果代码能执行到这里,没有抛出异常,说明Apple服务器接受了这个token,没问题
return jsonify({"status": "success", "message": "Token is valid"}), 200
except Exception as e:
print(f"FAILURE: Token {device_token} is INVALID. Reason: {e}") # 如果发送失败,apns2库会抛出异常(BadDeviceToken 错误)
return jsonify({"status": "error", "message": f"Token is invalid: {e}"}), 400
3.根据ATTest 这就是Apple出的专门用来对抗重打包的机制,属于DeviceCheck中的一项,它也是使用硬件级的安全特性(Secure Enclave)来向服务器提供加密证明,证实与服务器通信的确实是未经修改的、正版的App:
import DeviceCheck
import CryptoKit // For SHA256
class AppAttestManager {
let attestURL = URL(string: "https://ios-fp.betamao.com/submit-attestation")!
let protectedAPI_URL = URL(string: "https://ios-fp.betamao.com/protected-api")!
var keyId: String?
func attestDevice() { // 生成密钥并获取证明,然后发送给服务器
let attestService = DCAppAttestService.shared
guard attestService.isSupported else { return }
attestService.generateKey { [weak self] (keyId, error) in // 生成密钥对
guard let keyId = keyId else { return }
self?.keyId = keyId
let serverChallenge = "one-time-challenge-from-server".data(using: .utf8)! // 从服务器获取的一个challenge (一次性的)
let challengeHash = Data(SHA256.hash(data: serverChallenge))
attestService.attestKey(keyId, clientDataHash: challengeHash) { (attestationObject, error) in // 请求Apple对密钥进行证明
guard let attestation = attestationObject else { return }
self?.sendAttestationToServer(keyId: keyId, attestation: attestation) // 将 keyId 和 attestationObject 发送到服务器
}
}
}
func accessProtectedAPI() { // 为受保护的API请求生成断言(签名)
guard let keyId = self.keyId else {
print("Key ID not available. Please attest first.")
return
}
let requestBody = ["message": "hello world"] // 准备要发送的请求体
let requestData = try! JSONSerialization.data(withJSONObject: requestBody)
let requestHash = Data(SHA256.hash(data: requestData)) // 对请求体进行哈希
DCAppAttestService.shared.generateAssertion(keyId, clientDataHash: requestHash) { [weak self] (assertionObject, error) in // 使用硬件私钥对哈希进行签名,生成断言
guard let assertion = assertionObject else { return }
self?.sendRequestWithAssertion(requestData: requestData, keyId: keyId, assertion: assertion) // 将原始请求和断言一起发送到服务器
}
}
private func sendAttestationToServer(keyId: String, attestation: Data) {
// ... 发送到开发者服务端
}
private func sendRequestWithAssertion(requestData: Data, keyId: String, assertion: Data) {
var request = URLRequest(url: protectedAPI_URL)
request.httpMethod = "POST"
request.httpBody = requestData
request.setValue(keyId, forHTTPHeaderField: "X-Key-Id") // 将 keyId 和断言(签名)放在请求头中
request.setValue(assertion.base64EncodedString(), forHTTPHeaderField: "X-Assertion-Signature")
// ... URLSession code ...
}
}
Bundle ID 一致性(增强)
// 对比 Info.plist CFBundleIdentifier 与 Bundle.main.bundleIdentifier
// 运行时两者不一致 = 重打包后 plist 被篡改
embedded.mobileprovision 检测
正版应用包含描述文件,重打包可能缺失或被替换。
越狱检测
使用anyMatch策略(任一检测为正即判定越狱)。涵盖文件系统、动态库、环境变量、URL Scheme、写权限、进程派生等18个维度。
基础探针示例
越狱检测的核心思路: 1. 是否存在常见越狱文件 2. 是否存在常见越狱后的软件 3. 是否有超出正常应用的权限
import UIKit
func isDeviceJailbroken() -> Bool {
let jailbreakFilePaths = [
"/Applications/Cydia.app",
"/Library/MobileSubstrate/MobileSubstrate.dylib",
"/bin/bash",
"/usr/sbin/sshd",
"/etc/apt"
]
for path in jailbreakFilePaths { // 检查常见的越狱文件
if FileManager.default.fileExists(atPath: path) { return true }
}
if let cydiaURL = URL(string: "cydia://"), UIApplication.shared.canOpenURL(cydiaURL) { // 例:检查是否可以打开 Cydia 的 URL Scheme
return true
}
do {
try "jb_test".write(toFile: "/private/jb_test.txt", atomically: true, encoding: .utf8) // 检查是否可以写入系统目录
try? FileManager.default.removeItem(atPath: "/private/jb_test.txt")
return true
} catch {
// 写入失败是正常的
}
return false
}
越狱文件系统检测(四路探针交叉验证)
使用FileManager + stat/open/access四个独立探针检测同一批路径,覆盖Cydia/Unc0ver/Checkra1n/Palera1n/Dopamine/Roothide/Sileo体系:
let jailbreakPaths = [
// Cydia & Classic
"/Applications/Cydia.app", "/Library/MobileSubstrate/MobileSubstrate.dylib",
"/private/var/lib/apt", "/private/var/lib/cydia",
// MobileSubstrate / libhooker / Substitute
"/usr/lib/libsubstrate.dylib", "/usr/lib/libsubstitute.dylib",
"/usr/lib/libhooker.dylib", "/usr/lib/TweakInject.dylib",
// Sileo / Zebra / Installer 5
"/Applications/Sileo.app", "/var/jb/Applications/Sileo.app",
// Unc0ver
"/.installed_unc0ver", "/var/lib/undecimus",
// Checkra1n
"/Applications/checkra1n.app", "/.bootstrapped_electra",
// Palera1n(rootless + rootful)
"/.palecursus", "/.installed_palera1n", "/var/jb/.installed_palera1n",
"/var/jb/usr/bin/apt", "/var/jb/usr/lib/libsubstrate.dylib",
// Dopamine(iOS 15-16 rootless)
"/.installed_dopamine", "/var/jb/.installed_dopamine",
// Roothide
"/.installed_roothide", "/var/jb/.bootstrapped",
// SSH/工具链
"/usr/bin/ssh", "/var/jb/usr/bin/ssh", "/usr/bin/cycript",
]
// ⚠️ /bin/sh、/bin/bash、/bin/zsh 在正常 iOS 真机上也存在(系统内置),不可作为越狱判据
URL Scheme 越狱应用检测
let jailbreakSchemes: [(String, String)] = [
("cydia://", "Cydia"),
("sileo://", "Sileo"),
("zbra://", "Zebra"),
("undecimus://", "Undecimus"),
("activator://", "Activator"),
("winterboard://", "WinterBoard"),
]
// UIApplication.shared.canOpenURL(url) — 返回 true = 越狱应用已安装
环境变量检测(ProcessInfo + getenv 双路验证)
let jailbreakEnvKeys = [
"DYLD_INSERT_LIBRARIES", // 动态库注入核心变量
"_MSSafeMode", // MobileSubstrate 安全模式标志
"_SafeMode", // 通用安全模式标志
"SUBSTRATE_EXEC", // Substrate 执行环境
"DYLD_LIBRARY_PATH", // 自定义库搜索路径
"DYLD_FRAMEWORK_PATH", // 自定义框架路径
"TweakInject", // TweakInject 环境
"TweakLoader", // TweakLoader 环境
]
// 探针1: ProcessInfo.processInfo.environment[key](Foundation 层)
// 探针2: getenv(key)(C stdlib 层)
// 两者不一致 = Foundation 层被 hook
environ 全局数组直接遍历(绕过 getenv Hook)
阿里安全SDK使用的更底层检测方式 — 不通过getenv() API,而是直接遍历C运行时的environ全局指针数组。这样即使getenv被Hook返回NULL,也能检测到注入变量:
#include <string.h>
// environ 是 libc 维护的全局环境变量数组指针
extern char **environ;
/// 直接遍历 environ 数组检测注入
/// getenv hook 无法覆盖此检测路径
bool detectSubstrateViaEnviron(void) {
if (environ == NULL) return false;
const char *targets[] = {
"MobileSubstrate", // 大小写均检查
"mobilesubstrate",
"SUBSTRATE",
"substrate",
"TweakInject",
"DYLD_INSERT",
NULL
};
for (char **env = environ; *env != NULL; env++) {
for (int i = 0; targets[i] != NULL; i++) {
if (strstr(*env, targets[i]) != NULL) {
return true; // 发现注入环境变量
}
}
}
return false;
}
注意:反检测方案需要从environ数组本身物理删除包含关键字的条目(指针重排),仅Hook getenv/setenv不够。
statfs 文件系统只读属性检测
正常iOS设备的/System和/分区为只读文件系统(SSV,Signed System Volume)。越狱后这些分区可能被重新挂载为可写,通过statfs可以检测此变更:
import Darwin
/// 检测关键系统分区是否从只读变为可写
func checkFileSystemReadOnly() -> Bool {
let criticalPaths = ["/", "/System"]
for path in criticalPaths {
var stat = statfs()
if statfs(path, &stat) == 0 {
// MNT_RDONLY (0x0001): 文件系统只读标志
let isReadOnly = (stat.f_flags & UInt32(MNT_RDONLY)) != 0
if !isReadOnly {
return true // 越狱: 系统分区被重新挂载为可写
}
// 额外检查: f_type 和 f_fstypename 是否异常
let fsType = withUnsafePointer(to: &stat.f_fstypename) {
$0.withMemoryRebound(to: CChar.self, capacity: Int(MFSTYPENAMELEN)) {
String(cString: $0)
}
}
// 正常应为 "apfs",出现 "hfs"/"union" 等可能为越狱挂载
}
}
return false
}
系统写入权限检测
let testPaths = [
"/private/jailbreak_test_<random>.txt",
"/var/tmp/jailbreak_test_<random>.txt",
"/etc/jailbreak_test_<random>.txt",
]
// try "test".write(toFile: path, atomically: true, encoding: .utf8)
// 写入成功 = 沙盒隔离被越狱破坏,具有系统级写权限
// 正常沙盒中写入 /private/、/etc/ 应抛出 Permission denied
dlopen(RTLD_NOLOAD) 动态库探针
// 不加载,仅检查目标库是否已在进程中存在
let suspiciousLibs = ["libsubstrate.dylib", "libsubstitute.dylib", ...]
for lib in suspiciousLibs {
if dlopen(lib, RTLD_NOLOAD) != nil {
// 动态链接器确认已加载 — 与 _dyld_get_image_name 探针交叉验证
}
}
动态库注入检测(_dyld_get_image_name + dlopen)
通过遍历已加载动态库列表,匹配30+ 越狱/Hook框架关键词:
import MachO
let suspiciousKeywords = [
"MobileSubstrate", "SubstrateLoader", "libsubstrate", "libsubstitute",
"libhooker", "TweakInject", "Cycript", "cynject", "FridaGadget",
"frida-agent", "ElleKit", "ellekit", "rocketbootstrap",
"SSLKillSwitch", "objection", "iSpy", "TweakLoader"
]
let imageCount = _dyld_image_count()
for i in 0..<imageCount {
guard let namePtr = _dyld_get_image_name(i) else { continue }
let imagePath = String(cString: namePtr)
for keyword in suspiciousKeywords {
if imagePath.localizedCaseInsensitiveContains(keyword) {
// 发现可疑注入库
}
}
}
vm_read 内存注入检测(绕过 _dyld_get_image_name hook)
_dyld_get_image_name本身可以被fishhook/Dobby hook来隐藏注入库。本技术绕过dyld API,通过Mach内核接口直接读取进程内存:
// 1. task_info(TASK_DYLD_INFO) 获取 dyld_all_image_infos 地址
var dyldInfo = task_dyld_info_data_t()
var count = mach_msg_type_number_t(MemoryLayout<task_dyld_info_data_t>.size / MemoryLayout<natural_t>.size)
withUnsafeMutablePointer(to: &dyldInfo) { ptr in
ptr.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { rebound in
task_info(mach_task_self_, task_flavor_t(TASK_DYLD_INFO), rebound, &count)
}
}
// 2. vm_read 直接读取 dyld_all_image_infos 结构体
var data: vm_offset_t = 0
var dataCount: mach_msg_type_number_t = 0
vm_read(mach_task_self_, vm_address_t(dyldInfo.all_image_info_addr), 64, &data, &dataCount)
// 解析 infoArrayCount 与 _dyld_image_count() 对比
// 数量不一致 = 有镜像被 hook 隐藏
来源:腾讯Turing v2.0075 injectingImageNames逆向发现。
沙盒完整性检测
尝试访问其他应用的沙盒目录(正常App应被权限拒绝):
let sandboxPaths = [
"/var/mobile/Applications",
"/var/mobile/Containers/Bundle/Application"
]
for path in sandboxPaths {
let fd = Darwin.open(path, O_RDONLY)
if fd >= 0 {
// 沙盒隔离已被越狱破坏
Darwin.close(fd)
}
}
mmap 共享内存沙盒检测
阿里安全SDK使用的独特越狱检测方式:在沙盒内创建文件后尝试以MAP_SHARED + PROT_WRITE映射。正常iOS沙盒禁止共享可写内存映射(代码签名强制策略),越狱后此限制被移除:
import Foundation
func checkMmapSandbox() -> Bool {
let path = NSHomeDirectory() + "/Documents/tmpcheck"
FileManager.default.createFile(atPath: path, contents: Data([0x41]), attributes: nil)
defer { try? FileManager.default.removeItem(atPath: path) }
let fd = open(path, O_RDWR)
guard fd >= 0 else { return false }
defer { close(fd) }
let mapped = mmap(nil, 1, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0)
if mapped != MAP_FAILED {
munmap(mapped, 1)
return true // 越狱:MAP_SHARED 可写映射成功
}
return false // 正常:映射被拒绝
}
// 原理: iOS 代码签名强制策略要求所有可执行内存都有有效签名
// MAP_SHARED + PROT_WRITE 在未越狱设备上会被内核拒绝
// 阿里 SDK 实现: 写入 Documents/asdtemp → mmap(PROT_READ|PROT_WRITE, MAP_SHARED) 测试
符号链接检测(lstat + readlink)
越狱后系统目录常被替换为指向越狱分区的符号链接:
let symlinkCheckPaths = [
"/var/jb", // rootless 越狱指向 /private/preboot
"/var/lib/dpkg", // 存在即可疑
"/var/stash",
"/Applications" // 可能指向 /var/stash
]
for path in symlinkCheckPaths {
var linkBuf = [CChar](repeating: 0, count: 256)
let len = Darwin.readlink(path, &linkBuf, 255)
if len > 0 {
let target = String(cString: linkBuf)
// 发现异常符号链接
}
}
受限目录写入测试(沙盒越界检测)
正常iOS App被严格限制在自身沙盒目录内,越狱后文件系统保护被移除,可以写入原本受限的系统目录。通过尝试在多个受限路径创建/写入文件来检测越狱:
/// 受限目录写入测试 — 正常 App 所有尝试应失败
func testRestrictedDirectoryWrite() -> Bool {
let restrictedPaths = [
"/", // 根目录
"/root/", // root 用户目录
"/private/", // 私有目录
"/var/root/", // root 家目录
"/etc/", // 系统配置目录
"/var/jb/", // rootless 越狱目录
"/jb/", // 越狱快捷方式
"/tmp/.jb_test" // 临时目录(某些越狱也解除保护)
]
for dir in restrictedPaths {
let testFile = dir + ".sandbox_probe_\(arc4random())"
let fd = open(testFile, O_WRONLY | O_CREAT | O_TRUNC, 0o644)
if fd >= 0 {
close(fd)
unlink(testFile) // 清理
return true // 越狱: 成功写入受限目录
}
}
return false // 正常: 所有写入均被拒绝
}
进程派生沙盒逃逸(posix_spawn & fork)
iOS沙盒中普通App无法派生子进程,越狱设备内核补丁解除了此限制:
// 探针1: posix_spawn
var pid: pid_t = 0
let path = "/bin/sh"
// posix_spawn(&pid, path, nil, nil, argv, env)
// 返回 0 且 pid > 0 = 内核沙盒已被越狱解除
// 正常设备返回 EPERM/EACCES
// 探针2: fork()
let childPid = fork()
if childPid >= 0 {
if childPid == 0 { exit(0) } // 子进程立即退出
// fork 成功 = 内核沙盒已被越狱解除
}
sysctl 内核参数检测
通过内核接口检测调试标志和可疑进程:
// P_TRACED 标志检测
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()]
var info = kinfo_proc()
var size = MemoryLayout<kinfo_proc>.stride
sysctl(&mib, 4, &info, &size, nil, 0)
let isTraced = (info.kp_proc.p_flag & P_TRACED) != 0
// KERN_PROC_ALL 进程名扫描
let suspiciousProcessNames = [
"substrated", "substituted", "jailbreakd", "sshd",
"frida-server", "palera1nd", "dopamined", "cydia"
]
Mach 内核端口检测
尝试获取PID 0(内核)的task port或主I/O端口,正常App被沙盒拒绝:
// 探针1: task_for_pid
var kernelTask = mach_port_t(MACH_PORT_NULL)
let kr = task_for_pid(mach_task_self_, 0, &kernelTask)
// kr == KERN_SUCCESS = 内核沙盒限制被越狱解除
// 探针2: host_get_io_master
var ioMaster = mach_port_t(MACH_PORT_NULL)
let kr2 = host_get_io_master(mach_host_self(), &ioMaster)
// kr2 == KERN_SUCCESS = 发现越狱
ObjC 运行时 Hook 检测(IMP 溯源)
通过class_getMethodImplementation + dladdr反查系统方法IMP所在模块:
let targets: [(String, String)] = [
("NSFileManager", "fileExistsAtPath:"),
("UIApplication", "canOpenURL:")
]
for (className, selName) in targets {
guard let cls = NSClassFromString(className) else { continue }
let imp = class_getMethodImplementation(cls, NSSelectorFromString(selName))
var info = Dl_info()
dladdr(unsafeBitCast(imp, to: UnsafeRawPointer.self), &info)
let module = String(cString: info.dli_fname!)
// 正常应来自 /System/Library 或 /usr/lib/libobjc
// 来自其他模块 = Method Swizzle
}
C 函数 Inline Hook 检测(13 个高危函数)
通过dladdr反查C函数地址所在模块,覆盖文件IO、进程、内存、网络四类:
let highRiskSymbols = [
"stat", "access", "open", "fopen", "read", // 文件 IO
"kill", "getpid", "fork", // 进程/信号
"dlopen", "dlsym", "mmap", // 内存/动态库
"connect", "getaddrinfo" // 网络
]
for symbol in highRiskSymbols {
guard let sym = dlsym(UnsafeMutableRawPointer(bitPattern: -2), symbol) else { continue }
var info = Dl_info()
dladdr(sym, &info)
let module = String(cString: info.dli_fname!)
// 正常: /usr/lib/system/ 或 /System/Library/
// 偏离 = Inline Hook
}
GOT/PLT Hook 检测(fishhook 类工具)
扫描Mach-O __DATA段中的 __got / __la_symbol_ptr / __nl_symbol_ptr节:
// 1. 遍历主 binary 的 Mach-O load commands (LC_SEGMENT_64)
// 2. 找到 __DATA/__DATA_CONST 段中的符号指针节
// 3. 对每个函数指针执行 dladdr() 反查所在模块
// 4. 若指向非系统地址 = fishhook 已替换
// GOT 一致性比对:
// dlsym(RTLD_DEFAULT, "stat") vs GOT 节存储的 stat 指针
// 两者不一致 = GOT 已被 fishhook 替换
dlsym(RTLD_NEXT) 绕过 GOT Hook 直接检测
fishhook只替换App binary的GOT表。通过RTLD_NEXT取系统库中原始函数指针绕过:
typealias StatFunc = @convention(c) (UnsafePointer<CChar>, UnsafeMutablePointer<stat>) -> Int32
guard let sym = dlsym(UnsafeMutableRawPointer(bitPattern: -1), "stat") else { return }
let statFunc = unsafeBitCast(sym, to: StatFunc.self)
let jailbreakPaths = [
"/private/var/lib/apt",
"/Library/MobileSubstrate/MobileSubstrate.dylib",
"/var/jb/usr/lib/libsubstrate.dylib",
"/.installed_unc0ver", "/.installed_palera1n", "/.installed_dopamine"
]
for path in jailbreakPaths {
var buf = stat()
if path.withCString({ statFunc($0, &buf) }) == 0 {
// 即使 hook 框架隐藏了文件,原始 syscall 仍暴露了越狱路径
}
}
sysctl 自身 Hook 检测
通过dladdr反查sysctl/sysctlbyname函数地址所在模块:
guard let ptr = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "sysctl") else { return }
var info = Dl_info()
dladdr(ptr, &info)
let module = String(cString: info.dli_fname!)
// 正常: /usr/lib/system/libsystem_kernel.dylib
// 异常: 来自非系统模块 = sysctl 被 hook
时间一致性 Hook 检测(三路计时交叉验证)
对同一CPU密集workload用三种时钟接口计时,差异超阈值说明计时函数被hook:
// 1. mach_absolute_time() — 硬件计数器,极难被 hook
// 2. clock_gettime(CLOCK_MONOTONIC_RAW) — 内核单调时钟
// 3. gettimeofday() — libc 包装层,最易被 hook
// 三个结果理论上应接近(误差 < 3x)
// 若任意两者比率 > 50x = 该接口被 hook 篡改
符号一致性检测(RTLD_DEFAULT vs RTLD_NEXT)
Hook框架在RTLD_DEFAULT空间替换符号,对比两者返回地址:
let rtldDefault = UnsafeMutableRawPointer(bitPattern: -2)
let rtldNext = UnsafeMutableRawPointer(bitPattern: -1)
for sym in ["stat", "access", "open", "fork", "dlopen"] {
let defPtr = dlsym(rtldDefault, sym)
let nextPtr = dlsym(rtldNext, sym)
if defPtr != nextPtr {
// RTLD_DEFAULT 空间中的符号已被 hook 拦截
}
}
四路 Hook 精密检测(Runtime + LazyBind + GOT + ExportTrie)
腾讯TuringShield SDK中使用的高级Hook检测方案,同时检查4个独立路径获取函数地址,对比发现任何形式的Hook:
import MachO
/// 四路 Hook 检测 — 对任意 ObjC 方法或 C 函数,从 4 个独立数据源获取地址进行交叉验证
struct FourWayHookDetector {
enum HookType {
case none
case methodSwizzle // IMP 指向非预期 image
case fishhook // __la_symbol_ptr / __got 被修改
case inlinePatch // IMP 处机器码被篡改(JMP patch)
case fridaHook // IMP 指向 frida-agent 模块
}
/// 路径A: ObjC Runtime 获取 IMP
static func runtimeIMP(className: String, selector: String, isClassMethod: Bool) -> UnsafeMutableRawPointer? {
guard let cls = NSClassFromString(className) else { return nil }
let sel = NSSelectorFromString(selector)
let method = isClassMethod
? class_getClassMethod(cls, sel)
: class_getInstanceMethod(cls, sel)
guard let m = method else { return nil }
return unsafeBitCast(method_getImplementation(m), to: UnsafeMutableRawPointer.self)
}
/// 路径B: __la_symbol_ptr(lazy binding 表)中的地址
/// 遍历 Mach-O section headers,找到 S_LAZY_SYMBOL_POINTERS(type=7) 节
/// 通过 indirect symbol table 索引定位目标函数的 stub 地址
/// 路径C: __got / __DATA_CONST.__got(non-lazy binding 表)
/// 直接读取 GOT 条目存储的函数指针
/// 路径D: Export Trie 原始地址
/// 解析目标 dylib 的 LC_DYLD_INFO_ONLY → export_off/export_size
/// 在 trie 结构中查找符号名对应的原始 RVA
/// MurmurHash2-64A 对 IMP 前 16 字节机器码计算哈希
/// 用于检测 inline hook (JMP patch) — 即使 IMP 地址未变,但入口指令被改写
static func computeCodeHash(at address: UnsafeMutableRawPointer) -> UInt64 {
var data = Data(count: 16)
var outSize: mach_msg_type_number_t = 0
var readData: vm_offset_t = 0
// 使用 vm_read 绕过可能的内存保护
let kr = vm_read(mach_task_self_,
vm_address_t(UInt(bitPattern: address)),
16, &readData, &outSize)
guard kr == KERN_SUCCESS else { return 0 }
// MurmurHash2-64A (乘法常量 0xC6A4A7935BD1E995)
let m: UInt64 = 0xC6A4A7935BD1E995
let r: UInt64 = 47
var h: UInt64 = UInt64(16) &* m
let ptr = UnsafePointer<UInt64>(bitPattern: UInt(readData))!
for i in 0..<2 {
var k = ptr[i]
k &*= m; k ^= k >> r; k &*= m
h ^= k; h &*= m
}
h ^= h >> r; h &*= m; h ^= h >> r
return h
}
/// 综合检测: 4 路地址 + 代码哈希
static func detect(className: String, selector: String) -> HookType {
// 获取 4 路地址
let runtimeAddr = runtimeIMP(className: className, selector: selector, isClassMethod: false)
// let lazyBindAddr = ... (路径B)
// let gotAddr = ... (路径C)
// let exportAddr = ... (路径D)
// 对比: 4 路地址应一致
// runtimeAddr != exportAddr → method swizzle
// lazyBindAddr != gotAddr → fishhook
// 代码哈希: 对比 IMP 入口处机器码是否被改写
if let addr = runtimeAddr {
let hash = computeCodeHash(at: addr)
// 与基线哈希对比,不一致 = inline patch
}
// dladdr 判断 IMP 所在模块
if let addr = runtimeAddr {
var info = Dl_info()
dladdr(addr, &info)
let module = String(cString: info.dli_fname!)
if module.contains("frida-agent") { return .fridaHook }
if !module.hasPrefix("/System/") && !module.hasPrefix("/usr/lib/") {
return .methodSwizzle
}
}
return .none
}
}
核心原理:正常状态下,4路地址应指向同一位置(主binary的__TEXT.__text段内)。Method Swizzle会改变Runtime返回的IMP;fishhook会修改__la_symbol_ptr/__got指针;Frida的Interceptor将IMP重定向到frida-agent.dylib;inline patch不改变地址但修改目标处机器码(通常为BR X16跳板)。MurmurHash2-64A对IMP处指令的哈希可以捕获最后一种情况。
ObjC 运行时类名检测(越狱环境私有类)
越狱环境中加载的Tweak和系统扩展会注册一些正常iOS不存在的ObjC类。通过NSClassFromString或objc_getClassList检测这些类的存在性:
/// 检测越狱环境独有的 ObjC 类
func detectJailbreakClasses() -> [String] {
let suspiciousClasses = [
// SpringBoard 私有类(仅越狱设备可从 App 中加载)
"SBApplication",
"SBDisplayLayout",
"SBUserAgent",
"SBSMutableApplicationInfo",
// Cydia/包管理器相关
"CydiaObject",
"Installer",
// 越狱隐藏工具(自身也是检测信号)
"Shadow", // Shadow 越狱隐藏 Tweak
"Liberty", // Liberty Lite 隐藏 Tweak
// 系统进程相关
"LSApplicationProxy", // 正常 App 无权限实例化
"FBApplicationInfo" // FrontBoard 框架私有类
]
var detected: [String] = []
for className in suspiciousClasses {
if NSClassFromString(className) != nil {
detected.append(className)
}
}
return detected
}
/// 更全面:遍历已注册的全部 ObjC 类,搜索可疑前缀
func scanAllRegisteredClasses() -> [String] {
var count: UInt32 = 0
guard let classes = objc_copyClassList(&count) else { return [] }
defer { free(UnsafeMutableRawPointer(classes)) }
let suspiciousPrefixes = ["SB", "Cydia", "Substrate", "FridaGadget", "FLEX"]
var findings: [String] = []
for i in 0..<Int(count) {
let name = String(cString: class_getName(classes[i]))
if suspiciousPrefixes.contains(where: { name.hasPrefix($0) }) {
findings.append(name)
}
}
return findings
}
Mach-O 代码签名完整性验证(重签名 + 脱壳检测)
解析当前可执行文件的Mach-O结构,检查代码签名、权限列表和加密状态,对比历史值以检测重签名或脱壳:
import MachO
struct MachOIntegrityChecker {
/// 检测 LC_ENCRYPTION_INFO_64 中的 cryptid(加壳状态)
/// cryptid = 1: FairPlay DRM 加密(正常 App Store 下载)
/// cryptid = 0: 已脱壳(被 dumpdecrypted / frida-ios-dump 处理过)
static func checkEncryptionStatus() -> Bool {
guard let header = _dyld_get_image_header(0) else { return false }
var cursor = UnsafeRawPointer(header).advanced(by: MemoryLayout<mach_header_64>.size)
for _ in 0..<header.pointee.ncmds {
let cmd = cursor.assumingMemoryBound(to: load_command.self)
if cmd.pointee.cmd == LC_ENCRYPTION_INFO_64 {
let encCmd = cursor.assumingMemoryBound(to: encryption_info_command_64.self)
// cryptid == 0 意味着二进制已脱壳
return encCmd.pointee.cryptid != 0
}
cursor = cursor.advanced(by: Int(cmd.pointee.cmdsize))
}
return false // 无加密段(开发者直接分发或已脱壳)
}
/// 提取 LC_CODE_SIGNATURE 中的 CMS Blob 并计算 MD5
/// 与历史值对比可检测重签名行为(如企业证书重签/自签名)
static func codeSignatureMD5() -> String? {
guard let header = _dyld_get_image_header(0) else { return nil }
var cursor = UnsafeRawPointer(header).advanced(by: MemoryLayout<mach_header_64>.size)
for _ in 0..<header.pointee.ncmds {
let cmd = cursor.assumingMemoryBound(to: load_command.self)
if cmd.pointee.cmd == LC_CODE_SIGNATURE {
let sigCmd = cursor.assumingMemoryBound(to: linkedit_data_command.self)
let sigOffset = sigCmd.pointee.dataoff
let sigSize = sigCmd.pointee.datasize
// 从文件中读取 CMS blob 并计算 MD5
// 与 UserDefaults 中存储的历史 MD5 对比
// 不一致 = 发生了重签名
return "md5_of_cms_blob"
}
cursor = cursor.advanced(by: Int(cmd.pointee.cmdsize))
}
return nil
}
/// 提取嵌入式 Entitlement(magic 0xFADE7171)并计算 MD5
/// 检测权限篡改(如注入 get-task-allow 以允许调试)
static func entitlementMD5() -> String? {
// 在 LC_CODE_SIGNATURE 的 SuperBlob 中查找 magic = 0xFADE7171 的 slot
// 提取 entitlement plist XML 数据
// 计算 MD5 并与历史值对比
// 新增 get-task-allow = true = 被篡改以允许调试器附加
return "md5_of_entitlement"
}
/// 综合完整性检查
static func verify() -> [String: Any] {
let isEncrypted = checkEncryptionStatus()
let sigMD5 = codeSignatureMD5()
let entMD5 = entitlementMD5()
// 从 UserDefaults 读取历史值
let lastSigMD5 = UserDefaults.standard.string(forKey: "LastCMSBlobMD5")
let lastEntMD5 = UserDefaults.standard.string(forKey: "LastEntitlementMD5")
return [
"is_encrypted": isEncrypted, // false = 已脱壳
"sig_changed": sigMD5 != lastSigMD5, // true = 重签名
"ent_changed": entMD5 != lastEntMD5, // true = 权限篡改
]
}
}
/etc/fstab 篡改检测
正常iOS设备(9.0+)不存在/etc/fstab文件。越狱工具(如Palera1n)常创建此文件来修改文件系统挂载配置(如设置伪装分区):
func isFstabPresent() -> Bool {
var st = stat()
return stat("/etc/fstab", &st) == 0 && st.st_size > 0
}
自动化与远控工具检测 (FlyBird / TouchSprite)
除了Frida,风控系统还会探测常见的国产自动化/按键精灵工具,这些工具常被用于自动化撸羊毛:
let automationToolPaths = [
"/var/mobile/Media/TouchSprite", // 触动精灵
"/usr/bin/FlyBird", // FlyBird 远控 (路径1)
"/usr/libexec/flybirdd", // FlyBird 守护进程 (路径2)
"/etc/flybird.conf" // FlyBird 配置文件
]
// 遍历检测路径是否存在
MobileSubstrate 插件枚举 (Plugin Enumeration)
风控系统不仅检测是否越狱,还会通过枚举插件目录来建立设备风险画像。通过获取已安装插件的.dylib和.plist列表,后端可以识别出特定的Hook工具(如LocSim经纬度模拟、各类抢券插件):
func enumerateJailbreakPlugins() -> [String] {
let fm = FileManager.default
let pluginDirs = [
"/Library/MobileSubstrate/DynamicLibraries/",
"/usr/lib/DynamicLibraries/"
]
var plugins: [String] = []
for dir in pluginDirs {
if let files = try? fm.contentsOfDirectory(atPath: dir) {
plugins.append(contentsOf: files)
}
}
return plugins
}
可疑进程路径扫描(KERN_PROCARGS2)
通过KERN_PROCARGS2读取进程可执行路径,检查是否来自越狱目录:
let suspiciousPrefixes = [
"/var/jb/", "/private/preboot/", "/bootstrap/",
"/usr/bin/sshd", "/usr/bin/dropbear"
]
// sysctl(CTL_KERN, KERN_PROCARGS2, pid) 获取完整路径
// 路径匹配越狱目录 = 发现越狱进程
模拟器检测
使用多数投票策略(majorityMatch),超过一半技术命中才判定为模拟器:
编译时检测(最高可靠性)
#if targetEnvironment(simulator)
// 编译宏为 true = 当前二进制以模拟器 target 编译
#endif
UIDevice.model
模拟器通常在设备型号中包含"Simulator"字样:
let model = UIDevice.current.model
if model.contains("Simulator") {
// 发现模拟器
}
SIMULATOR_* 环境变量
模拟器运行时注入的专有环境变量:
let simEnvKeys = [
"SIMULATOR_UDID", "SIMULATOR_ROOT",
"SIMULATOR_SHARED_RESOURCES_DIRECTORY",
"SIMULATOR_HOST_HOME", "SIMULATOR_DEVICE_NAME"
]
for key in simEnvKeys {
if ProcessInfo.processInfo.environment[key] != nil {
// 模拟器环境
}
}
硬件架构检测
真机hw.machine返回设备型号(如iPhone17,1),模拟器返回CPU架构名:
var size = 0
sysctlbyname("hw.machine", nil, &size, nil, 0)
var machine = [CChar](repeating: 0, count: size)
sysctlbyname("hw.machine", &machine, &size, nil, 0)
let platform = String(cString: machine)
// 真机: "iPhone17,1" / "iPad14,3"
// 模拟器: "x86_64" / "arm64"
文件路径检测
模拟器的Bundle路径和Home目录包含CoreSimulator特征:
let bundlePath = Bundle.main.bundlePath
let homePath = NSHomeDirectory()
// 模拟器: 包含 "CoreSimulator" 或 "/Users/.../Library/Developer/"
// 真机: /var/containers/Bundle/... 或 /var/mobile/...
模拟器专用系统库
模拟器使用独立的模拟层系统库:
let imageCount = _dyld_image_count()
for i in 0..<imageCount {
let name = String(cString: _dyld_get_image_name(i)!)
if name.contains("libsystem_sim") || name.contains("dyld_sim") {
// 模拟器环境
}
}
uname内核信息
模拟器的uname().machine返回macOS宿主机内核特征:
var info = utsname()
uname(&info)
// 真机: machine="arm64e", release 为 iOS 内核版本
// 模拟器: machine="x86_64"/"arm64", 内核版本与 macOS 宿主一致
云手机/iOS虚拟机检测
专门对抗vphone-cli(基于Apple Virtualization.framework运行虚拟iPhone)等工具:
Hypervisor存在检测
var value: Int32 = 0
var size = MemoryLayout<Int32>.size
sysctlbyname("kern.hv_vmm_present", &value, &size, nil, 0)
// 真实设备恒为 0
// Apple Virtualization.framework 客户机为 1
还可以通过kern.hv_support获取Hypervisor支持状态(仅供参考):
var value: Int32 = 0
var size = MemoryLayout<Int32>.size
sysctlbyname("kern.hv_support", &value, &size, nil, 0)
内核启动参数(kern.bootargs)
vphone-cli在iBEC/LLB中注入启动参数:
var size = 0
sysctlbyname("kern.bootargs", nil, &size, nil, 0)
// 真实设备: 空或不可访问
// vphone: 含 "serial=3"、"debug=0x2014e"、"-v"、"amfi_get_out_of_my_way"
还可以检查kern.securelevel,负数通常表示内核处于调试模式:
var securelevel: Int32 = 0
var size = MemoryLayout<Int32>.size
sysctlbyname("kern.securelevel", &securelevel, &size, nil, 0)
// < 0 = 内核调试模式异常
vphone 专属守护进程(双层检测)
// 层1: KERN_PROC_ALL 进程名精确匹配(p_comm 16字节)
let vphoneProcessNames: Set<String> = [
"vphoned", // vphone 控制 daemon(vsock HID/control)
"trollvnc", // VNC 服务器(vphone 定制版 TrollVNC)
"vphone_jb_setup", // JB 变体一次性初始化脚本进程
"rpcserver_ios", // RPC 服务(vphone dev/jb 变体)
]
// ⚠️ 已排除 "dropbear" — 真实越狱设备也有此进程,避免误报
// 层2: KERN_PROCARGS2 进程路径扫描
let vphonePathSignals = [
"/iosbinpack64/", // vphone 工具链根目录
"/cores/vphone", // vphone JB 变体安装目录
"/var/log/vphone", // vphone JB 初始化日志
]
// sysctl(KERN_PROCARGS2, pid) → 前4字节为 argc,后跟完整可执行路径
虚拟化文件系统痕迹(FileManager + stat 双检)
// vphone 所有变体(Regular/Dev/JB)的专属路径
let vphoneFilePaths = [
"/iosbinpack64", // 工具链根目录(所有变体)
"/iosbinpack64/bin/bash",
"/iosbinpack64/usr/bin/ssh",
"/iosbinpack64/usr/sbin/dropbear",
"/usr/local/bin/vphoned", // 控制守护进程
"/var/run/vphoned.pid",
"/usr/local/bin/trollvnc", // 定制版 VNC
"/cores/vphone_jb_setup.sh", // JB 变体初始化
"/var/log/vphone_jb_setup.log",
"/var/log/vphone.log", // 配置/日志
]
// ⚠️ 已排除 /cores/systemhook.dylib、/var/jb/ 等越狱真机也存在的路径
// 双检绕过 Hook: FileManager.fileExists() + stat() 系统调用
CPU 核数 / 机型不一致 + hw.cpufamily 检测
// 1. hw.ncpu 与机型规格库对比
// vphone: make vm_new CPU=8,但 iPhone17,3 实际为 6 核
sysctlbyname("hw.ncpu", &ncpu, &ncpuSize, nil, 0)
// 维护完整 iPhone 机型 → 核数对照表(iPhone 11 ~ iPhone 16 系列)
// 2. hw.cpufamily 检测 Intel 宿主机 CPU 族泄露
sysctlbyname("hw.cpufamily", &cpuFamily, &size, nil, 0)
// 已知 iOS ARM CPU 族: 0x6F5129AC(Everest/A17-A18), 0xDA33D83D(A16), ...
// x86/Intel 宿主机(Intel Mac 上的 vphone): 0x486C9F05, 0x562F09FD(Skylake)...
// ⚠️ Apple Silicon 宿主机与 iOS CPU 族值重叠,无法通过 cpufamily 区分
// → 该场景依赖 kern.hv_vmm_present 检测
VirtIO / 半虚拟化设备特征(dylib + Metal + hw.targettype)
// 1. _dyld_image_name() 扫描已加载动态库
let virtioLibKeywords = [
"AppleVirtIO", // Apple Virtualization.framework VirtIO 驱动
"AppleParavirt", // 半虚拟化设备驱动(AppleParavirtGPU 等)
"AvpFairPlay", // VirtIO FairPlay(vphone 虚拟化 DRM 设备)
"vphoned", "vphone", "virtio"
]
// ⚠️ 已排除 "systemhook"/"libellekit" — 越狱真机也有
// 2. Metal GPU 设备名
let device = MTLCreateSystemDefaultDevice()
// 虚拟机: "Apple Paravirt GPU" / 含 "virtual"/"vmware"/"virtio"
// 真实设备: "Apple A17 Pro GPU" 等
// 3. hw.targettype 硬件目标标识
sysctlbyname("hw.targettype", ...)
// 真实 iPhone: "D93AP"/"D74AP" 等 AP 结尾代号
// vphone 宿主机: 可能返回含 "mac"/"VMM"/"virtual" 的值
VM Guest 标志 + 内存报告一致性
// 1. kern.vm_guest — 虚拟机客户机标志
var vmGuest: Int32 = 0
sysctlbyname("kern.vm_guest", &vmGuest, &size, nil, 0)
// 真实 iOS: 不存在此 key(ENOENT)或 = 0
// 虚拟机: 1=VMware, 2=Parallels, 3=VirtualBox, 4=HyperV, 5=KVM
// 2. hw.memsize vs ProcessInfo.physicalMemory 一致性
var hwMemsize: UInt64 = 0
sysctlbyname("hw.memsize", &hwMemsize, &size, nil, 0)
let piMemory = ProcessInfo.processInfo.physicalMemory
// 真实设备: 两者完全一致(误差 < 64MB)
// 虚拟化: Hypervisor 内存映射导致差异可能超过 64MB
VirtIO 网卡 MAC OUI
// getifaddrs() + AF_LINK 读取 en* 接口 MAC 地址
let virtualOUIs: Set<String> = [
"52:54:00", // QEMU/KVM(libvirt 默认)
"00:1C:42", // Parallels Desktop
"00:50:56", // VMware
"00:0C:29", // VMware(自动分配)
"00:05:69", // VMware(老版本)
"08:00:27", // VirtualBox
"00:15:5D", // Hyper-V
"02:42:AC", // Docker 虚拟网卡
"DE:AD:BE", // 测试/虚拟环境占位
]
// ⚠️ iOS 14+ 私有 Wi-Fi 地址会设置 locally-administered bit,
// 不能用 bit1 检测虚拟网卡(已禁用该探针避免 100% 误报)
AF_VSOCK 虚拟化通信套接字(双层探测)
// 层1: socket() 创建探测
let sock = socket(40 /* AF_VSOCK */, SOCK_STREAM, 0)
// 真实 iOS: 返回 -1, errno = EAFNOSUPPORT(47)
// vphone 内核: 创建成功(fd >= 0)= 虚拟化内核
// 层2: connect() 宿主机连接探测
// 尝试连接 VMADDR_CID_HOST(2), port=1234
// 真实 iOS: EAFNOSUPPORT / ENETUNREACH
// 虚拟机: EINPROGRESS(连接中)或 ECONNREFUSED(端口不开放但路由可达)
// 两种响应均表明 vsock 路由到宿主机已建立
kern.boottime + uptime 极短
// 1. kern.boottime 合法性: 时间戳不应早于 2020 或在未来
// 2. uptime 极短(< 2min)仅作参考信号,不独立触发判定
// ⚠️ 避免 iPhone 系统更新后第一次重启的误报
// 真实 iPhone 用户通常数天~数周不重启
// vphone 每次 make vm_new 都是全新虚拟机
音频路由异常(输入 + 输出双检测)
// 输出端口白名单: builtInSpeaker/headphones/bluetoothA2DP/airPlay/HDMI/carAudio/usbAudio
// 可疑关键词: "virtual"/"virtio"/"paravirt"/"remote io"/"vphone"/"null audio"/"dummy"
// 输入端口同理: builtInMic/headsetMic/bluetoothHFP 为正常,含可疑关键词则告警
// ⚠️ 空输出/输入列表不独立触发(可能只是 App 未激活音频会话)
摄像头设备异常(虚拟设备 + 数量一致性)
// 1. 虚拟摄像头检测
// localizedName/modelID 含 "virtual"/"fake"/"dummy"/"vphone"/"obs" 等
// modelID 为空字符串 = 强异常信号(真机摄像头 modelID 非空)
// 完全无摄像头 = 强异常(真实 iPhone 至少有 1 个后置摄像头)
// 2. 摄像头数量一致性(基于机型规格库)
// iPhone 16 Pro: 三摄+TrueDepth = 4 个
// iPhone 16 标准: 双摄+TrueDepth = 3 个
// iPhone 15 Pro: 三摄+TrueDepth = 4 个
// 若实际 < 预期最小值 = 虚拟化环境省略了部分摄像头
屏幕捕获检测
// UIScreen.main.isCaptured = true → 屏幕正在被外部录制
// 云手机核心工作模式: 捕获帧缓冲 → 实时编码 → 网络传输
// UIScreen.screens.count > 1 → 存在镜像/外接显示输出
// 云手机 VNC 镜像会触发此信号
传感器噪声分析(20帧标准差法)
// 加速度计: CMMotionManager 100Hz × 20 帧
// 计算三轴标准差之和 (stdX + stdY + stdZ)
// 真实 iPhone 静止时: std_sum ≥ 1e-4 g(随机热噪声)
// vphone/VM: std_sum < 1e-6(恒定值,无随机扰动)
// 陀螺仪: 同理,阈值 1e-7(温漂噪声)
// 传感器不可用 (isAccelerometerAvailable == false) 也是强异常
蜂窝网络缺失(CTCarrier + pdp_ip 接口)
// 1. CTTelephonyNetworkInfo().serviceSubscriberCellularProviders
// iPhone 设备: 始终有运营商条目(即使无 SIM 卡,carrierName 也有固定值)
// vphone: 返回 nil/空字典 → 完全没有虚拟 SIM 卡支持
// ⚠️ 仅对 UIDevice.model 含 "iPhone" 的设备检测(排除 iPad Wi-Fi)
// 2. getifaddrs() 检测 pdp_ip* 蜂窝数据接口
// 真实 iPhone: 始终有 pdp_ip0(3G/4G/5G 数据接口)
// vphone: 完全无蜂窝模组,pdp_ip* 接口不存在
CloudOS 守护进程(PCC 环境检测)
// 进程名扫描: KERN_PROC_ALL
let cloudOSProcessNames: Set<String> = [
"cloudboardd", "cloudmetricsd", "cloudpolicyd",
"cloudd", "cloudconfigurationd",
"splunkloggingd", "logstashd", "oahd"
]
// 文件系统路径:
let cloudOSPaths = [
"/private/var/db/cloudpolicyd", // PCC 运行时数据目录
"/private/var/log/cloudboard",
"/usr/local/bin/cloudboard_ctl", // PCC 控制工具
]
// ⚠️ 已排除 /System/Library/PrivateFrameworks/ 下的 Framework 路径
// (CloudAttestation 等在真实 iOS 26+ 设备上也存在)
调度器抖动分析(双探针)
// 探针1: mach_absolute_time 连续 200 次空循环采样
// 计算峰谷比 (P95/P5) 和变异系数 (CV)
// 真实设备: 峰谷比 < 4x,CV < 0.8(硬件时钟极稳定)
// 虚拟机: 峰谷比 > 8x 或 CV > 1.5(vCPU 被 Hypervisor 抢占)
// 探针2: sched_yield() 恢复延迟 × 50 次
// 真实设备: median < 50μs, P95 < 200μs
// 虚拟机: median > 200μs, P95 > 1ms, 偶发 > 5ms 极端值
// 对 Apple Silicon Mac 上的 vphone 也有效(Virtualization.framework vCPU 切换开销无法隐藏)
DRM 检测
FairPlay Streaming 能力完整性(双探针)
// 探针1: NSClassFromString("AVContentKeySession") — 类是否存在
// 模拟器/受损环境: nil
// 探针2: dlsym(AVFoundation_handle, "OBJC_CLASS_$_AVContentKeySession")
// dlopen(..., RTLD_LAZY | RTLD_NOLOAD) + dlsym 检测 FairPlay 符号
// AVFoundation 未加载或符号缺失 = 环境异常
App Store 收据检测
// 探针1: Bundle.main.appStoreReceiptURL 存在性记录
// ⚠️ 收据缺失本身不触发报警(Xcode 直接运行、开发者侧载均无收据)
// 探针2: 收据路径格式验证(仅在文件存在时)
// 合法路径: 包含 "StoreKit"(production: StoreKit/receipt, TestFlight: StoreKit/sandboxReceipt)
// 文件存在但路径不含 StoreKit → 路径被篡改,真实异常信号
代码签名完整性(LC_CODE_SIGNATURE + Bundle ID)
// 探针1: Bundle ID 一致性 — runtime vs plist 交叉验证
// 探针2: 扫描主可执行文件 Mach-O 头部 LC_CODE_SIGNATURE 加载命令
// 遍历 _dyld_image_count() → _dyld_get_image_header() → load_command
// LC_CODE_SIGNATURE 缺失 = 签名被剥离(重打包铁证)
DRM 相关动态库 Hook 检测
// 探针1: _dyld_get_image_name() 扫描可疑 DRM 注入库
let suspiciousKeywords = [
"SSLKillSwitch", "FairPlayDecrypt", "DRMHook",
"ContentKeyHook", "AVFoundationHook", "libfps",
"FPSHook", "MitMProxy", "StreamingCapture"
]
// 探针2: AVFoundation 加载路径验证
// 合法: /System/Library/Frameworks/AVFoundation.framework/
// 非系统路径 = 被替换攻击
高级反Hook与完整性技巧
以下为通用的完整性校验与对抗手段,适用于越狱、重打包、调试等多类场景。
__TEXT段代码完整性校验 (MD5)
通过计算主二进制Mach-O中__TEXT.__text section的MD5哈希值,可以检测应用是否被Inline Hook(如MSHookFunction)或被修改了机器码指令:
import MachO
import CommonCrypto
func getTextSegmentMD5() -> String? {
guard let header = _dyld_get_image_header(0) else { return nil }
let slide = _dyld_get_image_vmaddr_slide(0)
var cursor = UnsafeRawPointer(header).advanced(by: MemoryLayout<mach_header_64>.size)
for _ in 0..<header.pointee.ncmds {
let cmd = cursor.assumingMemoryBound(to: load_command.self)
if cmd.pointee.cmd == LC_SEGMENT_64 {
let seg = cursor.assumingMemoryBound(to: segment_command_64.self)
if String(cString: UnsafeRawPointer(&seg.pointee.segname).assumingMemoryBound(to: CChar.self)) == "__TEXT" {
var sectCursor = cursor.advanced(by: MemoryLayout<segment_command_64>.size)
for _ in 0..<seg.pointee.nsects {
let sect = sectCursor.assumingMemoryBound(to: section_64.self)
if String(cString: UnsafeRawPointer(§.pointee.sectname).assumingMemoryBound(to: CChar.self)) == "__text" {
let textData = Data(bytes: UnsafeRawPointer(bitPattern: UInt(Int(sect.pointee.addr) + slide))!, count: Int(sect.pointee.size))
var md = [UInt8](repeating: 0, count: Int(CC_MD5_DIGEST_LENGTH))
_ = textData.withUnsafeBytes { CC_MD5($0.baseAddress, CC_LONG(textData.count), &md) }
return md.map { String(format: "%02x", $0) }.joined()
}
sectCursor = sectCursor.advanced(by: MemoryLayout<section_64>.size)
}
}
}
cursor = cursor.advanced(by: Int(cmd.pointee.cmdsize))
}
return nil
}
敏感路径混淆技术
成熟的SDK (如KSCrash)不会直接硬编码越狱路径字符串,而是采用古典密码编码或MBA等,防止静态分析工具(grep, strings)直接定位检测逻辑:
// 编码后的路径示例: "/Applications/Cydia.app" -> ".Bqqmjdbujpot/Dzejb.bqq" (每字符+1)
let encodedPath = ".Bqqmjdbujpot/Dzejb.bqq"
let decoded = encodedPath.map { Character(UnicodeScalar(UInt8($0.asciiValue! - 1))) }
if access(String(decoded), F_OK) == 0 {
// 发现越狱环境
}
dyld镜像加载监控 (Add Image Callback)
通过_dyld_register_func_for_add_image注册全局回调,监控进程运行期间所有动态库的加载行为。即使是在App运行中途注入的Frida或其他dylib,也能被即时捕获:
import MachO
// 回调函数:每当有新镜像加载时触发
func image_add_callback(header: UnsafePointer<mach_header>?, slide: Int) {
guard let header = header else { return }
var info = Dl_info()
if dladdr(header, &info) != 0, let pathPtr = info.dli_fname {
let path = String(cString: pathPtr)
// 记录或匹配可疑关键词:MobileSubstrate, Frida, Tweak...
}
}
// 注册监听
_dyld_register_func_for_add_image(image_add_callback)
syscall(336) 直接模块扫描 (最底层)
直接调用syscall(336)(即__proc_info中的特定子功能)来获取当前进程的模块映射列表,绕过所有对_dyld_get_image_name等公开API的Hook:
// 伪代码展示底层逻辑
let result = syscall(336, mach_task_self(), PROC_INFO_CALL_LISTDYS, ...)
// 遍历 result 获取模块路径
// 检测关键词:/dopamine, /TweakInject, /frida-server, /frida-trace
远程触发文件一致性校验
服务器可主动下发XML指令,要求客户端校验任意文件的任意偏移区间的Hash值:
// 微信支持的路径变量展开:
// ${WeChatBundle} -> 主程序路径
// ${WeChatLibrary} -> Library 目录
// ${WeChatTmp} -> Tmp 目录
// 采集数据:fileName, fileOffset, bufferSize, checkBufferHash, fileSize
远程触发应用/进程列表上报
通过sysctl枚举当前系统中所有运行中的进程名(p_comm),用于识别自动化工具、多开应用或其他风险环境:
func getRunningProcesses() -> [String] {
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_ALL]
// ... 调用 sysctl 获取 kinfo_proc 数组
// 提取 p_comm 字段
return processNames
}
传感器行为采集 (Sensor Collect)
由服务器下发ClientCheckSensorCollect指令触发,采集加速度计、陀螺仪等传感器数据,用于通过人机算法识别自动化脚本:
// 采集场景:SensorScene + BehaviorID
Fishhook 运行时检测
通过监控敏感函数的调用(如__cxa_throw),使用dladdr检查函数指针是否指向预期的系统库镜像。
// 微信内部 fishhook 检测日志示例:
// "fishhook, func name: %s, replacement: %s, image name: %s"
自定义 Mach-O 段跳板 (Trampolines)
在Mach-O文件中定义非标准段(如__mm_trace, __WCDY),存放运行时填充的函数指针。
* 目的:实现延迟绑定和间接跳转,增加静态分析工具定位函数真实调用关系的难度。
* 机制:调用者不直接调用目标函数,而是跳到自定义段中的存根代码(Stub),存根从__data或__bss段读取真实地址并跳转。
探针不一致性检测(Anti-Hook 元信号)
同一事实用多个探针从不同系统调用验证。若探针结论不一致,则说明某个系统调用被Hook篡改了返回值——这是极强的环境异常信号:
// 示例:文件存在性检测用4个探针交叉验证
// Probe 1: FileManager.fileExists(atPath:)
// Probe 2: Darwin.stat(path)
// Probe 3: Darwin.access(path, F_OK)
// Probe 4: Darwin.open(path, O_RDONLY)
// 若 1/2 说不存在而 3/4 说存在 -> inconsistencyDetected = true -> Hook Signal
内存完整性校验 (vm_read_overwrite)
通过vm_read_overwrite读取进程内存中的特定区域,然后与预期值比较。检测代码段是否被Hook修改(Inline Hook检测)、验证Mach-O头部完整性。
策略驱动的动态检测 (JDGuard 架构)
安全检测项不再硬编码,而是由服务端下发策略(Policy)动态控制。服务端可针对特定用户或场景动态启用/禁用探测点(如针对黑产用户开启全量扫描,对普通用户关闭以节省性能),提高对抗的灵活性。
隐蔽的资源文件 (图片隐写/LSB)
将加密密钥、检测规则或WBAES查找表使用LSB (Least Significant Bit)隐写术,将数据嵌入像素的最低有效位隐藏在看似正常的图片资源中(如.jpg或.png),这个在国内大厂里很常见。