聊聊我在鹅厂做iOS安全开发踩过的坑
腾讯三年客户端开发,做过微信小程序相关业务,最近在准备跳槽,边工作边刷题,喜欢研究底层原理。
上周五晚上十一点半,我正在工位上刷LeetCode,突然收到一条告警推送——线上某个小程序的本地缓存数据被越狱设备上的第三方工具直接dump出来了。当时我手里的奶茶差点洒在键盘上,心想这要是被安全团队抓到,年终奖怕是要打骨折了。
赶紧拉群排查,搞到凌晨两点才定位到问题。第二天顶着黑眼圈跟leader汇报,leader拍了拍我肩膀说:"兄弟,这块确实得系统性地搞一下了,你来牵头吧。"
得,又给自己揽了个活儿。
先说说背景
我在鹅厂主要负责微信小程序相关的客户端开发,说白了就是天天跟WXML、WXSS、JS桥打交道。但安全这事儿吧,平时不显山不露水,一出事就是大事。我们组之前其实也有做一些安全加固,但说实话,比较零散,属于"头痛医头脚痛医脚"那种。
去年双11期间,我们的小程序日活峰值破了千万,各种薅羊毛的、搞逆向的、抓包改请求的妖魔鬼怪全出来了。安全团队那边每周都能给我们提一堆漏洞,什么明文存储敏感信息啦、证书校验没做严格啦、关键数据没加密啦……说实话,有些漏洞我看了都想骂前任开发者,但转念一想,我自己不也写过类似的代码嘛,五十步笑百步罢了。
最近准备跳槽,面试了几家大厂,发现安全这块问得越来越多了。所以借着这次系统性加固的机会,把踩过的坑和最佳实践整理出来,算是给自己做个总结,也希望能帮到同行兄弟们。
Keychain不是万能的
很多iOS开发同学一说到本地安全存储,第一反应就是:"用Keychain啊!"
没错,Keychain确实是苹果官方推荐的安全存储方案,比UserDefaults强了不知道多少倍。但Keychain也不是银弹,你得知道它的能力边界在哪。
我们之前有个很蠢的做法,把用户的session token直接明文扔进Keychain就完事了。结果呢?越狱设备上有个叫keychain-dumper的工具,分分钟把你的Keychain数据全导出来。没越狱的设备呢?如果用户做了iCloud备份,备份文件在某些情况下也是可以被提取的。
后来我们的做法是:Keychain + 本地加密,双重保护。
import Security
import CryptoKit
class SecureStorage {
private static let service = "com.yourapp.secure"
// 从Keychain读取加密密钥(密钥本身存在Keychain里)
private static func getEncryptionKey() throws -> SymmetricKey {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: "encryption_key",
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status == errSecItemNotFound {
// 首次使用,生成一个新的密钥
let key = SymmetricKey(size: .bits256)
let keyData = key.withUnsafeBytes { Data($0) }
try saveKeyToKeychain(keyData)
return key
}
guard status == errSecSuccess,
let data = item as? Data else {
throw SecureStorageError.keychainError(status)
}
return SymmetricKey(data: data)
}
private static func saveKeyToKeychain(_ keyData: Data) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: "encryption_key",
kSecValueData as String: keyData,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw SecureStorageError.keychainError(status)
}
}
// 加密后存储到UserDefaults(数据本身加密了,存哪都安全些)
static func saveSensitiveData(_ data: Data, forKey key: String) throws {
let encryptionKey = try getEncryptionKey()
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
guard let combined = sealedBox.combined else {
throw SecureStorageError.encryptionFailed
}
UserDefaults.standard.set(combined, forKey: key)
}
static func loadSensitiveData(forKey key: String) throws -> Data {
guard let combined = UserDefaults.standard.data(forKey: key) else {
throw SecureStorageError.dataNotFound
}
let encryptionKey = try getEncryptionKey()
let sealedBox = try AES.GCM.SealedBox(combined: combined)
return try AES.GCM.open(sealedBox, using: encryptionKey)
}
enum SecureStorageError: Error {
case keychainError(OSStatus)
case encryptionFailed
case dataNotFound
}
}
这里有个细节要注意:kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly这个属性。它保证了:
- 设备重启后第一次解锁前,数据不可访问
- 数据不会被同步到iCloud或其他设备
很多同学习惯用kSecAttrAccessibleAfterFirstUnlock,结果数据跟着iCloud跑了,换个设备就能读到,这在安全场景下是个隐患。
网络层的安全,远不止HTTPS
"我们用了HTTPS,安全的。"
每次听到有同学这么说,我都想翻白眼。HTTPS只是基础中的基础,你要是不做证书校验,中间人攻击分分钟教你做人。
我们之前线上就出过一件事:有个用户在公共WiFi环境下,请求被劫持了,返回了一个假的接口数据。排查了一圈发现,我们用的某个第三方SDK,内部的网络请求居然没有做证书校验(是的,某些大厂的SDK也会犯这种低级错误)。
后来我们统一封装了网络层,强制做SSL Pinning:
class NetworkSecurityManager: NSObject, URLSessionDelegate {
// 预置的服务器证书公钥hash(SHA256)
private let pinnedPublicKeyHashes: Set<String> = [
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", // 主证书
"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 policy = SecPolicyCreateSSL(true, challenge.protectionSpace.host as CFString)
SecTrustSetPolicies(serverTrust, policy)
var trustResult: SecTrustResultType = .invalid
let status = SecTrustEvaluate(serverTrust, &trustResult)
guard status == errSecSuccess,
trustResult == .unspecified || trustResult == .proceed else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// 再做公钥Pinning
guard let serverCertificate = SecTrustGetCertificateAtIndex(serverTrust, 0) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let serverPublicKey = SecCertificateCopyKey(serverCertificate)
let serverPublicKeyData = SecKeyCopyExternalRepresentation(serverPublicKey!, nil)
let serverHash = (serverPublicKeyData! as Data).sha256().base64EncodedString()
if pinnedPublicKeyHashes.contains(serverHash) {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
} else {
// 证书不匹配,拒绝连接,同时上报安全事件
completionHandler(.cancelAuthenticationChallenge, nil)
reportSecurityEvent(.sslPinningFailed, host: challenge.protectionSpace.host)
}
}
private func reportSecurityEvent(_ event: SecurityEvent, host: String) {
// 上报到安全监控平台,方便排查
}
enum SecurityEvent {
case sslPinningFailed
case jailbreakDetected
}
}
顺便说一句,备用证书那个hash一定要留。去年我们证书到期轮换的时候,就是因为提前在客户端预置了新证书的hash,才做到了无感切换。不然要是客户端发版才能更新hash,那段时间用户就全报错了,想想都后怕。
越狱检测:道高一尺魔高一丈
越狱检测这事儿吧,说实话,是个"猫鼠游戏"。你做一层检测,人家就绕一层。但做总比不做好,至少能挡住大部分脚本小子。
我们目前的检测方案是多维度综合判断:
class JailbreakDetector {
static func isDeviceJailbroken() -> Bool {
// 多维度检测,命中任意一条就认为是越狱设备
return checkSuspiciousPaths()
|| checkCanOpenCydia()
|| checkWriteOutsideSandbox()
|| checkForkProcess()
}
// 1. 检测越狱相关的路径是否存在
private static func checkSuspiciousPaths() -> Bool {
let paths = [
"/Applications/Cydia.app",
"/usr/sbin/sshd",
"/bin/bash",
"/etc/apt",
"/private/var/lib/apt/",
"/Library/MobileSubstrate/MobileSubstrate.dylib",
"/usr/lib/substrate",
"/private/var/lib/cydia",
"/private/var/mobile/Library/SBSettings/Settings.plist"
]
return paths.contains { FileManager.default.fileExists(atPath: $0) }
}
// 2. 检测能否打开Cydia的URL Scheme
private static func checkCanOpenCydia() -> Bool {
if let url = URL(string: "cydia://package/com.example.package") {
return UIApplication.shared.canOpenURL(url)
}
return false
}
// 3. 检测能否在沙盒外写文件
private static func checkWriteOutsideSandbox() -> Bool {
let path = "/private/jailbreak_test"
do {
try "test".write(toFile: path, atomically: true, encoding: .utf8)
try FileManager.default.removeItem(atPath: path)
return true // 能写进去,说明越狱了
} catch {
return false
}
}
// 4. 检测fork系统调用(越狱设备上通常可以fork)
private static func checkForkProcess() -> Bool {
let forkResult = fork()
if forkResult >= 0 {
// fork成功了,说明越狱了
if forkResult > 0 {
// 父进程,需要kill子进程
kill(forkResult, SIGKILL)
}
return true
}
return false
}
}
但说实话,这些检测手段都有被绕过的可能。比如fishhook可以hook掉fork、stat这些系统调用,让你的检测全部失效。所以更稳妥的做法是:检测到了越狱设备,不要直接闪退或者弹框告诉用户"你越狱了"(这样等于告诉攻击者你的检测逻辑),而是默默降低这个设备的信任等级,对敏感操作增加验证,比如要求重新输入密码、限制某些高风险功能等。
App Store审核的血泪教训
说到安全开发,不得不提App Store审核。这块我真是被坑过好几回了。
有一次我们上了一个加密通信功能,用到了CommonCrypto。结果审核被拒了,理由是"使用了加密相关API,需要提交ERB(Encryption Registration)"。我们之前根本没搞过这个,赶紧去商务部申请,折腾了快两周才搞定。
所以这里给大家一个忠告:如果你的App用到了任何加密相关的东西(包括HTTPS以外的加密),一定要提前搞清楚出口合规的问题。
| 场景 | 是否需要ERB | 备注 |
|---|---|---|
| 仅使用HTTPS | 不需要 | 标准HTTPS属于豁免范围 |
| 使用AES/RSA等加密本地数据 | 需要 | 即使是本地加密也要申报 |
| 使用系统自带的Keychain | 不需要 | 系统API豁免 |
| 使用CryptoKit | 需要 | 虽然也是系统API,但涉及自定义加密逻辑 |
| 仅做哈希(MD5/SHA) | 不需要 | 哈希不属于加密 |
另外,Info.plist里的NSAppTransportSecurity配置也要注意。有些同学为了调试方便,把NSAllowsArbitraryLoads设成YES,结果审核直接被拒。正确的做法是:
<key>NSAppTransportSecurity</key>
<dict>
<!-- 不要设成YES!审核会拒! -->
<!-- <key>NSAllowsArbitraryLoads</key>
<true/> -->
<!-- 如果确实有某些域名需要例外,精确配置 -->
<key>NSExceptionDomains</key>
<dict>
<key>legacy-api.yourcompany.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSExceptionMinimumTLSVersion</key>
<string>TLSv1.2</string>
</dict>
</dict>
</dict>
一些容易被忽视的细节
最后说几个我们团队在实战中总结出来的小tips,都是血泪教训:
1. 日志脱敏
我们之前有个线上问题排查,开发同学为了方便,在Debug模式下把用户的手机号、身份证号全打到日志里了。结果Release包忘了关掉这个开关,被安全扫描扫出来了。
// 正确做法:封装一个安全的日志工具
class SafeLogger {
#if DEBUG
static func debug(_ message: String) {
print("[DEBUG] \(message)")
}
#else
static func debug(_ message: String) {
// Release环境下什么都不做
}
#endif
// 敏感信息永远不要打到日志里,不管什么环境
static func info(_ message: String) {
#if DEBUG
print("[INFO] \(message)")
#endif
// 写入本地日志文件时,自动脱敏
let sanitized = sanitize(message)
writeToFile(sanitized)
}
private static func sanitize(_ message: String) -> String {
// 手机号脱敏:138****1234
// 身份证脱敏:110***********1234
// ... 正则替换
return message
}
}
2. 剪贴板安全
iOS 14之后,读取剪贴板会弹系统提示。但更重要的是,不要把敏感数据写到剪贴板里。用户复制了一个验证码,结果被其他App读走了,这种事故我们行业里不是没发生过。
3. 后台截图保护
App进入后台时,系统会自动截一张图作为多任务切换的预览。如果这时候你的页面正好展示着用户的银行卡号、密码啥的,那这张截图就会保存在磁盘上。
// AppDelegate里处理
func applicationWillResignActive(_ application: UIApplication) {
// 盖一层遮罩,防止敏感信息泄露
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
blurView.frame = window?.bounds ?? .zero
blurView.tag = 9999
window?.addSubview(blurView)
}
func applicationDidBecomeActive(_ application: UIApplication) {
window?.viewWithTag(9999)?.removeFromSuperview()
}
4. 键盘缓存
UITextField默认会有键盘缓存(UITextAutocorrectionType默认是yes),如果你做的是密码输入框,一定要关掉:
passwordField.autocorrectionType = .no
passwordField.spellCheckingType = .no
// 如果是密码,用secure entry
passwordField.isSecureTextEntry = true
// 禁止密码管理器自动填充(某些场景需要)
passwordField.textContentType = .oneTimeCode
写在最后
安全开发这事儿,说白了就是"没有银弹"。你做了Keychain加密,人家能hook你的App;你做了越狱检测,人家能绕过你的检测;你做了SSL Pinning,人家能反编译你的App拿到公钥hash。
但做和不做,区别就是成本。你防护做得越完善,攻击者的成本就越高,大部分攻击者就会知难而退。安全本质上就是个博弈的过程。
最近准备跳槽,面试的时候被问到安全相关的问题,发现能把这些实战经验讲清楚的同学真的不多。很多候选人背一堆理论,但一问到具体场景怎么处理就懵了。所以建议大家,有机会的话一定要在实际项目中把这些安全方案落地,踩过坑的经验才是最值钱的。
好了,不说了,leader又在群里@我了,好像又出了什么安全告警。打工人打工魂,我去搬砖了。
如果这篇文章对你有帮助,欢迎点赞收藏,有问题评论区见。我们下期再见。
P.S. 最近刷题刷到头秃,LeetCode Hard真的是反人类。有没有一起准备跳槽的兄弟,可以互相交流下面试经验。


评论 0