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 盖在你上面。所以这只是第一层,别指望它一劳永逸。

我的习惯:所有涉及登录、支付、个人隐私的页面,我都会加上 FLAG_SECURE。哪怕只是展示一个手机号,也值得保护。

第二道防线:任务栈监听,发现异常立即报警

Activity劫持的本质是什么?是恶意应用把自己的 Activity 插入到了你的任务栈里。你想想看,正常情况下你的 App 的 Activity 应该一个接一个地压栈。突然冒出来一个别的包名的 Activity?那肯定有问题。

我们可以通过监听任务栈的变化来发现这种异常。Android 5.0 之后提供了 UsageStatsManager,但那个需要权限,而且有延迟。我更推荐用 AccessibilityService 来实时监听。

核心思路是这样的:

  1. 注册一个 AccessibilityService
  2. 在 onAccessibilityEvent 里捕获 TYPE_WINDOW_STATE_CHANGED 事件
  3. 拿到当前顶层 Activity 的包名
  4. 跟自己的包名对比,如果不一致,说明被劫持了

代码大概长这样:

@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();
        }
    }
}
我曾经踩过的坑:AccessibilityService 需要用户在系统设置里手动开启。很多用户根本不知道这个功能,或者嫌麻烦不开。所以这个方案只能作为增强手段,不能作为唯一依赖。

还有一个更轻量的方法:监听 onPauseonResume 的时序。正常情况下,你的 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?系统会先检查权限。没有权限?直接拒绝。劫持者连门都进不去。

核心要点:自定义权限 + signature 级别 = 只有你自己能启动。这是最彻底的防护方式。

但有个问题:如果你的 App 有多个模块,或者有插件化架构,签名权限可能会带来一些麻烦。我建议只在最核心的 Activity 上使用,比如支付页面、修改密码页面。

三种方案怎么选?一张图看懂

我把这三种方案的适用场景和优缺点整理了一下。你根据自己 App 的实际情况来选:

方案 防护能力 实现成本 用户感知 推荐场景
FLAG_SECURE 防截屏录屏 低(一行代码) 所有敏感页面
任务栈监听 检测劫持行为 中(AccessibilityService) 需用户授权 金融、支付类App
自定义权限 阻止未授权启动 低(声明即可) 核心Activity

我个人习惯是:FLAG_SECURE 打底 + 自定义权限保核心 + 任务栈监听做增强。三层叠加,基本能挡住 99% 的劫持攻击。

核心逻辑流程图

下面这张图展示了 Activity 劫持攻击的完整链路,以及我们的三道防线分别在哪个环节起作用:

Activity劫持攻击链路与三道防线 恶意应用 启动劫持Activity 覆盖目标Activity 窃取信息 防线1: FLAG_SECURE 防截屏/防录屏 防线2: 任务栈监听 检测异常窗口/包名 防线3: 自定义权限 signature级别校验 防止信息被截取 发现覆盖行为 阻止未授权启动 推荐组合方案 FLAG_SECURE(所有敏感页面)+ 自定义权限(核心Activity)+ 任务栈监听(增强) 三层叠加,覆盖攻击全链路

实战中的几个坑

方案说完了,我再分享几个实际项目中踩过的坑:

  • FLAG_SECURE 在 WebView 里不生效。如果你在 WebView 里加载了敏感页面,攻击者可以通过 WebView 的 JavaScript 接口来获取内容。我建议在 WebView 里也做一层校验,或者干脆不用 WebView 展示敏感信息。
  • AccessibilityService 会被系统杀死。有些厂商的 ROM 会频繁杀掉后台服务。我遇到过用户反馈说检测功能突然失效了,一查发现是服务被系统回收了。解决方案是加一个定时心跳检测,如果服务挂了就重新启动。
  • 自定义权限在 Android 11 之后有变化。从 Android 11 开始,系统对权限的管理更严格了。如果你的 App 目标版本是 30 以上,记得检查一下权限声明是否合规。
一个小技巧:在 onResume 里检查当前 Activity 是否还在栈顶。可以用 isTaskRoot() 方法。如果返回 false,说明你的 Activity 上面还盖着别的东西。这时候可以弹个警告,或者直接 finish 掉。

好了,Activity 劫持的防护就聊到这儿。记住一个原则:不要相信任何外部输入,包括系统窗口。你的 App 的界面,只能由你自己控制。