1. 从一个容易被忽视的组件说起
移动安全学习这条路上,很多人一上来就扎进逆向、脱壳、Hook 框架这些硬核方向,结果学了大半年,连一个 App 的四大组件都没摸清楚。我刚开始也是这样,觉得 BroadcastReceiver 这种"发广播、收广播"的东西太简单了,能有什么安全问题?直到有一次在分析一个本地应用时,随手用adb发了一条广播,直接把一个本该隐藏的页面拉了起来,才意识到这个组件的水比想象中深得多。
BroadcastReceiver,中文叫广播接收器,是 Android 四大组件之一。它的核心职责就一件事:接收并处理广播消息。系统开机、网络变化、电量低、屏幕点亮,这些系统级事件都会以广播的形式发出来;应用之间也可以自定义广播来传递消息。听起来很朴素,但问题恰恰出在"谁都能发、谁都能收"这个特性上。如果开发者没有做好权限控制和数据校验,攻击者就能通过构造恶意广播,触发应用内部的敏感逻辑,甚至实现越权操作、信息泄露、拒绝服务。
这篇笔记面向的是正在学习移动安全、想系统掌握组件漏洞挖掘方法的人。不管你是刚入门的新手,还是已经会写简单 PoC 但不知道如何系统排查的老手,我都会从原理讲到实操,把 BroadcastReceiver 的漏洞挖掘链路完整拆一遍。核心关键词就几个:广播接收器、组件暴露、权限绕过、Intent 注入、动态注册。搞懂这几个词背后的机制,你就能独立完成一次完整的广播组件安全审计。
需要提前说明的是,下面所有案例和代码都是基于常见开发实践构造的模拟场景,不针对任何真实应用。工具使用、命令操作都经过脱敏处理,你可以直接在自己的测试环境里复现。
2. BroadcastReceiver 的两种注册方式与暴露面差异
2.1 静态注册:Manifest 里的"常驻哨兵"
静态注册是在AndroidManifest.xml里通过<receiver>标签声明的,系统在应用安装时就会解析并注册这个接收器。它的特点是生命周期跟随应用进程,即使应用没启动,只要匹配到广播,系统也能把进程拉起来处理。
<receiver android:name=".MyReceiver" android:exported="true"> <intent-filter> <action android:name="com.example.MY_ACTION" /> </intent-filter> </receiver>这里最关键的一个属性是android:exported。它决定了这个接收器是否允许其他应用发送广播过来。在 Android 12(API 31)之前,如果声明了intent-filter,exported默认就是true;从 Android 12 开始,必须显式声明这个属性,否则安装时直接报错。很多老应用在升级 targetSdk 时忘了改这个,结果要么编译不过,要么被迫加上exported="true",反而把攻击面暴露得更彻底。
我实际审计时,第一步永远是全局搜索exported="true"的 receiver。这一步能快速筛出所有对外暴露的入口,效率比逐个文件看高得多。
2.2 动态注册:代码里的"临时接线"
动态注册是在代码中调用registerReceiver()完成的,通常在 Activity 或 Service 的onCreate/onResume里注册,在onDestroy/onPause里注销。
IntentFilter filter = new IntentFilter("com.example.DYNAMIC_ACTION"); registerReceiver(myReceiver, filter);动态注册的暴露面取决于注册时是否指定了权限,以及IntentFilter的匹配规则。如果没加权限限制,任何应用都能发送匹配的广播。更隐蔽的是,动态注册的接收器只在组件存活期间有效,静态扫描 Manifest 根本发现不了,必须结合代码审计或运行时 Hook 才能定位。
提示:Android 14(API 34)对动态注册的 receiver 增加了强制要求——如果应用 targetSdk 升级到 34,注册时必须显式指定
RECEIVER_EXPORTED或RECEIVER_NOT_EXPORTED,否则会抛异常。这个变化让很多"裸奔"的动态注册被迫显式声明,反而方便了审计人员快速定位。
两种注册方式的暴露面差异,可以用一张表来对比:
| 维度 | 静态注册 | 动态注册 |
|---|---|---|
| 声明位置 | AndroidManifest.xml | 代码中 registerReceiver |
| 生命周期 | 随应用进程 | 随注册组件 |
| 静态扫描可见性 | 高 | 低 |
| 默认 exported(旧版本) | 有 filter 则为 true | 取决于权限参数 |
| 常见漏洞 | 越权调用、Intent 注入 | 权限缺失、生命周期泄漏 |
理解这张表,你就知道为什么审计时要"静态扫 Manifest + 动态看代码"两条腿走路。
3. 广播漏洞的四种典型形态与成因
3.1 未授权访问:谁都能敲的门
这是最常见也最容易被低估的问题。一个 receiver 声明了exported="true",却没有在onReceive里做任何调用方校验,那么任何应用都能发送对应 action 的广播,触发里面的逻辑。
假设某个应用有个 receiver 收到com.example.RESET_PASSWORD广播后就把密码重置为默认值,且不校验发送方。攻击者只需要几行代码:
Intent intent = new Intent("com.example.RESET_PASSWORD"); intent.putExtra("user", "admin"); sendBroadcast(intent);密码就被重置了。这类漏洞的本质是把敏感操作暴露给了不可信输入源。开发者往往觉得"这个 action 名字只有我知道",但 action 字符串在反编译后一览无余,根本算不上秘密。
3.2 权限校验形同虚设
有些开发者意识到了风险,于是加了权限检查,但检查方式有问题。常见错误有三种:
第一种是只在onReceive里检查getCallingUid(),但广播的发送方 UID 在接收时并不总是可靠可获取的,尤其是通过sendBroadcast发送时,接收方拿到的 calling Uid 可能是系统或 null。
第二种是自定义权限的 protectionLevel 设置不当。比如声明了一个normal级别的权限,任何应用只要在 Manifest 里申请就能获得,等于没设防。正确的做法是用signature级别,只有相同签名的应用才能持有。
<permission android:name="com.example.PRIVATE_PERM" android:protectionLevel="signature" /> <receiver android:name=".SecureReceiver" android:permission="com.example.PRIVATE_PERM" android:exported="true"> ... </receiver>第三种是权限声明了但发送方没带,接收方也不强制校验,导致权限成了摆设。
3.3 Intent 注入与数据污染
广播携带的 Intent 里可以放各种 extra 数据。如果接收方直接把这些数据拼接到 SQL 语句、文件路径、URL 或者反射调用的类名里,就可能引发注入类漏洞。
举个模拟场景:某 receiver 收到广播后,取出extra里的filename,然后openFileOutput(filename)。攻击者传入../../databases/secret.db,就可能读写到非预期目录。再比如把 extra 里的类名通过反射实例化,攻击者就能加载任意类,后果更严重。
这类漏洞的挖掘要点是:追踪 onReceive 里所有来自 Intent 的数据流,看它们最终流向了哪里。流向文件、数据库、网络、反射、命令执行的地方,都是高危汇聚点。
3.4 拒绝服务与逻辑轰炸
即使没有敏感操作,一个 exported 的 receiver 也可能被滥用。攻击者可以高频发送广播,触发接收方做耗时操作(比如大量 IO、网络请求),导致应用卡顿甚至 ANR。如果onReceive里还有空指针解引用、数组越界等隐患,一条精心构造的广播就能让应用崩溃。
这类问题单独看危害不大,但在某些场景下可以作为组合攻击的一环,比如配合其他漏洞制造竞态条件。
4. 手工挖掘的完整操作链路
4.1 第一步:静态定位所有 receiver
拿到一个 APK,先反编译。我习惯用apktool解出 Manifest 和 smali,再用jadx看 Java 伪代码,两者互补。
apktool d target.apk -o target_dir打开target_dir/AndroidManifest.xml,搜索<receiver。重点记录三个信息:android:name、android:exported、android:permission,以及intent-filter里的 action。
如果 exported 是 true 且没有 permission,直接标记为高优先级目标。如果 exported 没写,根据 targetSdk 判断默认值:小于 31 且有 filter 的,默认 true;大于等于 31 的,必须显式声明。
4.2 第二步:定位 onReceive 实现并做数据流分析
用 jadx 打开 APK,根据 receiver 的类名找到对应的 Java 文件,看onReceive方法体。这一步的核心是回答三个问题:
- 这个方法里做了什么敏感操作?(写文件、发网络、改配置、启动组件)
- 这些操作的输入参数来自哪里?是否直接取自 Intent?
- 有没有对调用方做校验?校验逻辑是否可绕过?
我一般会在 jadx 里用Ctrl+F搜索getStringExtra、getParcelableExtra、getIntExtra这些关键词,快速定位数据入口,然后顺着变量往下追。
4.3 第三步:构造 PoC 验证
定位到可疑点后,写一个简单的测试应用或者用adb直接发广播验证。
用 adb 发送带 extra 的广播:
adb shell am broadcast -a com.example.MY_ACTION \ --es filename "../../databases/secret.db" \ --ei user_id 1如果要指定发送方包名(模拟特定应用发送),可以加-p参数。如果要发送有序广播,用am broadcast默认就是有序的,接收方可以通过setResult回传数据。
对于需要动态注册才能触发的 receiver,adb 直接发可能无效,因为接收器还没注册。这时候需要先启动对应组件,或者用 Frida 在运行时 HookregisterReceiver来观察注册的 filter 和权限。
4.4 第四步:验证权限绕过路径
如果 receiver 声明了权限,不要急着放弃。先确认权限的 protectionLevel。用aapt或apkanalyzer查看权限定义:
aapt dump permissions target.apk如果看到protectionLevel=normal或者dangerous,说明任何应用都能申请到这个权限,防护基本无效。只有signature级别才真正限制了调用方必须是同签名应用。
还有一种情况是权限声明在 receiver 上,但发送方用的是sendBroadcast(intent, receiverPermission)这种带权限参数的重载。如果接收方没在 Manifest 里声明对应权限,或者声明了但没生效,也可能绕过。
5. 动态注册接收器的运行时排查技巧
静态扫描搞不定的动态注册,必须上运行时手段。我常用的组合是 Frida + adb logcat。
5.1 用 Frida Hook registerReceiver
写一个简单的 Frida 脚本,Hookandroid.content.ContextWrapper.registerReceiver,打印出每次注册的 filter、action 和权限参数:
Java.perform(function () { var ContextWrapper = Java.use("android.content.ContextWrapper"); ContextWrapper.registerReceiver.overload( "android.content.BroadcastReceiver", "android.content.IntentFilter" ).implementation = function (receiver, filter) { console.log("[registerReceiver] filter actions:"); var actions = filter.actionsIterator(); while (actions.hasNext()) { console.log(" action: " + actions.next()); } return this.registerReceiver(receiver, filter); }; });跑起来之后,操作应用触发注册逻辑,logcat 或 Frida 控制台就会打印出所有动态注册的 action。拿到 action 后,再用 adb 发广播验证。
5.2 观察广播的实际处理路径
有时候一个 action 被多个 receiver 接收,或者被系统拦截。可以用dumpsys activity broadcasts查看当前系统里的广播状态,虽然信息有限,但能确认广播是否被发出。
更直接的办法是在onReceive上下断点。如果应用可调试,用 jdb 或 Android Studio 附加调试;如果不可调试,用 Frida Hook 目标类的onReceive方法,打印调用栈和 Intent 内容:
var TargetReceiver = Java.use("com.example.MyReceiver"); TargetReceiver.onReceive.implementation = function (context, intent) { console.log("[onReceive] action: " + intent.getAction()); console.log("[onReceive] extras: " + intent.getExtras()); console.log(Java.use("android.util.Log").getStackTraceString( Java.use("java.lang.Exception").$new())); return this.onReceive(context, intent); };调用栈能告诉你这个广播是从哪里发出来的,是系统发的还是某个应用发的,对判断攻击面很有帮助。
5.3 动态注册的生命周期陷阱
动态注册的 receiver 如果忘记在onDestroy里unregisterReceiver,会导致内存泄漏,同时接收器在组件销毁后仍然存活,扩大了攻击窗口。审计时可以重点看注册和注销是否成对出现,尤其是那些在onCreate注册但只在异常分支注销的代码。
6. 那些文档里不会写的踩坑经验
6.1 Android 版本差异能把人坑哭
同一个 APK,在 Android 10 上能触发的广播漏洞,在 Android 13 上可能完全没反应。原因通常是高版本对隐式广播做了限制。Android 8.0(API 26)之后,大部分隐式广播不能再静态注册接收,必须动态注册。这意味着你按 Manifest 里的 action 发广播,系统可能直接丢弃。
解决办法是确认目标 action 是否在隐式广播限制名单里。如果在,就得想办法触发动态注册路径,或者用-p指定包名发送显式广播(如果 receiver 支持)。
6.2 exported 的默认值随 targetSdk 变化
我见过太多人因为没注意 targetSdk 而误判。一个 receiver 没写 exported,targetSdk 是 30,那有 filter 就是 true;targetSdk 是 31,那必须显式写,没写的话应用根本装不上。审计前先看AndroidManifest.xml里的targetSdkVersion,这个数字决定了默认行为。
6.3 权限校验的调用方 UID 不可靠
前面提过,广播接收时getCallingUid()经常拿不到发送方。真正可靠的做法是发送方在 Intent 里带上自己的标识,接收方通过签名校验或 token 验证。如果只依赖 UID,很容易被伪造或拿到 null 后逻辑走偏。
6.4 有序广播的 result 回传可能泄露数据
有序广播允许接收方通过setResult把数据回传给发送方。如果接收方把敏感信息塞进 result,而发送方是攻击者,数据就泄露了。审计时看到setResult、setResultData、setResultExtras要格外留意。
6.5 别忽略系统广播的滥用
有些应用监听BOOT_COMPLETED、CONNECTIVITY_CHANGE这类系统广播来做初始化。如果这些 receiver 是 exported 的,攻击者可以手动发送同名广播,提前触发初始化逻辑,可能绕过某些状态检查。虽然高版本对系统广播的伪造有限制,但在低版本或特定条件下仍然可行。
7. 从挖掘到修复的闭环思路
挖到漏洞只是第一步,能给出可落地的修复方案才算完整。针对 BroadcastReceiver 的安全问题,修复思路可以归纳为几条:
最小暴露原则。不需要对外接收广播的 receiver,一律exported="false"。动态注册时,Android 14 以上显式用RECEIVER_NOT_EXPORTED。
权限收紧。确实需要跨应用通信的,用signature级别的自定义权限,并在 receiver 上声明android:permission。发送方也要用带权限参数的sendBroadcast重载,双向校验。
输入校验。onReceive里所有来自 Intent 的数据,在使用前都要做白名单校验。文件路径要规范化后检查是否在允许目录内,类名要限制在预期集合里,SQL 参数要参数化。
敏感操作二次确认。重置密码、修改配置这类操作,不能只靠一条广播触发,应该加上 token 或用户交互确认。
生命周期管理。动态注册的 receiver 确保在对应生命周期回调里注销,避免泄漏和窗口扩大。
我在实际审计中,通常会把发现的问题按"可利用性"和"影响面"两个维度排优先级。一个 exported 且无权限、onReceive里直接写文件的 receiver,优先级最高;一个 exported 但只打日志的,优先级最低。这样输出的报告才有重点,修复建议也能按轻重缓急排。
最后分享一个我自己的习惯:每审计完一个应用,都会把遇到的 receiver 模式整理成一个小清单,比如"哪些 action 对应哪些敏感操作""哪些权限配置是无效的"。积累多了之后,再看到新的 APK,扫一眼 Manifest 就能大致判断出哪些地方值得深挖。这种模式识别能力,比记住某个具体漏洞更有价值。