综合实战项目:构建一个安全的Android应用
从架构设计到代码实现,全面防护组件攻击。嗯,这个题目听起来挺唬人的,但其实说白了,就是把我们前面29章讲的东西,全部串起来用一次。
我个人习惯,每学完一个阶段,必须亲手搭一个完整项目。光看理论,你永远不知道坑在哪。我记得有一次,我自认为代码写得天衣无缝,结果一上渗透测试,三分钟就被打穿了……从那以后,我再也不敢轻视实战。
项目背景与威胁模型
我们要构建一个「企业级文档管理App」。用户登录后,可以查看、上传、分享文档。听起来简单,但攻击面其实不少。
先列一下我们面临的威胁:
- Activity劫持:恶意App覆盖我们的登录界面,窃取账号密码。
- Provider数据泄露:ContentProvider暴露了内部文档路径,被第三方App直接读取。
- Service后台滥用:恶意应用绑定我们的Service,执行未授权操作。
- BroadcastReceiver投毒:伪造广播,触发我们的敏感逻辑。
你看,这些攻击手段,每一个都能让App瞬间变成“裸奔”状态。所以,我们的架构设计,必须从一开始就把这些堵死。
核心原则:默认拒绝,按需开放。所有组件默认不导出,除非有明确的跨进程通信需求。
整体架构设计
我采用分层架构,从上到下依次是:
- UI层:Activity + Fragment,只处理界面渲染和用户交互。
- 业务层:ViewModel + UseCase,处理业务逻辑。
- 数据层:Repository + DataSource,封装本地和远程数据。
- 安全层:贯穿所有层,负责权限校验、加密、防劫持。
你想想看,如果把安全逻辑散落在各个Activity里,后期维护起来有多痛苦?我建议,把安全相关的代码抽成一个独立的模块,叫SecurityManager。
// SecurityManager.kt
class SecurityManager(private val context: Context) {
fun validateIntent(intent: Intent): Boolean {
// 检查来源包名是否在白名单内
val callingPackage = intent.`package` ?: return false
return whitelist.contains(callingPackage)
}
fun encryptData(data: ByteArray): ByteArray {
// 使用AES-GCM加密
// 具体实现略
}
}
这样做的好处是,所有安全策略都集中在一个地方。改需求时,你只需要改这一个文件,而不是满世界找代码。
Activity劫持防护实战
Activity劫持,说白了就是恶意App在用户点击前,把自己的界面盖在我们的界面上。用户输入的账号密码,直接进了攻击者的口袋。
我在项目中遇到过这种攻击。当时用户反馈说“登录后总是闪退”,我一查日志,发现是劫持App在后台偷偷篡改了Intent。从那以后,我养成了一个习惯:所有敏感界面,必须校验当前Activity是否处于前台。
// 在BaseActivity中
override fun onResume() {
super.onResume()
if (!isTaskRoot) {
// 当前Activity不是栈底,说明可能被劫持
finish()
return
}
// 检查当前是否被覆盖
if (isActivityOverlayed()) {
// 弹出警告,并锁定界面
showSecurityAlert()
}
}
private fun isActivityOverlayed(): Boolean {
// 通过WindowManager检查是否有悬浮窗覆盖
// 具体实现略
}
避坑指南:我曾经只检查了isTaskRoot,结果攻击者用FLAG_ACTIVITY_NEW_TASK绕过了。后来我加了悬浮窗检测,才算真正堵住这个洞。
ContentProvider数据泄露防护
Provider是数据泄露的重灾区。很多开发者图省事,直接把android:exported="true",然后所有App都能访问。这相当于把家门钥匙挂在门外。
我的做法是:永远使用权限保护。自定义一个签名级别的权限,只有同签名的App才能访问。
<!-- AndroidManifest.xml -->
<permission
android:name="com.example.docapp.READ_DOCS"
android:protectionLevel="signature" />
<provider
android:name=".provider.DocProvider"
android:authorities="com.example.docapp.provider"
android:exported="true"
android:readPermission="com.example.docapp.READ_DOCS"
android:writePermission="com.example.docapp.WRITE_DOCS" />
你可能会问,如果攻击者也是同签名怎么办?嗯,那说明你的签名证书已经泄露了,这属于更高级别的安全事件。对于普通攻击者,签名权限已经足够。
另外,在Provider内部,我还会做一层细粒度的校验:
// DocProvider.kt
override fun query(uri: Uri, projection: Array<String>?, selection: String?,
selectionArgs: Array<String>?, sortOrder: String?): Cursor? {
// 检查调用者是否拥有权限
checkCallingPermission("com.example.docapp.READ_DOCS")
// 校验URI路径,防止路径遍历
val path = uri.lastPathSegment ?: return null
if (path.contains("..")) {
throw SecurityException("Invalid path")
}
return super.query(uri, projection, selection, selectionArgs, sortOrder)
}
注意:checkCallingPermission只能检查调用进程的权限。如果调用者通过Binder跨进程调用,你需要用checkCallingOrSelfPermission。我踩过这个坑,当时查了半天才发现是权限检查对象搞错了。
Service与BroadcastReceiver防护
Service和BroadcastReceiver的防护思路类似:不导出 + 白名单校验。
对于Service,我建议使用android:exported="false",然后通过本地广播或绑定方式与同进程组件通信。如果确实需要跨进程,那就加上权限。
<service
android:name=".service.DocSyncService"
android:exported="false" />
对于BroadcastReceiver,动态注册时指定权限:
// 动态注册
val filter = IntentFilter("com.example.docapp.DOC_UPLOADED")
registerReceiver(uploadReceiver, filter, "com.example.docapp.RECEIVE_DOCS", null)
静态注册的Receiver,我一般只在必要的时候用,而且会加上android:exported="false"。你想想看,静态注册的Receiver,系统启动时就会加载,攻击者只要发个广播就能唤醒你的App,多危险。
知识体系总览
下面这张图,是我对这个项目的整体安全架构总结。你可以把它当成一个检查清单,开发时逐项对照。
总结与最佳实践
好了,整个项目搭下来,你会发现一个规律:安全不是某个点的加固,而是全链路的防护。从Activity到Provider,从Service到BroadcastReceiver,每一个组件都可能成为突破口。
我最后给你几个建议:
- 默认不导出:所有组件,除非明确需要跨进程,否则一律
exported="false"。 - 权限最小化:只申请你真正需要的权限,而且尽量用签名级别。
- 输入校验:永远不要信任外部传入的数据,包括Intent、URI、广播数据。
- 日志审计:记录所有敏感操作的日志,方便事后追溯。
- 定期渗透测试:找个安全团队,每半年打一次你的App。你会发现很多自己想不到的漏洞。
我记得有一次,我自认为代码已经固若金汤了,结果安全团队用了一个非常冷门的攻击向量——通过TaskAffinity劫持了我们的Activity。嗯,从那以后,我再也不敢说“绝对安全”这四个字了。
最后一句:安全是一场持续的攻防战,没有银弹。但只要你把基础打牢,90%的攻击者都会绕道走。