Activity劫持检测与防护:用FLAG_SECURE、任务栈监听、自定义权限筑起防线
Activity劫持,说白了就是恶意应用偷偷把你的界面换掉。你明明在登录银行App,看到的却是假的输入框。密码就这么被钓走了。我在项目中遇到过好几次类似的攻击案例,每次复盘都让人后背发凉。
今天咱们就聊聊怎么防。核心思路就三个:让敏感界面不能被截图、监控任务栈的变化、用自定义权限卡住入口。一个一个来。
第一道防线:FLAG_SECURE 防截图防录屏
FLAG_SECURE 是个好东西。它告诉系统:这个窗口的内容不允许被截屏,也不允许被录制。攻击者想通过录屏来获取你的操作流程?没门。
用法很简单,在 Activity 的 onCreate 里加一行:
getWindow().setFlags(WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE);
或者直接在布局文件的根节点加上:
android:flags="secure"
嗯,这里要注意。FLAG_SECURE 只防截屏和录屏,它防不了劫持。什么意思?恶意应用还是可以把它的 Activity 盖在你上面。所以这只是第一层,别指望它一劳永逸。
第二道防线:任务栈监听,发现异常立即报警
Activity劫持的本质是什么?是恶意应用把自己的 Activity 插入到了你的任务栈里。你想想看,正常情况下你的 App 的 Activity 应该一个接一个地压栈。突然冒出来一个别的包名的 Activity?那肯定有问题。
我们可以通过监听任务栈的变化来发现这种异常。Android 5.0 之后提供了 UsageStatsManager,但那个需要权限,而且有延迟。我更推荐用 AccessibilityService 来实时监听。
核心思路是这样的:
- 注册一个 AccessibilityService
- 在 onAccessibilityEvent 里捕获 TYPE_WINDOW_STATE_CHANGED 事件
- 拿到当前顶层 Activity 的包名
- 跟自己的包名对比,如果不一致,说明被劫持了
代码大概长这样:
@Override
public void onAccessibilityEvent(AccessibilityEvent event) {
if (event.getEventType() == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
String packageName = event.getPackageName().toString();
if (!packageName.equals("com.your.app")) {
// 检测到劫持!立即报警
showAlert("检测到可疑窗口:" + packageName);
// 可以尝试把用户带回自己的 Activity
bringBackToForeground();
}
}
}
还有一个更轻量的方法:监听 onPause 和 onResume 的时序。正常情况下,你的 Activity 从 onPause 到 onResume 应该很快。如果中间间隔太久,或者 onResume 时发现栈顶不是自己,那就有问题。
@Override
protected void onPause() {
super.onPause();
pauseTimestamp = System.currentTimeMillis();
}
@Override
protected void onResume() {
super.onResume();
long elapsed = System.currentTimeMillis() - pauseTimestamp;
if (elapsed > 2000) { // 超过2秒,可疑
checkIfTopActivityIsOurs();
}
}
这个方法不需要任何权限,实现也简单。但缺点是只能事后发现,不能实时阻止。不过对于大多数场景来说,够用了。
第三道防线:自定义权限校验,卡住入口
Activity劫持还有一种玩法:恶意应用直接启动你的 Activity。比如它知道你的 Activity 的完整类名,直接通过 Intent 启动。这时候怎么办?用自定义权限来卡住它。
首先在 AndroidManifest 里声明一个自定义权限:
<permission
android:name="com.your.app.permission.SECURE_ACTIVITY"
android:protectionLevel="signature" />
注意 protectionLevel 是 signature。这意味着只有跟你用同一个签名的应用才能获取这个权限。其他应用就算声明了也用不了。
然后在你的敏感 Activity 上加上这个权限:
<activity
android:name=".SecureActivity"
android:permission="com.your.app.permission.SECURE_ACTIVITY">
...
</activity>
这样一来,其他应用想启动这个 Activity?系统会先检查权限。没有权限?直接拒绝。劫持者连门都进不去。
但有个问题:如果你的 App 有多个模块,或者有插件化架构,签名权限可能会带来一些麻烦。我建议只在最核心的 Activity 上使用,比如支付页面、修改密码页面。
三种方案怎么选?一张图看懂
我把这三种方案的适用场景和优缺点整理了一下。你根据自己 App 的实际情况来选:
| 方案 | 防护能力 | 实现成本 | 用户感知 | 推荐场景 |
|---|---|---|---|---|
| FLAG_SECURE | 防截屏录屏 | 低(一行代码) | 无 | 所有敏感页面 |
| 任务栈监听 | 检测劫持行为 | 中(AccessibilityService) | 需用户授权 | 金融、支付类App |
| 自定义权限 | 阻止未授权启动 | 低(声明即可) | 无 | 核心Activity |
我个人习惯是:FLAG_SECURE 打底 + 自定义权限保核心 + 任务栈监听做增强。三层叠加,基本能挡住 99% 的劫持攻击。
核心逻辑流程图
下面这张图展示了 Activity 劫持攻击的完整链路,以及我们的三道防线分别在哪个环节起作用:
实战中的几个坑
方案说完了,我再分享几个实际项目中踩过的坑:
- FLAG_SECURE 在 WebView 里不生效。如果你在 WebView 里加载了敏感页面,攻击者可以通过 WebView 的 JavaScript 接口来获取内容。我建议在 WebView 里也做一层校验,或者干脆不用 WebView 展示敏感信息。
- AccessibilityService 会被系统杀死。有些厂商的 ROM 会频繁杀掉后台服务。我遇到过用户反馈说检测功能突然失效了,一查发现是服务被系统回收了。解决方案是加一个定时心跳检测,如果服务挂了就重新启动。
- 自定义权限在 Android 11 之后有变化。从 Android 11 开始,系统对权限的管理更严格了。如果你的 App 目标版本是 30 以上,记得检查一下权限声明是否合规。
isTaskRoot() 方法。如果返回 false,说明你的 Activity 上面还盖着别的东西。这时候可以弹个警告,或者直接 finish 掉。
好了,Activity 劫持的防护就聊到这儿。记住一个原则:不要相信任何外部输入,包括系统窗口。你的 App 的界面,只能由你自己控制。