IOS风控与设备指纹

Published: 2024年12月06日

In Auto.

ios太封闭了,能收集的信息十分有限,下面列出遇到过的常见信息~

指纹信息收集

硬件信息

内存

同型号的总内存是固定值,看代码:

import Foundation

ProcessInfo.processInfo.physicalMemory

磁盘

同型号的总磁盘空间是固定的,正常用户的可用空间应该符合使用规律,获取代码如下:

import Foundation

let attributes = try FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory())
let totalSpace = attributes[.systemSize] as? Int64 ?? 0
let freeSpace = attributes[.systemFreeSize] as? Int64 ?? 0

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" // Simulator
#endif

屏幕

包括固定的屏幕大小,像素比等和与用户相关亮度信息,如下:

import UIKit

screen = UIScreen.main
screen.nativeBounds.size    // 物理像素
screen.bounds.size  // 逻辑点
screen.nativeScale  // 缩放因子
screen.brightness   // 亮度

型号

不是硬件但同型号硬件一致,

import UIKit
let device = UIDevice.current
device.name
device.model
device.systemName
device.systemVersion   

网络信息

获取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")")
}

文件信息

这里它能检测很多文件的创建/修改/访问时间和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)))")
   }
}

应用安装时间

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

系统信息

系统运行时间

本次开机后运行多久了~:

import Foundation

let uptime = ProcessInfo.processInfo.systemUptime

系统和内核版本

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)
         }
      }
      return SystemVersionInfo(
         systemVersion: device.systemVersion,
         kernelVersion: kernelVersion
      )
}

唯一标识符

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请求跟踪,这样就拿不到它了。要获取它需要在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
   }
}

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老早就被禁了,直接忽略

活动信息

定位

定位当然很重要,除了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()

电池信息

含充放电状态和电量信息,直接可获取:

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

区域

包括时区和语言:

import Foundation

let locale = Locale.current
print("Locale Identifier: \(locale.identifier)")
print("Preferred Languages: \(Locale.preferredLanguages.joined(separator: ", "))")
print("Time Zone: \(TimeZone.current.identifier)")

安装的应用

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

当前应用信息

安装时间 版本 plist版本

截屏录屏

对于截屏,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会将整个屏幕变黑!

辅助功能

...

分析检测

抓包

代理

检测是否使用了代理:

import SystemConfiguration

func isProxyEnabled() -> Bool {
   guard let proxySettings = CFNetworkCopySystemProxySettings()?.takeRetainedValue() as? [String: Any],
         let proxies = proxySettings[kCFNetworkProxiesHTTPProxy as String] as? String,
         !proxies.isEmpty
   else {
      return false
   }

   let httpEnabled = (proxySettings[kCFNetworkProxiesHTTPEnable as String] as? NSNumber)?.boolValue ?? false
   return httpEnabled
}

VPN

VPN也算代理:

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
}

调试检测

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
}

异常环境

重打包检测

重打包有多种检测方式,下面分别说明。 1.根据签名和Bundle Identifier 先说bundle id,直接查就好了,但有可能会被hook,所以要用多种方式查询:

Bundle.main.bundleIdentifier

再看签名:

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

越狱检测

要检测越狱,思路为: 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
}

social