Android 安全最佳实践:从数据存储到网络通信的防护思路
为什么需要关注 Android 安全
Android 应用一旦发布,就运行在用户真实设备上,面临的风险远比服务端复杂:安装包可以被反编译,本地数据可以被有 root 权限的人读取,网络请求可以被中间人抓包,WebView 可能被注入恶意脚本,导出的组件可以被其他应用直接调用。
安全不是上线前补一张检查表就能解决的事情,而是贯穿在数据存储、加密、网络通信、代码保护、组件暴露控制等每个具体决策里。这篇文章从实际项目中最容易踩坑的地方讲起,帮助开发者建立一个可落地的安全防护框架。
本地数据存储安全
SharedPreferences 的风险
SharedPreferences 以明文 XML 存在应用私有目录下,普通用户无法直接访问,但 root 用户或者被导出的备份文件中可以读到。
// 不推荐:直接存敏感信息
val prefs = context.getSharedPreferences("user", Context.MODE_PRIVATE)
prefs.edit().putString("token", userToken).apply()
如果存储内容涉及 token、用户标识、支付状态等敏感字段,建议使用 Jetpack DataStore 配合加密,或者直接用 EncryptedSharedPreferences。
EncryptedSharedPreferences
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val securePrefs = EncryptedSharedPreferences.create(
context,
"secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
加密后的数据仍然以 SharedPreferences 接口暴露,业务代码不需要感知底层加密细节。需要注意的是,加密 key 来自 Android Keystore,如果设备系统版本过低或 Keystore 损坏,读写会失败,需要做好降级处理。
Room 与本地数据库
Room 数据库文件同样存在应用私有目录,明文存储。如果数据库内容涉及用户隐私,可以使用 SQLCipher 加密。
val passphrase: ByteArray = SQLiteDatabase.getBytes(dbKey.toCharArray())
val factory = SupportOpenHelperFactory(passphrase)
val db = Room.databaseBuilder(context, AppDatabase::class.java, "secure.db")
.openHelperFactory(factory)
.build()
加密 key 的生成和管理需要格外小心,不能硬编码在代码里,也不能从服务端明文下发后直接缓存。通常做法是从 Android Keystore 派生,或在用户登录时动态生成,退出后清除。
文件存储
应用内部存储的文件默认对其他应用不可见,但如果通过 FileProvider 共享给外部应用,或者写入外部存储(SD 卡),就失去了这层保护。
敏感文件写入外部存储前,应该先加密:
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val encryptedFile = EncryptedFile.Builder(
context,
File(cacheDir, "secret.dat"),
masterKey,
EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
).build()
encryptedFile.openFileOutput().use { it.write(data) }
Android Keystore 与密钥管理
Android Keystore 系统让密钥以硬件或系统级安全环境存储,应用代码无法导出私钥,只能用它做加解密操作。
生成和使用密钥
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"
)
keyGenerator.init(
KeyGenParameterSpec.Builder("my_key_alias",
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build()
)
val secretKey = keyGenerator.generateKey()
密钥一旦生成,就绑定在当前应用上。即使有人拿到了应用的数据文件,也无法在其他应用里使用这个 key。这是 Android Keystore 最核心的价值。
密钥失效与用户验证
可以设置密钥在用户锁屏失效或需要生物验证才能使用:
KeyGenParameterSpec.Builder("auth_key",
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setUserAuthenticationRequired(true)
.setUserAuthenticationParameters(30, KeyProperties.AUTH_BIOMETRIC_STRONG)
.setInvalidatedByBiometricEnrollment(true)
.build()
这类密钥适合用于支付确认、高敏感操作前的身份复核,但不适合用来加密需要长期离线访问的数据,因为用户未验证时 key 不可用。
网络安全
强制 HTTPS 与网络安全配置
Android 9 开始默认禁止明文 HTTP 请求,但很多老项目仍然在 Manifest 里关闭了这个限制:
<!-- 不推荐:全局放开明文流量 -->
<application android:usesCleartextTraffic="true">
更安全的做法是在 res/xml/network_security_config.xml 中精确配置:
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<!-- 仅开发环境允许明文 -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">10.0.2.2</domain>
</domain-config>
</network-security-config>
<application
android:networkSecurityConfig="@xml/network_security_config">
这样既能保证正式环境强制 HTTPS,又不会让本地开发环境(模拟器用 10.0.2.2 访问宿主机)受阻。
证书锁定(Certificate Pinning)
HTTPS 防止了明文传输,但如果用户设备上安装了恶意 CA 证书(企业管控、调试工具、恶意软件),中间人攻击依然成立。
OkHttp 支持证书锁定,把服务端公钥的哈希写进客户端:
val certificatePinner = CertificatePinner.Builder()
.add("api.example.com",
"sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.build()
val client = OkHttpClient.Builder()
.certificatePinner(certificatePinner)
.build()
证书锁定能防住大多数本地抓包,但也有代价:服务端证书轮换时,如果客户端没有提前预埋备用证书哈希,应用会直接无法联网。所以生产环境建议至少预埋两个哈希(当前证书 + 备用证书),并在证书到期前通过版本升级推送新哈希。
WebView 安全
WebView 是 Android 安全漏洞的高发区,常见问题包括:
- 允许加载任意 URL,导致钓鱼页面在应用内打开
- 开启 JavaScript 接口,暴露了应用内部方法
- 允许 File 协议访问本地文件
基本防护:
webView.settings.apply {
javaScriptEnabled = true
allowFileAccess = false
allowContentAccess = false
// 禁止加载外部 file:// 协议
}
webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView, request: WebResourceRequest
): Boolean {
val url = request.url.toString()
// 只允许白名单域名
return !isAllowedDomain(url)
}
}
addJavascriptInterface 暴露给 WebView 的方法,必须用 @JavascriptInterface 注解限制,且只暴露必要的方法,避免暴露整个 Activity 或全局对象。
代码保护
ProGuard / R8 混淆
混淆不是加密,不能阻止逆向,但可以明显增加反编译代码的阅读难度,让攻击者更难定位关键逻辑。
# 保留必要的类
-keep class com.example.app.model.** { *; }
-keep class com.example.app.api.** { *; }
# 移除日志
-assumenosideeffects class android.util.Log {
public static int v(...);
public static int d(...);
public static int i(...);
}
混淆规则需要持续维护。每次接入新 SDK,都要确认其 keep 规则是否完整,否则 release 包会出现运行时崩溃。
不要在客户端存储密钥
API Key、服务端 Secret、支付私钥这些东西不应该出现在客户端代码或配置文件里。即使做了混淆,攻击者依然可以通过动态调试或内存 dump 拿到。
正确的做法是:敏感逻辑放在服务端,客户端只做身份验证和结果展示。如果某些场景必须在客户端做加解密(比如离线数据),密钥应该从 Android Keystore 动态派生,而不是硬编码。
组件暴露控制
exported 属性
Android 12 开始强制要求所有包含 intent-filter 的组件显式声明 android:exported:
<activity
android:name=".DeepLinkActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="myapp" />
</intent-filter>
</activity>
但显式声明不等于安全。exported=true 意味着任何应用都可以启动这个 Activity、发送广播、调用 Service 或查询 ContentProvider。
最小暴露原则
- 没有外部调用需求的组件,一律 exported=false
- 需要外部调用的组件,用 intent-filter 的 scheme、action 做尽量窄的匹配
- 跨应用通信优先用签名级权限(signature permission),而不是无权限或普通权限
<permission
android:name="com.example.app.MY_PERMISSION"
android:protectionLevel="signature" />
<service
android:name=".InternalService"
android:permission="com.example.app.MY_PERMISSION"
android:exported="true" />
这样只有用同一签名密钥打包的应用才能调用这个 Service。
Deep Link 安全
Deep Link 允许外部通过 URL 直接打开应用内页面,但也意味着攻击者可以构造恶意链接,把用户引导到意料之外的页面,或传入恶意参数。
处理 Deep Link 时,要对所有参数做校验,不要直接拼进 SQL 查询或当作 Intent extra 透传给其他组件:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val uri = intent.data ?: return
val targetPage = uri.getQueryParameter("page") ?: return
// 白名单校验,不接受任意页面名
if (targetPage !in ALLOWED_PAGES) {
finish()
return
}
navigateTo(targetPage)
}
日志与调试信息
很多安全问题的来源不是代码逻辑,而是日志。生产环境的 Log 输出可能包含 token、用户 ID、接口参数、错误堆栈里的内存地址。
建议在 release 构建时通过混淆规则移除日志,或者用封装的日志工具,在 release 变体下自动关闭输出:
object AppLog {
private const val ENABLED = BuildConfig.DEBUG
fun d(tag: String, msg: String) {
if (ENABLED) Log.d(tag, msg)
}
}
不要相信"发布前搜一遍 Log"这种做法,人工检查很容易遗漏,而且第三方 SDK 的日志不受你控制。
常见安全漏洞复盘
Intent 重定向
应用把外部传入的 Intent 直接 startActivity,攻击者就可以让应用代为启动任意私有组件:
// 危险:直接转发外部 Intent
val forwarded = intent.getParcelableExtra<Intent>("forward")
startActivity(forwarded)
修复方式是严格校验目标 Intent 的 component 和 action,或者根本不接受外部传入的 Intent 对象。
Fragment 注入
某些旧版 Support Library 允许通过 Intent extra 指定要加载的 Fragment 类名,攻击者可以加载任意 Fragment。这个问题在新版 Navigation Component 中已不存在,但如果项目还在用旧方案,需要检查 PreferenceActivity 和类似入口。
剪贴板泄露
用户复制到剪贴板的内容对其他应用(Android 10 之前)或输入法(Android 10 之后前台应用)可见。如果用户复制了密码或 token,这个内容可能已经泄露。
对于密码输入框,可以禁用剪贴板:
passwordEditText.imeOptions = EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING
Android 13 引入了短暂剪贴板访问提示,用户能看到哪个应用在读取剪贴板,这对应用开发者也是一种约束。
实战建议
安全防护可以按优先级落地:
- 先关掉明文 HTTP,配置 network_security_config,这是成本最低、收益最高的操作。
- 检查所有 exported 组件,把不必要的 exported=true 改掉,补上权限控制。
- 敏感数据用 EncryptedSharedPreferences 或 EncryptedFile,密钥来源改为 Android Keystore。
- 生产包开启 R8 混淆,配置合理的 keep 规则,移除日志。
- 对 Deep Link、WebView、Intent 转发等高风险入口建立白名单校验。
- 高安全场景(支付、身份验证)使用证书锁定 + 生物识别绑定密钥。
安全不是一次性工作,而是随着业务演进持续检查的过程。每次接入新 SDK、增加新页面、开放新 Deep Link,都应该重新过一遍这些检查点。
总结
Android 安全的核心思路可以概括为三句话:敏感数据不要明文存、不要信任外部输入、不要暴露不必要的入口。
从数据存储的加密,到网络通信的 HTTPS 与证书锁定,到代码混淆与日志控制,再到组件暴露与 Deep Link 校验——这些措施单独看都不复杂,难的是在真实项目里持续执行,不因为赶进度而跳过,不因为"反正用户不会 root"而放松。
安全防护的上限不取决于最复杂的技术,而取决于最薄弱的环节。把基础打牢,比堆叠高级手段更有价值。