保护用户数据,别让App变“裸奔”:一个搜索工程师的iOS安全实践手记

死锁制造者
2026-01-04 21:36
阅读 1659

上周五晚上十一点半,我正瘫在成都家里那张被猫抓得有点破的沙发上刷《三体》,突然钉钉“叮”了一声。一看是产品经理发来的消息:“兄弟,咱们新版本要过审了,Apple那边说我们敏感权限没处理好,再不修就拒了。”

我差点一口冰可乐喷出来。这已经是本月第三次因为隐私问题被打回来了。作为一个在百度干了两年、天天和搜索算法打交道的后端偏移动端杂食型工程师,我本以为自己对“数据安全”理解得够深了——毕竟每天都在处理TB级的用户查询日志。但真到了写 iOS App 的时候,才发现理论和实践之间,隔着一整个App Store审核团队的距离

说实话,以前我对前端(或者说客户端)的安全防护总有点“傲慢”。觉得“不就是存个 token、加个 HTTPS 嘛”,直到自己踩了坑才明白:在 Apple 的生态里,用户数据不是你“能不能拿”的问题,而是你“有没有资格碰”的问题

今天这篇笔记,不讲大道理,也不堆术语。就聊聊我在开发内部工具类 App 时,从被拒到一次过审的过程中,总结出的那些真正管用的 iOS 安全开发最佳实践。顺便安利几本让我少走弯路的书——毕竟,代码人生,不能光靠 Stack Overflow 硬扛。


从“裸存”到 Keychain:本地存储的血泪史

最开始,为了赶双11前上线,我图省事,直接把用户登录后的 access token 存在 UserDefaults 里。代码简单到令人发指:

// 千万别学!这是反面教材!
UserDefaults.standard.set(accessToken, forKey: "user_token")

结果呢?第一次提审就被拒了。理由很官方:“App stores sensitive user data insecurely.”(应用以不安全方式存储敏感用户数据)。我当时还纳闷:HTTPS 都用了,数据传输没问题啊?

后来翻 Apple 的 App Store Review Guidelines 第 5.1.1 条才恍然大悟:本地存储同样受严格监管UserDefaults 是明文存的 plist 文件,随便一台越狱机或者用 Xcode 调试器就能 dump 出来。这等于把家门钥匙贴在楼道公告栏上。

痛定思痛,我重构成用 Keychain。Keychain 是 iOS 提供的加密存储服务,由系统级安全模块(Secure Enclave)保护,即使设备被物理获取,也极难破解。

关键代码如下(基于 KeychainSwift 封装库):

import KeychainSwift

let keychain = KeychainSwift()
keychain.set("my_secret_token", forKey: "auth_token")
keychain.set("user_id_123", forKey: "user_id")

// 读取时
if let token = keychain.get("auth_token") {
    // 使用 token
}

小贴士:别自己造轮子!Apple 的原生 Keychain API 写起来又臭又长。推荐用开源库如 KeychainAccessKeychainSwift,它们封装了复杂性,还支持 group sharing(适合主 App + Widget 场景)。

另外,不要把 Keychain 当数据库用!它适合存少量高敏信息(token、密码、证书),不适合存大量结构化数据。曾经有个同事试图把用户历史搜索记录全塞进 Keychain,结果启动慢得像蜗牛,还触发了系统限制。


网络通信:HTTPS 不是终点,而是起点

说到安全,很多人第一反应是“上 HTTPS”。没错,这是基础。但在 iOS 9 之后,Apple 强制启用了 App Transport Security (ATS),要求所有网络请求必须使用 TLS 1.2+,且证书必须有效。

你以为配个 HTTPS 就万事大吉?Too young.

去年我们对接一个第三方物流接口,对方用的是自签名证书。测试时在模拟器跑得好好的,一上真机就报错:

NSURLSession/NSURLConnection HTTP load failed (kCFStreamErrorDomainSSL, -9802)

查了半天,发现是 ATS 拦截了。临时方案是在 Info.plist 里加例外:

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSExceptionDomains</key>
    <dict>
        <key>insecure-logistics-api.com</key>
        <dict>
            <key>NSExceptionAllowsInsecureHTTPLoads</key>
            <true/>
            <key>NSExceptionMinimumTLSVersion</key>
            <string>TLSv1.2</string>
        </dict>
    </dict>
</dict>

但!Apple 明确表示:提审时如果发现你为非必要域名关闭 ATS,大概率被拒。所以我们最后还是逼着第三方升级了证书。

更安全的做法是:证书绑定(Certificate Pinning)。原理很简单——在 App 里预埋服务器公钥指纹,每次连接时校验。这样即使中间人伪造了合法 CA 签发的证书,也无法通过验证。

用 Alamofire 实现证书绑定示例:

let serverTrustPolicies: [String: ServerTrustEvaluating] = [
    "api.mysearchapp.com": PublicKeysTrustEvaluator(),
]

let session = Session(
    serverTrustManager: ServerTrustManager(evaluators: serverTrustPolicies)
)

session.request("https://api.mysearchapp.com/data").response { response in
    // 处理响应
}

当然,证书绑定也有坑:更新证书时必须同步发版 App,否则全量崩溃。我们吃过一次亏,半夜回滚版本,运维差点把我名字刻在工牌背面。


权限申请:别让用户觉得你在“偷看”

作为搜索工程师,我深知用户行为数据的价值。但 Apple 的哲学是:“用户有权知道你在看什么,并随时说不”。

所以,任何涉及隐私的权限(定位、相册、麦克风、通讯录等),必须提前弹窗说明用途,而且文案要具体、真诚。

比如,别写“我们需要访问您的位置以提供更好服务”——太虚了!
应该写:“开启定位后,可为您展示附近的搜索热点(如‘成都春熙路奶茶店’)”。

更重要的是:只在真正需要时才请求权限。别一启动 App 就弹五个权限框,用户会直接点“不允许”然后卸载。

我们在做语音搜索功能时,一开始在首页就请求麦克风权限。结果留存率暴跌。后来改成:点击麦克风图标时才触发授权,转化率立马回升。

SwiftUI 下优雅请求权限的示例:

import AVFoundation
import SwiftUI

struct VoiceSearchView: View {
    @State private var isRecording = false
    
    var body: some View {
        Button(action: requestMicrophonePermission) {
            Image(systemName: "mic.fill")
        }
    }
    
    private func requestMicrophonePermission() {
        AVAudioSession.sharedInstance().requestRecordPermission { granted in
            DispatchQueue.main.async {
                if granted {
                    startRecording()
                } else {
                    showPermissionDeniedAlert()
                }
            }
        }
    }
}

记住:拒绝权限 ≠ 功能不可用。要提供降级方案。比如用户不让用定位,就默认用城市 IP 定位,而不是直接灰掉按钮。


代码混淆与反调试:防君子也防小人

虽然 Apple 的沙盒机制已经很强,但如果你的 App 涉及金融、支付或高价值数据,建议加上反调试代码混淆

我们内部有个工具用于分析搜索 query 的敏感词,逻辑比较核心。为防止被逆向,我做了三件事:

  1. 启用 Bitcode:虽然 Apple 已逐步弃用,但在某些场景下仍能增加反编译难度。
  2. 字符串加密:关键 URL、API 字段名用简单 XOR 或 Base64 编码(别用太复杂的,影响性能)。
  3. 检测调试器:在关键函数入口加一段汇编检测。

反调试代码片段(谨慎使用,可能误伤):

#include <sys/types.h>
#include <sys/ptrace.h>

bool isDebuggerAttached() {
    return ptrace(PT_TRACE_ME, 0, 0, 0) == -1;
}

// 在 AppDelegate 初始化时调用
if (isDebuggerAttached()) {
    exit(0); // 或者返回假数据
}

不过要提醒:过度防护可能违反 App Store 规则。Apple 明确禁止“干扰审核流程”的行为。我们曾因检测到 Xcode 调试环境就返回空白页面,被认定为“隐藏功能”,直接拒审。

平衡之道是:只对核心数据路径做轻量防护,别搞成“谍战片”


书籍与学习:站在巨人的肩膀上写代码

说实话,这些经验很多是从坑里爬出来的。但如果有正确的指引,能少走半年弯路。

强烈推荐两本书:

  • 《iOS 应用安全权威指南》(作者:David Thiel)
    虽然出版于 2016 年,但底层原理至今适用。讲透了 Keychain、沙盒、代码签名等机制,比网上碎片化教程系统得多。

  • 《Secure by Design》(中文版:《安全设计之道》)
    不局限于 iOS,但从架构层面教你如何“从第一天起就考虑安全”。我在百度做搜索服务时就受这本书启发,现在写 App 也沿用其思想。

此外,Apple 官方文档其实写得非常清晰,尤其是 SecurityApp Store Review Guidelines 这两块。别嫌英文难啃,每年 WWDC 的 Security 相关 Session 也值得看(比如 WWDC21 的 “Explore secure app and system design”)。


总结:安全不是功能,而是态度

回到开头那个周五夜晚。我改完 Keychain 存储、加上权限说明文案、移除 ATS 例外后,重新打包上传。第二天下午,审核通过。

那一刻我瘫在椅子上,点了杯瑞幸(成都程序员续命水),突然想起刚入职百度时导师说过的一句话:“你写的每一行代码,背后都是真实的人。”

在搜索领域,我们常说“理解用户意图”;在安全领域,其实也一样——你要理解用户的恐惧。他们怕隐私泄露,怕账号被盗,怕手机变成监控器。而我们的责任,就是用技术筑起一道看不见的墙。

现在,每当我写 Swift 代码时,除了考虑性能、可维护性,还会多问一句:“这段逻辑,会让用户感到不安吗?”

代码人生,不止于跑通,更在于值得托付。


附:iOS 安全开发自查清单(提审前必看)

检查项 是否完成 备注
敏感数据是否使用 Keychain 存储? ✅ / ❌ 如 token、密码
所有网络请求是否强制 HTTPS? ✅ / ❌ 检查 ATS 配置
是否实现证书绑定(如必要)? ✅ / ❌ 金融类 App 强烈建议
隐私权限是否延迟申请 + 明确说明? ✅ / ❌ 文案需具体
Info.plist 中是否包含 NSPrivacy... 描述? ✅ / ❌ NSLocationWhenInUseUsageDescription
是否避免在日志中打印用户数据? ✅ / ❌ Release 版本关闭 debug log
第三方 SDK 是否最小化集成? ✅ / ❌ 移除未使用的分析/广告 SDK

愿你的 App 永远一次过审,用户数据安然无恙。

评论 0

最热最新
暂无评论
死锁制造者Lv.1
0
影响力
0
文章
0
粉丝