Flutter 基础体系 · 第 19/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter 应用安全:Secret、网络、存储、WebView、证书和供应链
1. 先建立安全边界:Flutter 代码不是可信执行环境
移动应用安全的第一个边界是:发布到用户设备上的应用,最终都处于用户控制之下。用户可以调试进程、修改文件、代理网络、提取 APK/IPA、反编译 Dart AOT 产物,甚至运行修改过的系统。
因此,客户端可以执行:
- 展示界面;
- 保存用户自己的缓存;
- 发起经过服务器授权的请求;
- 持有短期、可撤销的会话凭据;
- 进行输入校验和体验优化。
客户端不能被当作:
- 私钥保管箱;
- 服务端授权逻辑;
- 永久可信的计费结果来源;
- 只有客户端知道的 Secret 存储位置;
- 能阻止设备所有者调试的安全边界。
可以把一次请求的安全条件写成:
客户端的 if (isAdmin)、隐藏按钮或本地保存的 role=admin 都不能替代服务端的后面三项。
典型数据流如下:
flowchart LR
UI[Flutter UI] --> C[客户端状态与校验]
C --> N[HTTPS 请求]
N --> T[平台 TLS 栈]
T --> S[服务端认证与授权]
S --> DB[(服务端数据库)]
C --> P[Preferences/文件/SQLite]
C --> K[平台 Keychain/Keystore]
C --> W[WebView]
这里有三个不同的信任区域:
- 应用进程:能读取 Dart 运行时可访问的数据。
- 操作系统提供的安全存储:通常比普通文件更难被其他应用读取,但在越狱、Root、调试或备份场景下仍不能假设绝对安全。
- 服务端:真正决定资源是否允许访问。
后文的 Secret、网络、存储、WebView、证书和供应链,分别解决不同层次的问题,不能互相替代。
2. Secret:客户端中的“秘密”通常不是秘密
2.1 Secret 的定义和分类
Secret 是一旦泄露就能让攻击者获得未授权能力,且不能仅凭公开信息重新推导的数据。例如:
- 服务端 API 私钥;
- 云厂商管理密钥;
- 数据库密码;
- JWT 签名私钥;
- 第三方支付或地图服务的服务端密钥;
- 用来解密所有用户数据的固定主密钥。
与之不同的是,某些值虽然称为“key”,但本质是公开配置:
- OAuth 的客户端 ID;
- Android 包名与 iOS Bundle ID;
- 某些只允许按包名、域名或签名限制的公开 SDK 标识;
- API 的公开地址。
公开配置仍需要限制来源和权限,但不能把它误认为 Secret。
2.2 为什么硬编码、混淆和环境变量都不能保护客户端 Secret
下面的代码不会保护密钥:
const apiKey = 'sk_live_very_sensitive_value';
把它改成:
const apiKey = String.fromEnvironment('API_KEY');
也只改变了密钥进入构建产物的时间。如果它参与了发布构建,攻击者仍可以通过以下路径得到它:
- 从 APK、IPA 或桌面安装包提取资源和字符串;
- 反编译或反汇编调用点;
- 运行时调试,在请求发送前读取内存;
- 观察应用发出的请求头或请求参数;
- 从构建日志、CI 缓存或错误报告中获取。
Dart 的 --dart-define 适合注入环境配置,例如:
flutter build apk \
--release \
--dart-define=API_BASE_URL=https://api.example.com
对应代码:
const apiBaseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'https://api.example.com',
);
这里 API_BASE_URL 不是安全存储。它只是构建时常量;如果值需要保密,就不能放进客户端。
代码混淆也只能提高逆向成本。混淆的目标是让符号名更难读,不是让运行时无法获得数据。更不能用 Base64、字符串拆分、异或或自定义编码来代替加密:
// 这不是加密;原文可以直接恢复。
final value = base64Decode('c2VjcmV0').toString();
2.3 正确的数据流:把真正的 Secret 留在服务端
假设应用需要调用一个第三方支付服务。错误架构是:
Flutter 应用 -> 第三方支付服务
需要服务端私钥
正确架构是:
Flutter 应用 --用户凭据--> 自有服务端
自有服务端 --服务端私钥--> 第三方支付服务
具体过程:
- Flutter 使用用户登录态请求自有服务端;
- 服务端验证访问令牌、用户身份、订单状态和金额;
- 服务端使用只存在于服务端的第三方私钥;
- 服务端把最小必要结果返回给 Flutter;
- Flutter 不接触第三方私钥。
如果第三方只能提供客户端 SDK,则应使用其明确设计为公开的客户端凭据,并在服务端和第三方控制台限制:
- 包名、Bundle ID;
- 签名证书;
- iOS Associated Domains 或 Android 应用限制;
- API 来源域名;
- 可调用的 API 范围;
- 配额和告警。
2.4 访问令牌也属于 Secret
“不是永久密钥,所以可以写入普通文件”是常见误解。访问令牌在有效期内同样具有权限,属于需要保护的凭据。
合理的令牌生命周期通常是:
stateDiagram-v2
[*] --> 未登录
未登录 --> 已登录: 登录成功
已登录 --> 令牌有效
令牌有效 --> 令牌有效: 普通请求
令牌有效 --> 刷新中: 收到 401 且未重试
刷新中 --> 令牌有效: 刷新成功
刷新中 --> 未登录: 刷新失败/撤销
令牌有效 --> 未登录: 主动退出或服务端撤销
刷新逻辑必须防止并发请求同时刷新:
class TokenManager {
TokenManager(this._refreshToken);
final Future<String?> Function() _refreshToken;
Future<String?>? _refreshing;
Future<String?> refreshOnce() {
final current = _refreshing;
if (current != null) return current;
final future = _refreshToken();
_refreshing = future;
return future.whenComplete(() {
if (identical(_refreshing, future)) {
_refreshing = null;
}
});
}
}
这个模式的因果关系是:
- 第一个收到
401的请求创建刷新 Future; - 后续并发请求复用同一个 Future;
- 刷新成功后所有等待者得到同一个新令牌;
- 刷新失败时统一进入重新登录或清除凭据流程;
identical检查避免旧 Future 完成时错误清除更新后的刷新任务。
实际项目中还要处理:
- 令牌刷新接口本身不能无限重试;
- 旧刷新令牌被轮换后,服务端可能只接受最新令牌;
- 退出登录必须取消或使待发送请求失效;
- 日志不能打印
Authorization、Cookie、刷新令牌和完整请求体。
3. 网络安全:HTTPS 保护的是传输链路,不是业务授权
3.1 HTTPS、TLS 和证书链
HTTPS 是 HTTP 运行在 TLS 之上。TLS 建立连接时大致经历:
- 客户端发送支持的协议版本、密码套件和随机数;
- 服务端返回证书链、协商结果和密钥交换参数;
- 客户端验证服务端证书;
- 双方通过密钥交换得到会话密钥;
- 后续 HTTP 数据使用会话密钥加密和完整性保护。
证书通常包含:
- 主体名称;
- 有效期;
- 公钥;
- Subject Alternative Name(SAN)中的域名;
- 签发者;
- CA 签名;
- 用途约束。
客户端不是简单地判断“证书文件存在”,而是验证:
这套验证通常由 Android、iOS、桌面平台的 TLS 实现完成。Flutter dart:io 的 HttpClient 会使用平台或运行时的信任配置,但具体根证书、代理行为和系统策略由平台决定。
3.2 Flutter 中的基本 HTTPS 请求
import 'dart:convert';
import 'dart:io';
Future<Map<String, dynamic>> fetchProfile({
required String accessToken,
}) async {
final client = HttpClient();
try {
final request = await client.getUrl(
Uri.parse('https://api.example.com/v1/profile'),
);
request.headers
..set(HttpHeaders.acceptHeader, 'application/json')
..set(HttpHeaders.authorizationHeader, 'Bearer $accessToken');
final response = await request.close();
final body = await response.transform(utf8.decoder).join();
if (response.statusCode < 200 || response.statusCode >= 300) {
throw HttpException(
'profile request failed: ${response.statusCode}',
uri: request.uri,
);
}
final decoded = jsonDecode(body);
if (decoded is! Map<String, dynamic>) {
throw const FormatException('unexpected response shape');
}
return decoded;
} on SocketException {
rethrow; // 网络不可达、DNS 失败等
} on HandshakeException {
rethrow; // TLS 握手或证书验证失败
} finally {
client.close(force: true);
}
}
需要注意:
HttpClient必须关闭,否则连接资源可能泄漏;- HTTP 状态码不是异常,必须显式检查;
jsonDecode成功不代表数据结构正确;- 不应把整个响应体写入生产日志;
HandshakeException和业务401是不同故障,恢复策略也不同。
生产应用通常会使用 HTTP 客户端库统一实现超时、取消、重试、拦截器和错误映射,但这些库不会自动解决服务端授权问题。
3.3 明文 HTTP 的平台差异
生产环境应优先使用 HTTPS。明文 HTTP 存在窃听、篡改和降级风险。
平台差异包括:
- Android:应用网络安全策略可以禁止明文流量;目标 SDK、系统版本和
networkSecurityConfig会影响行为。 - iOS:App Transport Security(ATS)通常要求安全连接;对特定域名的例外需要在
Info.plist中明确配置。 - 桌面:Flutter 桌面应用依赖操作系统的网络和证书环境,企业代理、自定义根证书和系统更新会影响结果。
- Web:浏览器执行混合内容、CORS、证书和安全上下文策略;Flutter Web 不能绕过浏览器的 TLS 或 CORS 规则。
开发环境若必须访问本机服务,应区分环境,而不是把全局验证关闭。例如使用本地测试域名、开发证书和仅开发构建启用的网络策略。把生产应用配置为允许任意 HTTP 是发布风险,不是调试技巧。
3.4 不要接受任意证书
以下代码会让 TLS 认证失去意义:
final client = HttpClient()
..badCertificateCallback =
(X509Certificate cert, String host, int port) => true;
badCertificateCallback 的语义是:平台默认证书验证失败时,是否接受该证书。返回 true 等于告诉客户端“即使证书不可信也继续”,攻击者可以用自签名证书进行中间人攻击。
如果必须在开发环境接受本地自签名证书,应满足:
- 只在 debug 或专用开发 flavor 编译;
- 只匹配明确的主机和端口;
- 不允许生产构建包含该逻辑;
- 在 CI 中验证 release 构建不会启用;
- 不把用户输入的主机名直接作为放行条件。
3.5 证书固定(Certificate Pinning)的边界
证书固定是除了系统 CA 验证外,再要求服务端证书或公钥指纹等于应用内预置值。它可以减少“设备信任库被额外安装 CA”时的风险,但会引入证书轮换和故障恢复问题。
固定证书指纹的判断可以表示为:
其中:
- 是哈希函数,常见为 SHA-256;
- 是应用内预置的允许指纹集合。
必须预置至少一个备用指纹,轮换时先让服务端同时使用新旧证书,再发布包含新指纹的客户端,最后移除旧证书。否则证书一更换,旧版本应用就会全部无法联网。
还要区分:
- 证书固定:固定某张叶证书,轮换频繁,维护成本高;
- 公钥固定:固定公钥或 SPKI,证书续期但密钥不变时仍可工作;
- CA 固定:范围更宽,安全收益和误配置风险取决于实现。
Dart HttpClient 的默认回调并不是通用的证书固定 API;在 Android 和 iOS 上,可靠的固定通常需要平台网络栈、对应 HTTP 库或经过审查的插件支持。不能把 badCertificateCallback 当作 pinning。移动应用还要考虑:
- 企业代理和调试代理;
- 证书轮换;
- 紧急撤销;
- 无网络时的错误提示;
- 备用域名和灾备服务是否也在固定范围内。
除非威胁模型明确要求,很多应用更适合依靠正确的系统 TLS、服务端证书管理、短期令牌和服务端风控,而不是贸然加入固定逻辑。
4. 存储安全:机密性、完整性和可恢复性是三个问题
本地存储不只有“加不加密”一个维度:
- 机密性:攻击者能否读到内容;
- 完整性:攻击者能否修改内容而不被发现;
- 可用性:应用能否在升级、迁移、卸载、备份恢复后继续工作;
- 生命周期:退出登录、令牌过期和用户删除数据时是否清理。
4.1 Preferences 适合配置,不适合 Secret
Preferences 类存储适合:
- 首次启动标记;
- 主题模式;
- 非敏感的用户偏好;
- 最后选择的筛选条件。
它通常以键值形式保存,读写简单,但不提供适合保存高价值凭据的安全保证。平台实现可能使用 XML、NSUserDefaults 或其他普通持久化机制,不能把 API 名称中的“存储”理解成“加密保险箱”。
import 'package:shared_preferences/shared_preferences.dart';
Future<void> saveThemeMode(bool isDark) async {
final preferences = await SharedPreferences.getInstance();
await preferences.setBool('theme.dark', isDark);
}
Future<bool> loadThemeMode() async {
final preferences = await SharedPreferences.getInstance();
return preferences.getBool('theme.dark') ?? false;
}
这里保存的是偏好,不是授权凭据。即使攻击者修改 theme.dark,最多改变显示效果;如果同样方式保存 isPremium=true 并让服务端相信它,就构成业务漏洞。
4.2 文件存储:路径和权限不等于加密
文件适合图片、导出文件、离线缓存和日志,但应明确:
- 应用沙箱限制的是其他普通应用访问,不是设备所有者;
- Root、越狱、调试、备份或恶意插件可能改变威胁模型;
- 文件删除不一定立即从闪存介质物理擦除;
- 临时文件、崩溃转储和日志可能复制敏感数据;
- 文件名、目录名和 SQLite WAL 文件也可能泄露信息。
文件写入至少应采用临时文件加替换,避免进程崩溃留下半个 JSON:
import 'dart:convert';
import 'dart:io';
Future<void> writeJsonAtomically(File target, Object value) async {
final temporary = File('${target.path}.tmp');
final content = jsonEncode(value);
await temporary.writeAsString(
content,
flush: true,
);
await temporary.rename(target.path);
}
这段代码降低了部分写入中断风险,但不提供加密,也不保证跨文件系统重命名的原子语义。敏感内容还需要:
- 生成随机加密密钥;
- 把密钥存入 Android Keystore 或 iOS Keychain 的平台安全存储;
- 使用带认证的加密模式,例如 AES-GCM 或 ChaCha20-Poly1305;
- 为每次加密使用唯一随机 nonce;
- 验证认证标签失败并拒绝解密结果;
- 处理密钥丢失导致的数据不可恢复。
不要固定写死 nonce,也不要把“加密密钥”和密文放在同一个普通文件中。加密只提供机密性和篡改检测,不能阻止正在运行的应用自己读取明文;应用进程被完全控制时,攻击者仍可在解密后截获数据。
4.3 SQLite:数据库结构安全不等于数据库内容加密
SQLite 适合需要查询、事务和关系结构的本地数据,例如:
- 离线队列;
- 大量缓存;
- 搜索索引;
- 多表关系;
- 可迁移的业务草稿。
SQLite 的核心能力包括事务和查询,但标准 SQLite 本身不自动提供透明加密。常见方案有:
- 使用 SQLCipher 等加密 SQLite 变体;
- 在应用层加密敏感字段;
- 仅把非敏感缓存放入 SQLite;
- 通过插件调用平台数据库或加密库。
应用层字段加密的缺点是无法直接对密文做普通范围查询、排序或全文搜索。数据库级加密维护成本更高,但可以覆盖页、索引和临时数据;具体能力取决于使用的实现和插件,不能仅根据“SQLite”三个字判断。
4.4 迁移是安全问题,不只是版本管理
假设旧版本表结构为:
CREATE TABLE notes (
id INTEGER PRIMARY KEY,
content TEXT NOT NULL
);
新版本需要增加 is_archived。安全的迁移需要:
- 在事务中检查当前 schema version;
- 执行对应版本的迁移;
- 更新 schema version;
- 迁移失败时回滚;
- 对旧数据进行约束和默认值验证。
示例:
BEGIN;
ALTER TABLE notes
ADD COLUMN is_archived INTEGER NOT NULL DEFAULT 0;
PRAGMA user_version = 2;
COMMIT;
如果在 ALTER TABLE 成功后、更新版本号前进程崩溃,下一次启动可能重复执行迁移。因此迁移框架必须让“当前版本判断”和“执行步骤”具备明确的恢复语义。不同 SQLite 封装对 DDL 事务、user_version 和异常回滚的支持细节可能不同,应以实际插件文档和测试结果为准。
必须测试这些路径:
- 从多个旧版本直接升级到当前版本;
- 迁移中断后再次启动;
- 数据库文件损坏;
- 存储空间不足;
- 加密密钥丢失;
- 降级安装;
- 备份恢复得到旧 schema;
- 多 isolate 或多个启动流程同时打开数据库。
4.5 退出登录时的清理边界
退出登录通常至少需要清除:
- 访问令牌;
- 刷新令牌;
- 用户身份缓存;
- 需要用户隔离的离线数据库或缓存;
- WebView Cookie、localStorage 和会话;
- 上传队列中与用户绑定的数据。
但不能简单删除所有本地数据,因为应用配置、崩溃诊断和匿名缓存可能不属于用户身份。正确做法是给每类数据标注归属和生命周期,而不是按文件名猜测。
5. WebView:把远程网页嵌入到应用进程中
5.1 WebView 的安全模型
WebView 是平台提供的网页渲染和 JavaScript 执行组件。它同时涉及:
- URL 导航;
- JavaScript;
- Cookie 和 Web Storage;
- 文件选择;
- 相机、麦克风、定位等权限;
- JavaScript 与 Dart/Native 的桥接;
- 页面内的 TLS 和浏览器安全策略。
WebView 不是普通文本控件。加载不可信 URL,相当于把一部分网页应用和脚本放进你的应用上下文。
一个基本的 webview_flutter 示例:
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
class TrustedPage extends StatefulWidget {
const TrustedPage({super.key});
@override
State<TrustedPage> createState() => _TrustedPageState();
}
class _TrustedPageState extends State<TrustedPage> {
late final WebViewController controller;
static final trustedHost = Uri.parse('https://app.example.com');
@override
void initState() {
super.initState();
controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.disabled)
..setNavigationDelegate(
NavigationDelegate(
onNavigationRequest: (request) {
final uri = Uri.tryParse(request.url);
final allowed = uri != null &&
uri.scheme == trustedHost.scheme &&
uri.host == trustedHost.host;
return allowed
? NavigationDecision.navigate
: NavigationDecision.prevent;
},
),
)
..loadRequest(trustedHost);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('帮助中心')),
body: WebViewWidget(controller: controller),
);
}
}
这段代码的前提是项目已经添加与当前版本兼容的 webview_flutter 依赖,并按 Android/iOS 平台要求完成配置。该插件不是 Flutter SDK 内置 API,具体构造器和平台实现应以项目锁定的插件版本为准。
关键点是:
- 默认关闭 JavaScript,只有业务确实需要时才打开;
- 导航时解析完整 URI,而不是简单使用
startsWith; - 同时检查 scheme 和 host;
- 不允许
javascript:,file:,data:等不需要的 scheme; - 不把任意页面跳转到“可信页面”;
- 外部链接应交给系统浏览器,并明确用户意图。
5.2 导航校验的常见错误
下面的判断不安全:
request.url.startsWith('https://app.example.com')
它可能错误放行:
https://app.example.com.attacker.example/
https://app.example.com@attacker.example/
更稳妥的判断应使用 URI 解析,并根据业务决定是否允许:
- 精确 host;
- 特定子域名;
- 精确端口;
- 特定 path;
- 是否允许重定向;
- 是否允许自定义 scheme。
只检查最终 URL 也不够,因为攻击者可能先利用页面中的链接、表单或重定向导航到外部站点。导航策略应在每一次请求和重定向节点执行。
5.3 JavaScript Bridge 是高风险边界
如果页面可以调用 Dart 方法,桥接接口必须当作远程输入处理。危险设计包括:
网页 JavaScript -> invoke("getAccessToken") -> Flutter 返回令牌
网页 JavaScript -> invoke("openFile", arbitraryPath)
网页 JavaScript -> invoke("pay", arbitraryAmount)
桥接的安全条件应是:
即使页面来自自己的域名,也要考虑 XSS、被入侵的 CDN、第三方脚本和页面重定向。桥接接口应该:
- 只暴露最小功能;
- 使用明确的消息 schema;
- 严格限制参数;
- 不返回令牌、Cookie、文件路径等高价值数据;
- 不允许通过消息构造任意原生调用;
- 对来源和当前导航状态进行校验;
- 在退出登录时清理 WebView 会话。
5.4 WebView Cookie 与 Flutter 请求不是天然共享
WebView 使用平台 WebView 的 Cookie、缓存和存储;dart:io 或其他 HTTP 客户端使用自己的 Cookie 管理。二者通常不会自动同步。
因此,以下假设不可靠:
用户在 WebView 中登录后,Flutter 的 HTTP 请求自然就已登录。
如果业务需要统一登录,应明确选择:
- 使用系统浏览器和 OAuth/OIDC 回调;
- 使用平台认证会话;
- 通过一次性授权码交换 Flutter 自己的令牌;
- 明确、安全地同步有限状态,而不是复制长期 Cookie。
把长期访问令牌拼接进 WebView URL 也不安全,因为 URL 可能进入历史记录、代理日志、截图、Referer 或崩溃信息。
5.5 平台差异
- Android/iOS:通常有系统 WebView,JavaScript、混合内容、文件访问和权限行为受平台版本影响。
- 桌面:是否支持 WebView 取决于插件和平台实现,不能假设移动端 API 与行为完全一致。
- Web:Flutter Web 运行在浏览器中,没有同样意义上的原生 WebView;嵌入网页常通过 iframe,而 iframe 受 CSP、X-Frame-Options、CORS 和浏览器同源策略限制。
6. 证书:TLS 证书、应用签名证书和身份绑定不是一回事
“证书”至少有三种常被混淆的含义。
6.1 TLS 服务器证书
它证明某个域名的服务器拥有对应私钥,并由客户端信任的 CA 签发。它保护的是:
客户端 <-> 服务器
TLS 证书私钥必须只存在于服务器或安全的密钥管理系统中,绝不能打包进 Flutter 应用。
证书失效、域名不匹配、链不完整或系统时间错误时,常见表现是:
HandshakeException;- Android 的
SSLHandshakeException; - iOS 的 ATS 或 URL Loading System 错误;
- Web 浏览器显示证书或混合内容错误。
诊断时应先区分:
- DNS 是否解析到正确地址;
- 系统时间是否正确;
- 服务器是否发送完整中间证书链;
- SAN 是否包含访问域名;
- Android/iOS/桌面系统是否信任签发 CA;
- 是否被企业代理替换证书;
- 是否误启用了证书固定。
6.2 Android 应用签名证书
Android APK/AAB 使用签名密钥签名。签名用于:
- 证明更新包来自同一发布者;
- 让 Android 判断更新包是否可以覆盖安装;
- 支持某些 API 或服务将应用包名与签名指纹绑定。
发布签名私钥不能放入仓库,也不能放进 Flutter 资产。CI 应通过受保护的密钥库、签名服务或受控凭据注入完成签名。签名密钥丢失可能导致无法以原应用身份发布更新;泄露则可能导致攻击者签署伪造包,具体影响取决于分发渠道和平台保护机制。
6.3 iOS 证书与 provisioning profile
iOS 发布涉及开发者证书、分发证书、Provisioning Profile、Bundle ID 和 entitlements。它们共同决定:
- 哪个团队可以签署应用;
- 哪个 Bundle ID 可以运行;
- 哪些能力可用;
- 应用能否安装或上架。
APNs、Associated Domains、Keychain Access Groups 等能力也和 entitlements、签名配置有关。更换团队、Bundle ID 或 profile 时,常见故障不是 Flutter Dart 代码错误,而是原生签名和能力配置不一致。
6.4 证书轮换与应用发布顺序
TLS 证书轮换和应用签名密钥轮换的失败模式不同:
- TLS 证书轮换错误,可能导致所有客户端无法建立连接;
- 应用签名密钥错误,可能导致更新包无法覆盖旧版本;
- pinning 指纹遗漏,可能导致只有旧版或新版客户端无法联网;
- profile/entitlement 错误,可能导致安装成功但某能力运行失败。
因此应在发布前验证:
构建产物身份 -> 签名验证
应用配置 -> Bundle ID/包名验证
网络域名 -> TLS 链和有效期验证
服务端轮换 -> 旧版和新版客户端兼容验证
7. 供应链:依赖、构建工具和发布凭据都是攻击面
7.1 Flutter 供应链包含哪些组件
Flutter 应用的供应链不只有 Dart 包,还包括:
- Flutter SDK 和 Dart SDK;
- Android Gradle Plugin、Gradle、Android SDK;
- Kotlin/Java 依赖;
- CocoaPods、Xcode 和 iOS 原生依赖;
- Dart/Flutter packages;
- Git 依赖和本地 path 依赖;
- 构建脚本、CI action 和 Docker 镜像;
- 签名密钥、发布 token 和商店凭据;
- 远程配置、CDN 资源和 WebView 页面。
任一环节被篡改,都可能在构建产物中植入恶意逻辑。
7.2 依赖解析、锁文件和版本范围
pubspec.yaml 中的约束描述允许范围,pubspec.lock 记录一次解析后的具体版本。应用项目应提交 pubspec.lock,使开发机和 CI 使用一致的解析结果。
常用检查命令:
flutter --version
dart --version
flutter pub deps
flutter pub outdated
flutter pub get --enforce-lockfile
它们分别用于:
- 确认 Flutter/Dart 工具链版本;
- 查看传递依赖图;
- 查看可升级依赖和潜在不兼容版本;
- 要求解析结果符合锁文件约束,避免无意更新。
锁文件不是“安全证明”。它只能减少版本漂移,不能证明某个版本没有漏洞,也不能阻止已被污染的版本。还需要:
- 固定 Flutter SDK 和原生工具链;
- 审查新增直接依赖及其维护状态;
- 避免无必要的 Git 依赖和未固定 revision;
- 检查插件的 Android/iOS 原生代码;
- 在 CI 中执行静态分析、测试和依赖扫描;
- 保护构建缓存,防止不可信任务写入后被后续任务复用。
7.3 插件权限应与功能相称
一个只展示文本的插件却要求通讯录、短信、无障碍或后台定位权限,应视为供应链风险信号。审查 Flutter 插件时至少看:
pubspec.yaml的平台声明;- Android
AndroidManifest.xml; - iOS
Info.plist和 entitlements; - 原生网络请求、动态加载和 WebView;
- 是否收集设备标识、剪贴板和日志;
- 依赖是否包含二进制 SDK;
- 版本更新是否有异常权限变化。
依赖越多,攻击面越大,但不能用“完全不使用第三方包”作为绝对答案;关键是理解依赖承担的功能、权限和升级责任。
7.4 构建与发布凭据
CI 中的这些数据都应视为高价值 Secret:
- Android keystore 密码和私钥;
- Apple API key、证书和 profile;
- 商店上传 token;
- 云服务凭据;
- 代码签名服务访问令牌;
- 崩溃平台上传 token。
安全的构建流程应满足:
- 拉取受信任且固定版本的源代码;
- 使用固定工具链构建;
- 在受保护环境注入签名材料;
- 构建日志自动脱敏;
- 构建完成后销毁临时凭据;
- 产出制品、校验和、版本号和提交号;
- 在发布前验证签名、包名、权限和环境配置;
- 保留回滚所需的上一版本制品。
不要把 keystore、.p12、.mobileprovision、API token 或生产配置提交到 Git。即使后来删除,Git 历史、镜像和缓存中仍可能保留它们;已经泄露的凭据应立即撤销或轮换,而不是只删除文件。
8. 发布验证:把安全条件变成可观测检查
安全问题常在发布后才暴露,因此需要把关键属性纳入验收。
8.1 Release 构建检查
至少检查:
flutter build apk --release
flutter build appbundle --release
flutter build ios --release
不同平台的命令和签名流程可能依赖本机 Xcode、Android SDK 和证书配置。构建后验证:
- release 是否仍包含 debug URL;
- 是否启用了任意证书接受逻辑;
- 是否包含测试账号、测试 token 和服务端私钥;
- Android 是否允许不必要的明文流量;
- iOS ATS 例外是否有明确理由;
- WebView 是否允许任意导航;
- 日志是否输出敏感数据;
- 包名、Bundle ID、签名和 flavor 是否匹配环境。
不能通过“源码里搜不到字符串”证明没有 Secret,因为 Secret 可能来自构建参数、资源、原生配置或传递依赖。
8.2 故障诊断顺序
遇到“接口不可用”时,按层定位比反复重试更有效:
- 应用配置:URL、环境、代理和 flavor 是否正确;
- DNS/网络:域名是否可解析、设备是否联网;
- TLS:证书链、域名、时间和系统信任是否正常;
- HTTP:状态码、重定向、响应头和超时;
- 认证:令牌是否过期、刷新是否并发失控;
- 授权:服务端是否拒绝角色、资源或业务状态;
- 序列化:响应结构是否与客户端模型匹配;
- 本地状态:缓存、数据库迁移和 WebView 会话是否污染结果。
例如:
HandshakeException通常先查 TLS,不要直接清除用户数据;401通常查令牌和刷新流程,不等于证书错误;403通常查服务端授权,不应在客户端把用户改成管理员;- JSON 解析错误要保留状态码和安全摘要,不要把完整响应写入日志;
- 只有旧版无法联网而新版正常,优先怀疑证书固定或 TLS 兼容性;
- 只有某些企业设备失败,优先检查代理、系统 CA 和设备策略。
8.3 安全与可用性的取舍
安全控制必须有恢复路径:
- 令牌需要撤销和重新登录机制;
- 证书固定需要备用 pin 和紧急发布方案;
- 加密存储需要明确密钥丢失后的数据策略;
- 数据库迁移需要失败回滚和备份恢复测试;
- 依赖升级需要灰度和回滚制品;
- 发布签名需要受控备份和访问审计。
最终目标不是让应用“看起来安全”,而是让每一层的保护条件清晰、失败行为可诊断、凭据可撤销、版本可回滚,并且不把客户端当成服务端的替代品。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter 可访问性与国际化:Semantics、焦点、Locale 和文本适配
- 下一篇:Flutter 错误处理与可观测性:Zone、日志、Crash、性能和隐私
- 延伸:Flutter 本地存储:Preferences、文件、SQLite、加密和迁移
- 延伸:Flutter 应用发布:签名、Flavor、商店、Web/桌面、灰度和回滚
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论