IOS风控与设备指纹

Published: 2024年12月06日

In Auto.

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.plistLSApplicationQueriesSchemes之中,如:

<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获取内核记录的开机精确时间戳。bootTimeuptime的组合可以作为设备稳定性的标识:

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: &timestamp) { 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中添加NSLocationWhenInUseUsageDescriptionNSLocationAlwaysAndWhenInUseUsageDescription(后台定位)说明目的,用户允许后可获取:

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) 测试

越狱后系统目录常被替换为指向越狱分区的符号链接:

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类。通过NSClassFromStringobjc_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(&sect.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),这个在国内大厂里很常见。

social