聊聊我在鹅厂做iOS安全开发踩过的坑

一键启动人生
2026-07-24 01:54
阅读 720

腾讯三年客户端开发,做过微信小程序相关业务,最近在准备跳槽,边工作边刷题,喜欢研究底层原理。


上周五晚上十一点半,我正在工位上刷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这个属性。它保证了:

  1. 设备重启后第一次解锁前,数据不可访问
  2. 数据不会被同步到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掉forkstat这些系统调用,让你的检测全部失效。所以更稳妥的做法是:检测到了越狱设备,不要直接闪退或者弹框告诉用户"你越狱了"(这样等于告诉攻击者你的检测逻辑),而是默默降低这个设备的信任等级,对敏感操作增加验证,比如要求重新输入密码、限制某些高风险功能等。

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

最热最新
暂无评论
一键启动人生Lv.1
0
影响力
0
文章
0
粉丝