聊聊我在iOS安全开发里踩过的坑和总结的最佳实践

GeekPower
2026-07-07 23:05
阅读 439

凌晨两点半,耳机里循环着Lo-fi Hip Hop,手边的咖啡已经凉透了。我盯着Cursor里刚生成的一段Keychain封装代码,突然觉得有点恍惚——半年前我还在为App Store审核被拒而抓狂,现在居然已经开始琢磨怎么把用户数据保护做得更极致了。

说来惭愧,作为一个远程办公的iOS开发,我每天的日常就是穿着睡衣坐在书桌前,一边听着音乐一边跟SwiftUI较劲。最近公司接了个金融类的项目,安全要求高得离谱,产品经理天天在飞书上@我,说什么"用户数据泄露了咱们都得进去"。虽然我知道他在夸张,但确实让我开始认真思考iOS安全开发这件事。

这篇文章,算是我这半年在安全开发上摸爬滚打的一些心得。中间用了不少AI辅助工具来加速学习和开发,也踩了不少坑,希望能给同样在做iOS安全开发的朋友一些参考。

被一次安全审计打醒

去年年底,公司做了一次第三方安全审计。审计报告出来的那天,我整个人都不好了——密密麻麻列了二十多条问题,什么明文存储用户Token啦、网络请求没做证书校验啦、日志里打印了敏感信息啦……最离谱的是,审计团队直接用Frida hook了我们的登录接口,把用户的密码明文抓出来了。

当时真的想找个地缝钻进去。

痛定思痛,我决定系统性地梳理一下iOS安全开发的最佳实践。说实话,以前写代码的时候,安全这事儿在我心里排不上号——功能能跑就行,性能差不多就上线,安全?那是运维的事吧。但这次审计彻底把我打醒了。

数据存储:别把用户数据裸奔

先说最基础也是最容易出问题的——数据存储。

很多新手(包括以前的我)喜欢用UserDefaults存各种东西,甚至有人把用户的登录Token直接扔进去。兄弟们,UserDefaults本质上就是一个plist文件,越狱设备上随便一个文件管理器就能读出来,没越狱的设备通过备份也能提取。这跟把数据写在纸上贴显示器上有啥区别?

Keychain才是正道

对于敏感数据,比如Token、密码、加密密钥这些,必须用Keychain。Keychain是系统级的安全存储,数据经过硬件加密,即使设备被越狱也很难直接读取。

import Security

class KeychainManager {
    
    enum KeychainError: Error {
        case duplicateItem
        case unknown(OSStatus)
    }
    
    /// 保存数据到Keychain
    static func save(key: String, data: Data, accessGroup: String? = nil) throws {
        // 先删除已存在的同名item
        let queryDelete: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecAttrService as String: Bundle.main.bundleIdentifier ?? ""
        ]
        SecItemDelete(queryDelete as CFDictionary)
        
        // 构建保存的query
        var query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecAttrService as String: Bundle.main.bundleIdentifier ?? "",
            kSecValueData as String: data,
            kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
        ]
        
        // 如果指定了accessGroup,用于App Groups共享
        if let group = accessGroup {
            query[kSecAttrAccessGroup as String] = group
        }
        
        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else {
            if status == errSecDuplicateItem {
                throw KeychainError.duplicateItem
            }
            throw KeychainError.unknown(status)
        }
    }
    
    /// 从Keychain读取数据
    static func load(key: String) -> Data? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecAttrService as String: Bundle.main.bundleIdentifier ?? "",
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]
        
        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)
        
        guard status == errSecSuccess else { return nil }
        return result as? Data
    }
}

这里有个细节要注意:kSecAttrAccessible这个属性决定了Keychain item的访问时机。我用的是kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,意思是用户解锁设备一次后就可以访问,但不会备份到iCloud,也不会同步到其他设备。这个设置兼顾了可用性和安全性。

如果你存的是特别敏感的数据,比如支付密码,可以用kSecAttrAccessibleWhenUnlockedThisDeviceOnly,这样只有设备处于解锁状态时才能访问。但要注意,这意味着你的App在后台时是读不到这个数据的,可能会影响一些后台任务的逻辑。

文件加密存储

对于大量结构化数据,比如用户的聊天记录、交易记录这些,我一般用SQLCipher对SQLite数据库进行加密。SQLCipher是对SQLite的开源扩展,提供透明的256位AES加密。

import SQLite3
import SQLCipher

class SecureDatabase {
    private var db: OpaquePointer?
    
    func open(path: String, passphrase: String) -> Bool {
        guard sqlite3_open(path, &db) == SQLITE_OK else {
            print("数据库打开失败: \(String(cString: sqlite3_errmsg(db)))")
            return false
        }
        
        // 设置加密密钥
        let keyStatement = "PRAGMA key = '\(passphrase)';"
        guard sqlite3_exec(db, keyStatement, nil, nil, nil) == SQLITE_OK else {
            print("加密密钥设置失败")
            sqlite3_close(db)
            return false
        }
        
        // 验证密钥是否正确
        guard sqlite3_exec(db, "SELECT count(*) FROM sqlite_master;", nil, nil, nil) == SQLITE_OK else {
            print("密钥验证失败,可能是错误的密钥")
            sqlite3_close(db)
            return false
        }
        
        // 一些安全相关的PRAGMA设置
        sqlite3_exec(db, "PRAGMA foreign_keys = ON;", nil, nil, nil)
        sqlite3_exec(db, "PRAGMA secure_delete = ON;", nil, nil, nil)
        
        return true
    }
}

加密密钥从哪来?这就是个套娃问题了。密钥本身也要安全存储,我一般的做法是:用Keychain存一个主密钥,每次打开数据库时从Keychain取出来。如果主密钥不存在(首次安装),就随机生成一个并存入Keychain。

网络通信:中间人攻击不是传说

说完存储说网络。金融类App对网络安全的重视程度是一般App比不了的。

ATS必须开

首先,App Transport Security必须开启,这是底线。在Info.plist里确保NSAppTransportSecurity没有设置NSAllowsArbitraryLoads = YES。如果有特殊域名需要例外,用NSExceptionDomains精确配置,千万别图省事全局放开。

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsArbitraryLoads</key>
    <false/>
    <!-- 只给特定域名开例外 -->
    <key>NSExceptionDomains</key>
    <dict>
        <key>legacy-api.example.com</key>
        <dict>
            <key>NSExceptionMinimumTLSVersion</key>
            <string>TLSv1.2</string>
            <key>NSExceptionRequiresForwardSecrecy</key>
            <true/>
        </dict>
    </dict>
</dict>

SSL Pinning

光用HTTPS还不够,还需要做SSL Pinning。为什么?因为如果用户设备上安装了恶意证书(比如某些企业MDM推送的证书,或者用户自己装的抓包工具),系统会信任这个证书,中间人就可以解密你的HTTPS流量。

SSL Pinning的做法是把服务端的证书或者公钥硬编码到App里,每次建立TLS连接时,校验服务端返回的证书是否和我们预置的一致。

import Foundation

class SSLPinningDelegate: NSObject, URLSessionDelegate {
    
    // 预置的证书公钥SHA256指纹
    private let pinnedPublicKeyHashes = [
        "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",  // 主证书
        "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="   // 备用证书
    ]
    
    func urlSession(_ session: URLSession, 
                    didReceive challenge: URLAuthenticationChallenge,
                    completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
        
        guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
              let serverTrust = challenge.protectionSpace.serverTrust else {
            completionHandler(.cancelAuthenticationChallenge, nil)
            return
        }
        
        // 获取服务端证书链
        let secTrust = serverTrust as SecTrust
        var certificateChain: [SecCertificate] = []
        
        let certificateCount = SecTrustGetCertificateCount(secTrust)
        for i in 0..<certificateCount {
            if let certificate = SecTrustGetCertificateAtIndex(secTrust, i) {
                certificateChain.append(certificate)
            }
        }
        
        // 校验证书链中是否有匹配的公钥
        let isPinned = certificateChain.contains { certificate -> Bool in
            guard let publicKey = SecCertificateCopyKey(certificate),
                  let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, nil) as Data? else {
                return false
            }
            
            let hash = sha256(data: publicKeyData)
            let hashString = hash.base64EncodedString()
            
            return pinnedPublicKeyHashes.contains("sha256/\(hashString)")
        }
        
        if isPinned {
            completionHandler(.useCredential, URLCredential(trust: serverTrust))
        } else {
            completionHandler(.cancelAuthenticationChallenge, nil)
        }
    }
    
    private func sha256(data: Data) -> Data {
        var hash = [UInt8](repeating: 0, count: 32)
        data.withUnsafeBytes { buffer in
            CC_SHA256(buffer.baseAddress, CC_LONG(data.count), &hash)
        }
        return Data(hash)
    }
}

这里我用了公钥Hash的方式做Pin,而不是直接Pin证书。好处是证书过期续签的时候,只要密钥对不变,App就不需要更新。记得一定要Pin至少两个公钥(主证书+备用证书),不然万一证书链变了,App直接就断网了。

说到这插一嘴,在做SSL Pinning的过程中,我用DeepSeek帮我分析了Apple官方文档里关于SecTrust的那堆C API,讲真那些文档写得跟天书一样,DeepSeek帮我理清了好几个容易搞混的概念,比如SecTrustGetCertificateAtIndexSecTrustCopyCertificateChain的区别——前者在iOS 15之后已经被标记为deprecated了,新项目一定要用后者。

代码层面的安全加固

防调试和防篡改

对于金融类App,防止被逆向和调试是基本操作。虽然iOS的安全机制已经很强了,但该做的防护还是得做。

import Foundation
import MachO

class SecurityGuard {
    
    /// 检测是否处于调试状态
    static 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)
        assert(junk == 0, "sysctl failed")
        return (info.kp_proc.p_flag & P_TRACED) != 0
    }
    
    /// 检测是否越狱
    static func isJailbroken() -> Bool {
        // 方法1: 检查常见越狱路径
        let jailbreakPaths = [
            "/Applications/Cydia.app",
            "/Library/MobileSubstrate/MobileSubstrate.dylib",
            "/bin/bash",
            "/usr/sbin/sshd",
            "/etc/apt",
            "/private/var/lib/apt/",
            "/usr/bin/ssh"
        ]
        
        for path in jailbreakPaths {
            if FileManager.default.fileExists(atPath: path) {
                return true
            }
        }
        
        // 方法2: 尝试写入沙盒外目录
        let testPath = "/private/jailbreak_test_\(UUID().uuidString)"
        do {
            try "test".write(toFile: testPath, atomically: true, encoding: .utf8)
            try FileManager.default.removeItem(atPath: testPath)
            return true
        } catch {
            return false
        }
    }
    
    /// 检测App是否被篡改(校验代码签名)
    static func isAppTampered() -> Bool {
        guard let infoPlistPath = Bundle.main.path(forResource: "Info", ofType: "plist") else {
            return true
        }
        
        // 获取embedded.mobileprovision中的bundle id
        guard let provisionPath = Bundle.main.path(forResource: "embedded", ofType: "mobileprovision"),
              let provisionData = try? Data(contentsOf: URL(fileURLWithPath: provisionPath)),
              let provisionString = String(data: provisionData, encoding: .ascii) else {
            return true
        }
        
        // 简单校验:检查签名是否存在
        // 实际项目中应该做更完整的签名校验
        return !provisionString.contains("TeamIdentifier")
    }
}

敏感信息脱敏

日志里绝对不能打印敏感信息。这一点说起来简单,做起来难。项目一大,到处都在打log,谁记得住哪些地方打印了敏感数据?

我的做法是封装一个安全日志工具,对敏感字段自动脱敏:

import os.log

enum LogLevel {
    case debug, info, warning, error
}

class SecureLogger {
    
    private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.app", category: "SecureLog")
    
    // 敏感字段白名单
    private static let sensitiveKeys = Set([
        "password", "token", "secret", "key", "credential",
        "cardNumber", "cvv", "pin", "ssn", "phone", "idCard"
    ])
    
    static func log(_ level: LogLevel, _ message: String, params: [String: Any] = [:]) {
        #if DEBUG
        // Debug模式下正常打印
        let sanitizedParams = params.mapValues { value -> Any in
            return value
        }
        print("[\(level)] \(message) \(sanitizedParams)")
        #endif
        
        // Release模式下,对敏感字段脱敏
        var safeMessage = message
        for (key, value) in params {
            if isSensitiveKey(key) {
                safeMessage = safeMessage.replacingOccurrences(
                    of: "\(value)", 
                    with: maskValue("\(value)")
                )
            }
        }
        
        switch level {
        case .debug: logger.debug("\(safeMessage, privacy: .public)")
        case .info: logger.info("\(safeMessage, privacy: .public)")
        case .warning: logger.warning("\(safeMessage, privacy: .public)")
        case .error: logger.error("\(safeMessage, privacy: .public)")
        }
    }
    
    private static func isSensitiveKey(_ key: String) -> Bool {
        let lowercased = key.lowercased()
        return sensitiveKeys.contains { lowercased.contains($0) }
    }
    
    private static func maskValue(_ value: String) -> String {
        guard value.count > 4 else { return "***" }
        let prefix = value.prefix(2)
        let suffix = value.suffix(2)
        let mask = String(repeating: "*", count: value.count - 4)
        return "\(prefix)\(mask)\(suffix)"
    }
}

键盘输入安全

这个点很多人会忽略——系统键盘在用户输入时,系统可能会缓存输入内容用于"智能输入建议"。对于密码输入框,必须禁用这个功能。

class SecureTextField: UIView {
    
    let textField = UITextField()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupSecureField()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setupSecureField()
    }
    
    private func setupSecureField() {
        textField.isSecureTextEntry = true
        textField.autocorrectionType = .no          // 禁用自动纠错
        textField.autocapitalizationType = .none     // 禁用自动大写
        textField.spellCheckingType = .no            // 禁用拼写检查
        textField.smartQuotesType = .no              // 禁用智能引号
        textField.smartDashesType = .no              // 禁用智能破折号
        textField.smartInsertDeleteType = .no        // 禁用智能插入删除
        
        // 禁用文本选择(防止截图泄露)
        textField.isUserInteractionEnabled = true
        
        // 防止截屏时显示内容
        textField.layer.addSecureEntryField()
    }
}

extension CALayer {
    func addSecureEntryField() {
        if #available(iOS 13.0, *) {
            // 使用私有API标记为安全输入区域
            // 注意:生产环境中不建议使用私有API
            // 这里仅作演示
        }
    }
}

App Store审核的那些事儿

说到安全开发,不得不提App Store审核。安全相关的审核被拒,我经历过好几次了,每次都让人血压飙升。

最经典的一次是,审核团队说我们的App"收集了过多的用户数据",直接给拒了。我们一脸懵——我们只收集了设备信息和位置信息啊。后来仔细一看,是我们用的一个第三方SDK在偷偷收集IDFA和剪切板内容,而我们自己都不知道。

所以,安全开发不仅仅是保护用户数据,还包括管好你用的每一个第三方库。

审核常见被拒原因 解决方案
隐私政策不完整 确保App Store Connect里的隐私标签准确反映实际数据收集情况
第三方SDK违规收集数据 使用依赖审计工具定期检查SDK行为
未提供账号注销功能 从iOS 15开始,提供账号删除功能是必须的
剪贴板访问无说明 每次访问剪贴板都需要有明确的用户触发场景

关于隐私清单(Privacy Manifest),这是Apple从2024年开始强制要求的。每个App和SDK都需要提供PrivacyInfo.xcprivacy文件,声明使用了哪些API以及收集了哪些数据。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>NSPrivacyTracking</key>
    <false/>
    <key>NSPrivacyTrackingDomains</key>
    <array/>
    <key>NSPrivacyCollectedDataTypes</key>
    <array>
        <dict>
            <key>NSPrivacyCollectedDataType</key>
            <string>NSPrivacyCollectedDataTypeDeviceID</string>
            <key>NSPrivacyCollectedDataTypeLinked</key>
            <false/>
            <key>NSPrivacyCollectedDataTypeUsed</key>
            <false/>
            <key>NSPrivacyCollectedDataTypePurposes</key>
            <array>
                <string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
            </array>
        </dict>
    </array>
    <key>NSPrivacyAccessedAPITypes</key>
    <array>
        <dict>
            <key>NSPrivacyAccessedAPIType</key>
            <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
            <key>NSPrivacyAccessedAPITypeReasons</key>
            <array>
                <string>CA92.1</string>
            </array>
        </dict>
    </array>
</dict>
</plist>

这个文件看起来简单,但里面的坑不少。比如NSPrivacyAccessedAPITypeReasons里的reason code,选错了审核直接不过。我当时为了这个跟Apple审核团队来回了四五封邮件,最后用百度Comate帮我查了一堆Apple Developer Forum的帖子,才搞清楚每个reason code对应的具体场景。

生物识别与本地认证

金融类App基本都绕不开生物识别。LocalAuthentication框架用起来不难,但有些细节要注意。

import LocalAuthentication

class BiometricAuthManager {
    
    enum AuthError: Error {
        case biometryNotAvailable
        case authenticationFailed
        case userCancelled
        case userFallback
        case unknown(Error)
    }
    
    /// 执行生物识别认证
    static func authenticate(reason: String, 
                            completion: @escaping (Result<Void, AuthError>) -> Void) {
        let context = LAContext()
        var error: NSError?
        
        // 检查设备是否支持生物识别
        guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
            completion(.failure(.biometryNotAvailable))
            return
        }
        
        // 设置回退策略
        context.localizedFallbackTitle = "使用密码"
        context.localizedCancelTitle = "取消"
        
        // 设置超时(可选)
        // context.touchIDAuthenticationAllowableReuseDuration = 0
        
        context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, 
                              localizedReason: reason) { success, evaluateError in
            DispatchQueue.main.async {
                if success {
                    completion(.success(()))
                } else {
                    guard let evaluateError = evaluateError else {
                        completion(.failure(.authenticationFailed))
                        return
                    }
                    
                    let laError = LAError(_nsError: evaluateError)
                    switch laError.code {
                    case .userCancel:
                        completion(.failure(.userCancelled))
                    case .userFallback:
                        completion(.failure(.userFallback))
                    default:
                        completion(.failure(.unknown(evaluateError)))
                    }
                }
            }
        }
    }
    
    /// 检查当前设备的生物识别类型
    static func biometryType() -> LABiometryType {
        let context = LAContext()
        _ = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
        return context.biometryType
    }
}

有个坑要特别注意:生物识别认证通过后,不要直接认为用户就是合法的。正确的做法是,认证通过后从Keychain中取出用户的Token或者密钥,用这个来做后续操作。因为生物识别本身只是验证了"设备的主人",但如果你的App被注入了恶意代码,攻击者可以绕过evaluatePolicy的回调直接拿到成功状态。

内存中的敏感数据

数据用完了要及时从内存中清除。Swift的String和Data在ARC管理下,你没法精确控制内存的释放时机。对于特别敏感的数据,比如加密密钥,可以用一些技巧来确保用完后立即清零。

/// 安全的内存缓冲区,释放时自动清零
class SecureBuffer {
    private var pointer: UnsafeMutableRawPointer?
    private let size: Int
    
    init(size: Int) {
        self.size = size
        self.pointer = UnsafeMutableRawPointer.allocate(byteCount: size, alignment: 1)
        // 清零初始化
        self.pointer?.initializeMemory(as: UInt8.self, repeating: 0, count: size)
    }
    
    func withUnsafeBytes<R>(_ body: (UnsafeRawBufferPointer) throws -> R) rethrows -> R {
        guard let pointer = pointer else {
            fatalError("SecureBuffer已被释放")
        }
        return try body(UnsafeRawBufferPointer(start: pointer, count: size))
    }
    
    func write(data: Data) {
        guard let pointer = pointer else { return }
        data.withUnsafeBytes { buffer in
            let copyCount = min(buffer.count, size)
            pointer.copyMemory(from: buffer.baseAddress!, byteCount: copyCount)
        }
    }
    
    deinit {
        // 释放前清零内存
        if let pointer = pointer {
            pointer.initializeMemory(as: UInt8.self, repeating: 0, count: size)
            pointer.deallocate()
        }
    }
}

一些碎碎念

写到这里,咖啡已经彻底凉透了,窗外的天也开始泛白。远程办公的好处是自由,坏处就是容易没有边界感,一写就写到天亮。

回顾这半年在iOS安全开发上的折腾,最大的感触是:安全不是一个功能,而是一种思维方式。不是说我加个加密、做个Pin就完事了,而是要在每一行代码里都想着"如果这里有漏洞会怎样"。

当然,有了AI工具的加持,这个过程确实轻松了不少。Cursor就不用说了,写代码的主力。遇到一些冷门的安全API不知道怎么用的时候,我会切到DeepSeek问问,它对Apple文档的理解还挺到位的。百度Comate的话,主要用来查一些国内开发者踩过的坑,毕竟中文社区里iOS安全方面的文章不算多,Comate能帮我快速筛选出有价值的信息。

最后说几句掏心窝子的话:

  1. 别等安全审计来了才重视安全。那时候改代码的成本是平时的十倍不止。
  2. 第三方SDK是安全隐患的重灾区。每引入一个新SDK,都要仔细审查它的权限和数据收集行为。
  3. 安全与体验要平衡。你不能为了安全把App搞得没法用,那跟没有App有什么区别。
  4. 持续关注Apple的安全更新。每年WWDC都会有一些安全相关的新特性,及时跟进能让你的App更安全也更容易过审。

好了,天亮了,该去洗漱然后假装今天也是按时起床的。如果你也在做iOS安全开发,欢迎在评论区交流,远程办公的人太需要社交了。

哦对了,如果你正在准备面试,iOS安全这块也是高频考点,Keychain、ATS、SSL Pinning、隐私清单这几个点一定要吃透。别问我怎么知道的,上个月刚帮一个朋友模拟面试来着。

评论 0

最热最新
暂无评论
GeekPowerLv.1
0
影响力
0
文章
0
粉丝