本文目录

客户端安全不是“让应用无法被破解”,而是在不可信设备上保护用户数据、限制攻击能力、提高滥用成本,并确保最终权限由可信服务端控制。


一、客户端安全的基本前提

Flutter 应用最终运行在用户控制的设备上。攻击者可能:

  • 获取安装包并进行静态分析。
  • 在 Root 或越狱设备上读取文件和进程信息。
  • Hook Dart、Native 函数或网络调用。
  • 修改客户端逻辑并重新打包。
  • 控制代理、DNS、Wi-Fi 和本地证书环境。
  • 自动化调用接口和批量滥用业务能力。
  • 从日志、截图、剪贴板、备份和通知中获取数据。

因此必须接受一个事实:

任何交付到客户端的固定密钥、算法、开关和业务规则,最终都有被读取或修改的可能。

这不代表客户端防护没有价值。合理的安全设计可以:

  • 降低意外泄露风险。
  • 提高逆向和自动化攻击成本。
  • 缩小单次凭证泄露的影响范围。
  • 延缓攻击,为服务端检测和止损争取时间。
  • 满足隐私、合规和审计要求。

客户端安全需要纵深防御:

flowchart LR
    A[安全设计] --> B[最小数据与权限]
    B --> C[安全存储与传输]
    C --> D[完整性与滥用检测]
    D --> E[服务端认证授权]
    E --> F[监控、灰度与响应]

二、从威胁建模开始

安全措施应针对明确威胁,而不是机械添加 Root 检测、混淆或证书绑定。

2.1 识别资产

需要保护的资产通常包括:

  • 登录 Token 和会话。
  • 用户个人信息。
  • 支付、订单和账户资产。
  • 本地业务数据和文件。
  • API 配额和付费能力。
  • 企业内部接口和配置。
  • 代码完整性与发布渠道。

2.2 识别入口

  • 登录、注册和找回密码。
  • 网络 API。
  • 本地数据库和文件。
  • Deep Link、推送和二维码。
  • WebView 与 JavaScript Bridge。
  • Platform Channel 和第三方 SDK。
  • 文件导入、导出和分享。
  • 远端配置和 Feature Flag。

2.3 评估影响

威胁可能影响主要防护方向
Token 被窃取账号接管短期凭证、刷新轮换、安全存储、服务端撤销
本地数据被读取隐私泄露数据最小化、加密、备份控制
客户端被篡改绕过 UI 与业务限制服务端授权、完整性信号、风控
接口被自动化滥用资源损失限流、风控、设备和账号关联
依赖被投毒代码执行、数据泄露依赖治理、锁定版本、制品验证

风险优先级应结合发生概率、影响范围、可检测性和恢复成本。不是所有应用都需要同等级的防护。


三、客户端不能保存真正的机密

下面的内容即使写入环境变量或经过混淆,也会进入最终制品:

const paymentSecret = String.fromEnvironment('PAYMENT_SECRET');

dart-define 适合构建配置,不是机密管理系统。攻击者可以通过静态分析、动态 Hook 或观察请求过程提取值。

不应放在客户端

  • 服务端私钥。
  • 数据库管理员凭证。
  • 可直接调用高权限接口的长期 API Secret。
  • 支付签名私钥。
  • 所有用户共享的永久加密主密钥。

可以放在客户端但要限制能力

  • 明确设计为公开标识的 API Key。
  • 短期访问 Token。
  • 与应用签名、包名、域名或配额绑定的 Key。
  • 只能调用低风险公开能力的标识。

安全能力应下沉到服务端:

sequenceDiagram
    participant A as Flutter App
    participant B as Backend
    participant P as Third-party API

    A->>B: 用户身份 + 业务请求
    B->>B: 认证、授权、风控
    B->>P: 使用服务端 Secret 调用
    P-->>B: 结果
    B-->>A: 最小必要数据

四、认证与会话安全

4.1 Access Token 与 Refresh Token

常见设计:

  • Access Token 生命周期短,用于普通 API。
  • Refresh Token 生命周期较长,只用于获取新 Access Token。
  • Refresh Token 轮换,旧 Token 使用后失效。
  • 服务端支持撤销、设备管理和风险冻结。

Token 生命周期需要在安全和体验之间平衡。仅在客户端设置过期时间不够,服务端必须检查有效性和撤销状态。

4.2 避免 Token 刷新风暴

多个请求同时收到 401 时,应让一个刷新操作执行,其余请求等待结果:

flowchart TD
    A[多个请求收到 401] --> B{已有刷新任务?}
    B -->|是| C[等待同一个 Future]
    B -->|否| D[创建刷新任务]
    D --> E{刷新成功?}
    E -->|是| F[重放允许重试的请求]
    E -->|否| G[清理会话并重新登录]
    C --> E

还要防止:

  • 刷新接口本身触发无限 401 循环。
  • 旧刷新结果覆盖新登录会话。
  • 非幂等请求被自动重复提交。
  • 登出后仍写入旧 Token。

4.3 敏感操作重新认证

即使用户已登录,修改密码、查看恢复码、转账等高风险操作仍可要求:

  • 最近登录验证。
  • 生物识别或设备凭证。
  • OTP 或其他二次认证。
  • 服务端风险挑战。

生物识别通常用于解锁设备上的密钥或确认本地用户存在,不能独立证明服务端账号身份。


五、本地安全存储

5.1 普通存储与安全存储

数据推荐位置说明
主题、语言Preferences非敏感配置
Access/Refresh TokenKeychain/Keystore 封装小型敏感凭证
大型业务数据数据库或文件按敏感度决定是否加密
加密主密钥Keychain/Keystore不与密文放在同一普通文件中

shared_preferences 不是安全存储,不能用来保存 Token、密码或私钥。

Flutter 的安全存储插件通常封装:

  • iOS Keychain。
  • Android Keystore 及加密存储机制。

具体加密方式、备份行为、设备迁移和最低系统版本依插件版本而异,必须阅读当前插件与平台文档。

5.2 安全存储不等于绝对安全

Keychain/Keystore 能降低普通文件读取风险,但不能保证:

  • Root/越狱环境中永远无法提取。
  • 应用运行时 Token 不会被 Hook。
  • 解锁后的明文不会出现在内存。
  • 备份和设备迁移配置一定符合业务要求。

因此仍需短期凭证、服务端撤销、设备风险检测和异常行为监控。

5.3 数据库加密与密钥管理

数据库加密只有在密钥管理正确时才有意义。

flowchart LR
    A[Keystore / Keychain] --> B[解锁数据密钥]
    B --> C[加密数据库]
    C --> D[最小时间持有明文]

常见错误:

  • 把数据库密钥硬编码在应用中。
  • 将密钥和密文保存在同一个普通文件。
  • 所有用户共享同一固定密钥。
  • 只加密数据库,却把敏感数据写入日志和缓存。

是否加密整个数据库应根据威胁模型决定。字段级加密可能减少敏感数据暴露,但会增加查询、迁移和密钥轮换复杂度。

5.4 数据生命周期

  • 只存储业务必需数据。
  • 设置缓存过期和大小上限。
  • 登出时删除账号相关敏感数据。
  • 账号切换时隔离数据命名空间。
  • 删除账号后清理本地残留。
  • 明确系统备份是否允许包含这些数据。

“加密后永久保存”不符合数据最小化原则。


六、日志、崩溃与分析数据

日志是常见泄露渠道。下面的代码不应出现:

// 错误示例
debugPrint('login token=$token');
debugPrint('response=${response.body}');

应禁止记录

  • Token、Cookie、Authorization Header。
  • 密码、验证码和恢复码。
  • 银行卡、证件和生物信息。
  • 完整请求与响应正文。
  • 用户输入的聊天、搜索和表单内容。
  • 敏感 Deep Link 参数。

结构化脱敏

Map<String, Object?> sanitizeNetworkEvent({
  required String routeName,
  required int statusCode,
  required Duration duration,
}) {
  return {
    'http.route': routeName,
    'http.status_code': statusCode,
    'duration_ms': duration.inMilliseconds,
  };
}

使用归一化路由名而不是完整 URL。字段采用 Allowlist,不依赖开发者在每次调用时手工删除敏感值。

崩溃上报

堆栈、Breadcrumb 和设备信息也可能包含敏感数据,需要:

  • 限制 Breadcrumb 内容与数量。
  • 用户标识匿名化。
  • 控制数据保留周期。
  • 对访问监控平台进行权限和审计。
  • 根据地区和用户选择执行隐私合规。

七、截图、录屏、剪贴板与通知

7.1 截图与录屏

支付密码、恢复码、身份信息等页面可考虑启用平台防截屏能力。需要注意:

  • Android 和 iOS 能力与限制不同。
  • 防截屏可能影响投屏、客服和无障碍使用。
  • 外部摄像头仍可拍摄屏幕。
  • 应按页面或状态最小化启用范围。

防截屏是降低意外泄露的措施,不是完整的数据保护方案。

7.2 应用切换器快照

应用进入后台时,系统可能生成任务切换预览。敏感页面应在适当生命周期覆盖模糊层或安全占位内容,并在返回前台后恢复。

实现时需要避免:

  • 生命周期切换时闪现敏感内容。
  • 遮罩阻塞恢复后的交互。
  • 在不同平台错误假设相同回调顺序。

7.3 剪贴板

  • 不自动复制 Token 和密码。
  • 敏感内容复制前提示风险。
  • 支持时设置过期或敏感标记。
  • 不在应用启动时主动读取剪贴板。
  • 不将剪贴板内容上传分析平台。

7.4 通知

  • 锁屏通知不展示完整敏感信息。
  • 允许用户选择预览等级。
  • Payload 不携带长期凭证。
  • 点击通知后重新检查登录与权限。
  • 清理不再有效的账号通知。

八、网络与传输安全

Flutter 应用应默认使用 HTTPS,并依赖系统信任链验证服务端证书。

基础要求

  • 禁止生产环境明文 HTTP。
  • 不实现“信任所有证书”的调试逻辑。
  • 调试代理能力不得进入 Release。
  • 正确处理重定向后的 Host。
  • Token 不出现在 URL Query。
  • 请求超时、重试和错误日志不泄露敏感内容。

证书绑定

Certificate/Public Key Pinning 可以提高中间人攻击成本,但会引入:

  • 证书和公钥轮换风险。
  • CDN、代理和灾难恢复复杂度。
  • Pin 过期导致全部客户端断网。
  • 应用商店旧版本长期存在的问题。

如果采用 Pinning,应至少准备:

  • 多个有效 Pin。
  • 明确轮换窗口。
  • 远端止损或兼容策略。
  • 真实过期与灾难恢复演练。

Pinning 不能替代服务端认证、Token 保护和业务授权。


九、Root、越狱与设备完整性

Root/越狱检测可作为风险信号,但不能作为绝对安全边界。

攻击者可能:

  • Hook 检测函数。
  • 隐藏 Root 痕迹。
  • 修改检测结果。
  • 在虚拟环境中模拟正常设备。

正确使用方式

将设备完整性作为多个风险信号之一:

flowchart LR
    A[设备完整性] --> E[风险评分]
    B[账号行为] --> E
    C[网络与地理异常] --> E
    D[应用签名/版本] --> E
    E --> F{风险等级}
    F -->|低| G[正常流程]
    F -->|中| H[二次认证]
    F -->|高| I[限制敏感操作]

可使用平台提供的应用和设备完整性证明能力,但需要服务端验证证明结果,并处理:

  • 不支持的设备。
  • 无 Google/Apple 服务环境。
  • 网络失败。
  • 误报和辅助设备。
  • 重放与 Nonce。

不应因为单一 Root 检测失败就让所有用户无法使用基础功能,除非业务风险和合规明确要求。


十、混淆、反调试与完整性

10.1 代码混淆

混淆可以:

  • 增加静态阅读成本。
  • 降低符号和业务命名暴露。
  • 配合符号文件还原崩溃堆栈。

混淆不能:

  • 保护硬编码 Secret。
  • 阻止动态 Hook。
  • 替代服务端权限校验。
  • 保证算法不可理解。

发布时必须安全保存符号文件,并按版本和构建号管理,否则线上崩溃无法正确符号化。

10.2 应用完整性

可以检查:

  • 包签名是否符合预期。
  • 安装来源与构建渠道。
  • 应用版本是否受支持。
  • 关键资源是否被篡改。
  • 平台完整性证明结果。

纯客户端自检可以被修改,因此高风险决策应由服务端结合证明、账号和行为信号完成。

10.3 反调试和反 Hook

反调试可以提高攻击成本,但可能引发:

  • 设备兼容问题。
  • 开发与客服排障困难。
  • 无障碍或企业环境误报。
  • 攻击者针对性绕过。

应根据资产价值决定强度,并保留安全降级、灰度和远端开关。


十一、权限与平台能力

权限遵循最小权限和及时请求:

  • 用户触发相关功能时再申请。
  • 只申请当前功能需要的最低粒度权限。
  • 解释用途,并与隐私声明一致。
  • 拒绝后提供安全降级路径。
  • 不通过连续弹窗强迫用户授权。
  • 权限不再需要时停止采集相关数据。

获得系统权限不等于获得业务授权。例如,相册权限允许选择图片,但不意味着应用可以自动上传整个相册。

Platform Channel 和插件还应检查:

  • 方法允许列表。
  • 参数类型、长度与范围。
  • Engine 和页面生命周期。
  • 错误码和敏感日志。
  • 多 Engine 与多实例隔离。

Deep Link、WebView 与 JavaScript Bridge 的详细安全边界可参考同工作区的《Flutter 输入与链接安全》。


十二、第三方 SDK 与供应链

第三方依赖与应用拥有近似的进程权限,可能读取设备信息、网络请求和用户数据。

12.1 依赖引入评估

  • 维护状态和发布频率。
  • 开源代码与许可证。
  • 已知漏洞。
  • 收集的数据和网络域名。
  • Android/iOS 权限变化。
  • 传递依赖与 Native Binary。
  • 最低平台和 Flutter 版本。

12.2 版本与制品治理

  • 应用提交 pubspec.lock,保证构建可重复。
  • 升级依赖时审查 Changelog 和权限变化。
  • 限制 dependency_overrides 长期存在。
  • CI 执行依赖漏洞和许可证检查。
  • 构建环境固定并隔离发布凭证。
  • 发布制品生成哈希并保留来源记录。

12.3 第三方 SDK 数据治理

  • 默认关闭不必要的自动采集。
  • 在用户授权后再初始化相关能力。
  • 使用字段 Allowlist。
  • 明确数据地区、保留时间和删除机制。
  • SDK 失败不能阻断核心业务。

不能因为 SDK 是知名厂商提供,就跳过数据流和权限审查。


十三、发布与密钥安全

发布签名和平台证书属于高价值资产。

CI/CD 要求

  • 发布密钥不进入代码仓库。
  • 使用专用密钥管理系统或受控 CI Secret。
  • 最小化能够执行生产签名的人员和任务。
  • 保护 CI Runner 和构建日志。
  • 审计密钥读取和发布操作。
  • 区分开发、测试与生产凭证。
  • 定期轮换可轮换凭证。

构建产物

  • 确认包名、签名、版本和渠道。
  • 禁止 Debug 开关、代理证书和测试接口进入 Release。
  • 上传符号文件,但限制访问。
  • 检查应用权限和隐私清单。
  • 对灰度和正式制品建立可追溯关系。

供应链攻击可能发生在依赖、构建机、CI 脚本、签名和上传各环节,不能只关注应用源码。


十四、安全监控与响应

客户端安全事件需要与服务端风控和监控结合。

可观测信号

  • Token 刷新异常和撤销失败。
  • 同一账号短时间跨设备、跨地区异常活动。
  • 应用签名、版本或完整性异常。
  • Root/越狱等设备风险信号。
  • 敏感接口高频调用。
  • 证书校验失败异常增长。
  • 本地解密、数据库或安全存储失败。

事件响应

flowchart LR
    A[异常信号] --> B[风险聚合]
    B --> C[确认影响范围]
    C --> D[撤销 Token / 限制能力]
    D --> E[关闭 Flag / 灰度止损]
    E --> F[修复与发布]
    F --> G[复盘与规则更新]

客户端版本无法瞬时覆盖所有用户,因此应提前建设:

  • 服务端 Token 撤销。
  • Feature Flag。
  • 最低支持版本策略。
  • 高风险能力远端关闭。
  • API 向后兼容。
  • 用户通知与账号恢复流程。

十五、常见误区

误区一:使用 dart-define 就能保护 Secret

构建变量最终会进入制品。它适合配置,不适合保存服务端机密。

误区二:数据放进安全存储就绝对安全

安全存储降低静态读取风险,但运行时明文仍可能被 Hook。还需要短期凭证、撤销和风险检测。

误区三:检测到 Root 就能阻止所有攻击

Root 检测可以被绕过,应作为风险信号,而不是唯一授权条件。

误区四:代码混淆可以保护业务安全

混淆只提高逆向成本。金额、权限、优惠和资源归属必须由服务端校验。

误区五:加密整个数据库就不会泄露数据

密钥管理、内存明文、日志、备份和导出都可能泄露。加密只是纵深防御的一层。

误区六:客户端做了证书绑定就不需要其他安全措施

Pinning 只针对部分传输攻击,还存在轮换风险,不能替代认证、授权、数据最小化和风控。


十六、落地清单

凭证与数据

  • 服务端 Secret 不进入客户端制品。
  • Access Token 短期化,Refresh Token 可轮换和撤销。
  • Token 使用 Keychain/Keystore 封装存储。
  • 敏感数据库加密具备独立密钥管理。
  • 登出、账号切换和删除账号时清理本地数据。
  • 日志、崩溃和分析数据采用字段 Allowlist。

设备与应用

  • Root/越狱与完整性只作为风险信号。
  • 高风险操作支持二次认证。
  • Release 构建启用合适的混淆并保存符号文件。
  • Debug、代理和测试能力不进入生产环境。
  • 敏感页面评估截图、任务快照和通知泄露。

工程与治理

  • 权限按最小范围和用户触发时申请。
  • 第三方 SDK 完成权限、数据和依赖审查。
  • 发布密钥由受控 CI Secret 管理。
  • 服务端具备 Token 撤销、限流和风控。
  • 安全能力支持灰度、远端止损和事件响应。

十七、总结

Flutter 客户端安全可以归纳为六条原则:

  1. 客户端不可信:固定 Secret 和本地校验最终都可能被绕过。
  2. 数据最小化:不采集、不存储、不输出不必要的敏感数据。
  3. 最小权限:Token、系统权限、插件和 SDK 只获得必要能力。
  4. 纵深防御:安全存储、加密、完整性、混淆和监控共同工作。
  5. 服务端权威:认证、授权、金额和资源归属由服务端决定。
  6. 可响应性:具备撤销、灰度、降级、回滚和复盘机制。

最终目标不是证明客户端无法被攻破,而是:

即使某一层被绕过,攻击者仍无法直接获得长期机密或高权限能力,系统也能及时发现、限制影响并完成恢复。


十八、问答复盘

Q1:为什么客户端不能保存真正的 Secret?

答: 客户端制品和运行环境由用户控制,固定值可通过静态分析、动态 Hook 或观察请求提取。高权限 Secret 必须保留在可信服务端。

Q2:dart-define 适合存储什么?

答: 适合环境名称、API 地址和功能配置,不适合私钥、服务端 Secret 或永久高权限凭证。

Q3:安全存储能否保证 Token 永不泄露?

答: 不能。它主要降低静态文件读取风险,Token 使用时仍会以明文出现在应用内存。还需短期有效、轮换、撤销和风险监控。

Q4:Root 或越狱检测失败后是否应该立即禁止应用?

答: 取决于业务风险。检测可被绕过且可能误报,通常应作为风险评分的一部分,对高风险操作增加认证或限制能力。

Q5:数据库加密最关键的问题是什么?

答: 密钥管理。把固定密钥硬编码或与密文存放在一起,会显著削弱加密价值。

Q6:代码混淆能够保护哪些内容?

答: 它能提高静态逆向成本并减少符号暴露,但不能保护硬编码 Secret,也不能替代服务端认证和授权。

Q7:为什么客户端获得系统权限后仍需要业务授权?

答: 系统权限只允许访问设备能力,不代表用户授权某个业务用途,更不代表服务端允许访问相关账号资源。

Q8:客户端安全事件发生后最有效的止损能力是什么?

答: 服务端 Token 撤销、能力限流、Feature Flag 和风险控制通常比等待应用商店发布新版本更快,因此必须提前建设。