Activity劫持原理:恶意Activity如何通过任务栈劫持合法Activity

大家好,我是你们的老朋友。今天我们来聊聊Android安全里一个非常经典、也非常“脏”的攻击手法——Activity劫持。

说白了,就是恶意应用怎么偷偷把你的界面换成它的界面。你明明在登录银行App,看到的却是假的输入框。密码就这么被钓走了。

我当年第一次在测试环境复现这个攻击时,后背真的发凉。你想想看,用户完全不知情,所有操作看起来都正常。嗯,这就是Activity劫持的可怕之处。

任务栈(Task)是什么?

要理解劫持,先得理解任务栈。Android里每个App启动Activity时,系统会把这些Activity放进一个栈里。这个栈就叫任务栈(Task)。

栈的特点是什么?后进先出。你打开A,再打开B,再打开C。按返回键时,C先退出,然后B,最后A。这就是标准行为。

每个任务栈有一个根Activity。栈顶的Activity就是当前用户正在交互的那个。系统只把焦点给栈顶的Activity。

好,关键点来了——恶意应用可以把自己的Activity插入到你的任务栈里,甚至直接推到栈顶。这就是劫持的核心。

劫持的两种典型手法

我归纳了一下,常见的Activity劫持分两类。咱们一个一个说。

1. 任务栈注入(Task Injection)

恶意App启动一个Activity时,如果设置了特定的Intent Flag,它就能把自己的Activity放到你的任务栈里。

最常见的Flag是:

Intent.FLAG_ACTIVITY_NEW_TASK
Intent.FLAG_ACTIVITY_CLEAR_TOP
Intent.FLAG_ACTIVITY_SINGLE_TOP

组合使用这些Flag,恶意App可以做到:

  • 把自己的Activity推到前台
  • 覆盖在合法App的界面之上
  • 甚至让用户以为它才是合法App

我在项目中遇到过这样一个案例:某恶意应用监听系统广播,一旦检测到银行App启动,立刻用FLAG_ACTIVITY_NEW_TASK启动一个伪造的登录界面。用户看到的确实是银行App的图标和名称,但界面是假的。

⚠️ 注意: 从Android 5.0开始,系统对FLAG_ACTIVITY_NEW_TASK的行为做了限制。但低版本设备仍然是重灾区。国内很多老旧设备还在用Android 4.x,这个攻击依然有效。

2. 界面覆盖(Overlay Attack)

这个更隐蔽。恶意App不需要启动自己的Activity,它只需要在合法Activity上面绘制一个悬浮窗。

用到的API是:

WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY

或者老版本的:

WindowManager.LayoutParams.TYPE_SYSTEM_ALERT

恶意App申请SYSTEM_ALERT_WINDOW权限后,就可以在任意界面之上绘制一个半透明的覆盖层。用户点击时,点击事件被覆盖层拦截,而不是底层的合法Activity。

我印象很深,有一次做渗透测试,发现一个恶意App就是利用这个手法。它在用户打开支付App时,在密码输入框上方覆盖了一个透明的“触摸拦截层”。用户输入密码时,密码被恶意App记录,同时事件也透传到底层App。用户完全没感觉。

核心区别:
  • 任务栈注入:恶意Activity替换了合法Activity
  • 界面覆盖:合法Activity还在,但上面盖了一层“透明纸”

攻击流程全景图

我画了一张图,帮你把整个攻击链路看清楚。

Activity劫持攻击流程 用户 合法App 恶意App 系统 启动 请求劫持 任务栈(Task) 合法Activity(栈底) 合法Activity(中间) 恶意Activity(栈顶) 覆盖 用户看到的永远是栈顶Activity,恶意App借此实现钓鱼攻击

你看这张图。用户启动合法App后,合法Activity进入任务栈。恶意App通过系统接口把自己的Activity推到栈顶。用户看到的变成了恶意界面。合法App被压在下面,完全不可见。

这就是典型的“狸猫换太子”。

钓鱼攻击的具体实现

光说原理不够,咱们看看代码。恶意App怎么监听合法App的启动?

// 恶意App注册一个BroadcastReceiver
public class AppMonitorReceiver extends BroadcastReceiver {
    @Override
    public void onReceive(Context context, Intent intent) {
        String packageName = intent.getStringExtra("package");
        if (packageName.equals("com.target.bank")) {
            // 目标App启动了,立刻劫持
            Intent hijackIntent = new Intent(context, FakeLoginActivity.class);
            hijackIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
            context.startActivity(hijackIntent);
        }
    }
}

这段代码里,恶意App监听系统广播。一旦检测到目标包名,立刻启动伪造的Activity。FLAG_ACTIVITY_NEW_TASK确保这个Activity能独立启动,并且出现在当前任务栈的顶部。

你可能会问:系统广播能随便监听吗?

嗯,这里有个坑。从Android 8.0开始,系统对隐式广播做了限制。但很多App仍然使用显式广播,或者通过轮询方式检测前台App。恶意App可以用UsageStatsManager来获取当前正在运行的App包名。

💡 个人经验: 我建议你在做安全测试时,重点关注那些申请了PACKAGE_USAGE_STATS权限的应用。这个权限虽然需要用户手动开启,但很多用户根本不知道它是干什么的,直接点了“允许”。

界面覆盖的代码实现

再来看看界面覆盖的写法。这个更隐蔽。

// 恶意App创建悬浮窗
WindowManager windowManager = (WindowManager) getSystemService(WINDOW_SERVICE);

// 创建覆盖层布局
View overlayView = LayoutInflater.from(this).inflate(R.layout.overlay_layout, null);

// 设置WindowManager参数
WindowManager.LayoutParams params = new WindowManager.LayoutParams(
    WindowManager.LayoutParams.MATCH_PARENT,
    WindowManager.LayoutParams.MATCH_PARENT,
    WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
    WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE |
    WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL |
    WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH,
    PixelFormat.TRANSLUCENT
);

// 添加到窗口
windowManager.addView(overlayView, params);

这段代码创建了一个全屏的、半透明的覆盖层。FLAG_NOT_FOCUSABLE让覆盖层不获取焦点,用户触摸事件可以穿透到底层。但恶意App可以通过onTouchEvent悄悄记录用户的点击位置和输入内容。

我曾经在分析一个恶意样本时,发现它甚至能精确覆盖到目标App的“确认支付”按钮上。用户点击时,恶意App先记录事件,再伪造一个“支付成功”的Toast。用户以为支付完成了,实际上钱转到了攻击者账户。

如何检测和防护?

说了这么多攻击手法,咱们也得聊聊怎么防。我总结了几条实用建议。

防护手段 说明 优先级
检查栈顶Activity 通过ActivityManager.getRunningTasks检测当前栈顶是否属于自己
使用FLAG_SECURE 设置WindowManager.LayoutParams.FLAG_SECURE,禁止截屏和覆盖
检测SYSTEM_ALERT_WINDOW权限 检查是否有其他App申请了悬浮窗权限
使用Android 12+的隐私保护 新版本系统对覆盖层有更严格的限制

我个人最推荐的做法是:在关键界面(如登录、支付)设置FLAG_SECURE。这样即使恶意App想覆盖,系统也会阻止。

// 在关键Activity的onCreate中设置
getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE);

还有一个技巧:定期检查当前任务栈的根Activity是不是自己的。如果不是,说明可能被劫持了。

// 检查当前栈顶Activity
ActivityManager am = (ActivityManager) getSystemService(ACTIVITY_SERVICE);
List<ActivityManager.RunningTaskInfo> tasks = am.getRunningTasks(1);
String topActivity = tasks.get(0).topActivity.getClassName();
if (!topActivity.equals(getClass().getName())) {
    // 可能被劫持,做相应处理
}
⚠️ 注意: getRunningTasks方法在Android 5.0之后已经被标记为deprecated。但在国内很多定制ROM上仍然可用。如果你要兼容低版本,这个方法还是有效的。

写在最后

Activity劫持这个漏洞,说难防也不难防,说简单也不简单。关键在于开发者有没有安全意识。很多App只关注业务功能,忽略了界面层的安全防护。

我见过太多App,登录界面没有任何防护措施。用户密码就这么裸奔在恶意App面前。你想想看,如果你的银行App被这样劫持,后果是什么?

嗯,该说的都说了。下一章咱们聊聊Provider数据泄露,那个坑更多。


公众号:蓝海资料掘金营,微信deep3321