聊聊我在iOS安全开发里踩过的坑和总结的最佳实践
凌晨两点半,耳机里循环着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帮我理清了好几个容易搞混的概念,比如SecTrustGetCertificateAtIndex和SecTrustCopyCertificateChain的区别——前者在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能帮我快速筛选出有价值的信息。
最后说几句掏心窝子的话:
- 别等安全审计来了才重视安全。那时候改代码的成本是平时的十倍不止。
- 第三方SDK是安全隐患的重灾区。每引入一个新SDK,都要仔细审查它的权限和数据收集行为。
- 安全与体验要平衡。你不能为了安全把App搞得没法用,那跟没有App有什么区别。
- 持续关注Apple的安全更新。每年WWDC都会有一些安全相关的新特性,及时跟进能让你的App更安全也更容易过审。
好了,天亮了,该去洗漱然后假装今天也是按时起床的。如果你也在做iOS安全开发,欢迎在评论区交流,远程办公的人太需要社交了。
哦对了,如果你正在准备面试,iOS安全这块也是高频考点,Keychain、ATS、SSL Pinning、隐私清单这几个点一定要吃透。别问我怎么知道的,上个月刚帮一个朋友模拟面试来着。


评论 0