Secure your app: threat modeling and anti-patterns
Privacy & Security 进阶 26m

保护你的应用:威胁建模与反模式

Secure your app: threat modeling and anti-patterns

2020年6月24日

在 Apple 官方观看视频

一句话判断

大多数应用的安全漏洞不是因为加密算法不够强,而是因为开发者犯了常识性错误——这场 Session 用 STRIDE 威胁模型和真实反模式案例帮你系统性地堵住这些漏洞。

这场 Session 讲了什么

这场 Session 从软件安全工程的角度讲解了如何系统性地保护你的应用。它不是教你怎么用某个 API,而是教你一种思维方式——威胁建模(Threat Modeling)。Session 使用了微软的 STRIDE 框架来分类威胁:Spoofing(欺骗)、Tampering(篡改)、Repudiation(否认)、Information Disclosure(信息泄露)、Denial of Service(拒绝服务)、Elevation of Privilege(权限提升)。

对每种威胁类型,Session 都给出了移动应用中的具体场景和防御策略。比如 Spoofing 可能是攻击者伪造 API 请求中的用户身份;Tampering 可能是中间人修改传输中的数据;Information Disclosure 可能是日志中意外打印了用户密码。每个场景都配有代码级的反模式示例——那些”看起来没问题但实际上很危险”的写法。

Session 特别强调了”纵深防御”(Defense in Depth)原则:不要只依赖一层安全措施。即使你用了 HTTPS,也应该对敏感数据做本地加密;即使你验证了用户身份,也应该在后端再次校验权限。每一层防御都是一道独立的屏障,任何单一环节的失守都不应该导致整个系统的崩溃。

值得深挖的点

常见安全反模式

Session 列举了几个高频反模式:将 API Key 硬编码在源码中(攻击者反编译后一搜就找到)、用 UserDefaults 存储密码和 token(未加密的 plist 文件)、在 URL 中传递敏感参数(会被日志和代理记录)、不验证 TLS 证书(allowsAnyHTTPSCertificate 是最危险的代码之一)、以及没有对用户输入做服务端校验(客户端校验可以被绕过)。

Keychain vs UserDefaults 的安全边界

UserDefaults 以未加密的 plist 文件形式存储在磁盘上,任何有设备物理访问权限的人都可以读取。Keychain 使用硬件级别的加密,即使设备被越狱,没有用户的 passcode 也无法读取加密的 Keychain 条目。Session 的规则很简单:任何你不希望出现在明文日志中的数据,都应该存在 Keychain 中。

代码片段

import Security
import Foundation

// 正确:使用 Keychain 存储敏感数据
class SecureKeychain {
    static func save(key: String, data: Data) -> Bool {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecValueData as String: data,
            kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
            // 关键:ThisDeviceOnly 确保数据不会通过 iCloud Keychain 同步到其他设备
        ]
        
        // 先删除已有的条目
        SecItemDelete(query as CFDictionary)
        
        let status = SecItemAdd(query as CFDictionary, nil)
        return status == errSecSuccess
    }
    
    static func load(key: String) -> Data? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            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
    }
    
    // 反模式:绝对不要这样做
    // UserDefaults.standard.set("my_api_key_12345", forKey: "api_key")
    // UserDefaults 文件是明文 plist,任何有物理访问权限的人都能读取
}

// 使用示例
let token = "user_auth_token_xxx".data(using: .utf8)!
SecureKeychain.save(key: "auth_token", data: token)

if let savedToken = SecureKeychain.load(key: "auth_token") {
    print("Token 已安全存储")
}
// 安全的网络请求配置
import Foundation

class SecureNetworkClient {
    let session: URLSession
    
    init() {
        let config = URLSessionConfiguration.default
        
        // 安全配置
        config.urlCache = nil  // 敏感请求不缓存
        config.requestCachePolicy = .reloadIgnoringLocalCacheData
        
        // 使用系统的证书验证(不要关闭它!)
        // 反模式:sessionDelegate 中信任所有证书
        // func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge,
        //                 completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
        //     completionHandler(.useCredential, challenge.protectionSpace.serverTrust.map { URLCredential(trust: $0) })
        //     // 这样写等于没有 TLS 保护
        // }
        
        session = URLSession(configuration: config)
    }
    
    func makeAuthenticatedRequest(url: URL, token: String) {
        var request = URLRequest(url: url)
        request.httpMethod = "GET"
        
        // 正确:token 通过 HTTP Header 传递
        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        
        // 反模式:token 放在 URL 查询参数中
        // let url = URL(string: "https://api.example.com/data?token=\(token)")!
        // URL 会被代理、日志、浏览器历史记录完整保存
        
        session.dataTask(with: request) { data, response, error in
            // 处理响应
        }.resume()
    }
}
// 数据保护与文件加密
import Foundation

class SecureFileStorage {
    // 使用文件保护 API
    func saveProtectedFile(data: Data, to url: URL) throws {
        // 写入文件
        try data.write(to: url, options: .completeFileProtection)
        // .completeFileProtection: 设备锁定时文件不可读
        
        // 更严格保护
        // .completeFileProtectionUnlessOpen: 锁定时不可读,但已打开的文件可以继续访问
        // .completeFileProtectionUntilFirstUserAuthentication: 首次解锁前不可读
    }
    
    // 检查 App Transport Security 配置
    func verifySecurityConfiguration() {
        // 确保 Info.plist 中没有 NSAllowsArbitraryLoads = YES
        // 这会允许所有 HTTP 连接,严重降低安全性
        
        // 反模式:
        // <key>NSAppTransportSecurity</key>
        // <dict>
        //     <key>NSAllowsArbitraryLoads</key>
        //     <true/>
        // </dict>
        
        // 正确做法:只对需要的主机豁免
        // <key>NSExceptionDomains</key>
        // <dict>
        //     <key>legacy-server.example.com</key>
        //     <dict>
        //         <key>NSExceptionAllowsInsecureHTTPLoads</key>
        //         <true/>
        //     </dict>
        // </dict>
    }
}

最佳实践

  • 所有敏感数据(token、密码、API Key)必须存储在 Keychain 中,设置 kSecAttrAccessibleWhenUnlockedThisDeviceOnly
  • 永远不要在代码中硬编码密钥和 token——使用环境变量或运行时从安全存储中获取
  • 保持 App Transport Security 开启,只在必要时对特定域名做最小化豁免
  • 在服务端验证所有用户输入和权限——客户端的验证是为了 UX,不是为了安全
  • 定期使用 Xcode 的 Security Audit 工具和 static analyzer 检查代码中的安全漏洞

还有什么值得关注

  • Session 提到了 Apple 的 Security Framework 中新增了 CryptoKit 的高级功能,建议使用 CryptoKit 而非 CommonCrypto
  • TLS 1.3 在 iOS 14 中默认启用,你的服务器也应该尽快支持
  • 对于企业级应用,MDM 可以远程擦除设备上的 Keychain 数据,这是应对设备丢失的重要安全措施
WWDC 2020