有隙可乘 - Android 序列化漏洞分析实战
作者:vivo 互联网大前端团队 - Ma Lian
本文主要描述了FileProvider,startAnyWhere实现,Parcel不对称漏洞以及这三者结合产生的漏洞利用实战,另外阐述了漏洞利用的影响和修复预防措施,这个漏洞波及了几乎所有的Android手机,希望能带给读者提供一些经验和启发。
一、背景
大家应该看到过一篇《2022年的十大安全漏洞与利用》的文章,文章中提到一个漏洞:
利用Android Parcel序列化和反序列不匹配,借助应用FileProvider未限制路径,可以获取系统级startAnyWhere能力,从而获取用户敏感信息,修改系统配置,获取系统特权等等。
这里面有三个关键词:
-
Parcel不匹配漏洞
-
startAnyWhere
-
FileProvider未限制路径
看到以上,大家可能会就其中涉及到的几个点有些疑问:
-
startAnyWhere是什么意思,是什么样的能力?
-
Parcel不匹配漏洞是什么原理,是如何产生的?
-
FileProvider的作用是什么,未限制路径又是什么问题?
-
这几者之间存在什么关联,又会带来哪些风险?
二、FileProvider
2.1 功能简介
图1.png首先我们来简单讲一下FileProvider,FileProvider其实就是用来进程间共享文件的。
上方左侧图是早期的应用间共享文件的方案,就是A应用把文件存在外置存储,然后把文件的物理地址给到B应用,B应用去这个地址去取。
那么这样的方式存在哪些问题呢?有以下几点:
-
权限无法控制:文件存放的位置,要保证都能访问,这样无法精确控制权限;
-
权限无法回收:文件一旦共享,无法撤销;
-
目录结构暴露:文件共享需要公开原始的文件地址,暴露了目录结构;
-
隐私内容泄露:部分私有目录文件共享存在安全隐私泄露的风险。
基于以上问题,google基于ContentProvider设计了FileProvider,如上方右侧图,文件共享必须基于FileProvider,由AMS来管控权限,提供的协议也是定制的content协议。
2.2 使用简介
了解了FileProvider出现的背景,下面介绍一下FileProvider的使用,使用FileProvider需要提供四个参数:
-
Uri(文件地址)
-
Action(接收方信息)
-
Type(文件类型)
-
Flags(授予权限)
如下面代码,最终通过startActivity来发起共享,记住这个startActivity,很重要。
Intent intent = new Intent();
intent.setAction("");
Uri uri = FileProvider.getUriForFile(getContext(), "", file);
intent.setType(getContext().getContentResolver().getType(uri));
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
startActivity(intent);
2.3 URI简介
content URI和普通的Http协议一样,也拥有scheme,authorities,path。
示例:content://authorities /XXX/xxx.txt。
Android提供了xml配置,如下代码所示,把实际的路径映射成一个虚拟的名称,这样的优势就是限制了路径,可以把指定目录的路径共享出去。
看到这里,大家就可以理解未限制路径的含义了,简单讲就是把系统根目录给共享出去了,正确的做法是只共享需要使用的目录。
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.file"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/******_paths" />
</provider>
<?xml version="1.0" encoding="utf-8"?>
<resources>
<paths>
<files-path name="test_in" path="/test/file" />
<external-path name="test_external" path="/test/file" />
</paths>
</resources>
2.4 权限简介
FLAG_GRANT_READ_URI_PERMISSION:文件读权限;
FLAG_GRANT_WRITE_URI_PERMISSION:文件写权限;
FLAG_GRANT_PERSISTABLE_URI_PERMISSION:持久授权,直至设备重启或者主动调用revokeUriPermission;
FLAG_GRANT_PREFIX_URI_PERMISSION:相同前缀路径统一授权。
2.5 授权方式
//第一种授权方式
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
//第二种授权方式
getContext().grantUriPermission("packageName", uri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
2.5.1 第一种授权方式:
一次授权,用完即止。
2.5.2 第二种授权方式:
-
不含持久授权flag的,权限依附于进程存活;
-
包含持久授权flag的,重启或者主动拒绝权限才会消失。
2.6 小结
上面主要简单介绍了一下FileProvider的设计思想、技术方案、使用方式,由此我们可以对文章开头提出的一些疑问进行解答。
1、FileProvider的作用
答:跨进程共享文件,一般通过startActivity的方式。
2、未限制路径
答:没有指定需要共享的文件目录,将系统根目录共享出去了。
3、存在什么风险,如何进行攻击
答:单针对FileProivider来看,风险较小,光依赖FileProvider这个问题还是没法进行攻击的,原因如下:
-
文件共享需要业务主动通过startActivity才能发起
-
读写权限交由系统来管理
三、startAnyWhere
接下来讲一下上文中提到的startAnyWhere,顾名思义,就是应用想打开哪个页面就打开哪个页面,那么在Android系统中,谁才有这个能力呢?
3.1 实现原理
能够实现startAnyWhere的只有系统SystemUid应用,这类应用在startActivity进行权限校验的时候是直接放行的,无论Activity是否exported,都能打开,最常见的应用比如系统设置。
下面是一个系统设置打开第三方应用的案例,通过设置可以直接打开第三方的账户登录页。
图2.gif3.2 实现流程
通过设置页面的添加账号的功能,可以直接拉起对应应用的界面,这个是今天漏洞的核心,我们来看一下系统调用流程。
如下图,首先系统设置调用AccountManager的addAccount,然后通过SystemServer中的AccountManagerService,一直调用到目标APP本身的AddAccount实现。
由APP本身提供一个Bundle,Bundle里面本身包含了一个intent的由设置进行打开。
图3.png这个里面其实存在一个风险,第三方应用可以随意提供一个恶意Intent,系统会直接调用startActivity,随之而来的风险很大。
上图中还存在一个第0步,即这个流程的发起方可以是三方应用本身,不一定需要从设置进入,那么这个整个流程就闭环了,完全无需用户介入,用户也可以完全无感知。
不过这个风险呢,google在Android4.4之后已经修复了,4.4之后增加了对intent内容的校验。
代码如下:
if (result != null&& (intent = result.getParcelable(AccountManager.KEY_INTENT)) != null) {
if (!checkKeyIntent(Binder.getCallingUid(),intent)) {
onError(AccountManager.ERROR_CODE_INVALID_RESPONSE,"invalid intent in bundle returned");
return;
}
}
上面代码就是取出外部传入的KEY-INTENT进行校验,这里面已经出现了今天的主角Parcel,整个攻击也是通过Parcel漏洞使得恶意的KEY-INTENT绕过系统的检查。
四、Parcel
下面我们看一下parcel漏洞及原理。
4.1 Parcel 简介
parcel是专门为Android提供的一个序列化的类,parcel的原理其实很简单,就是一个严格的对称读写,如下代码所示。
public void writeToParcel(Parcel dest, int flags) {
dest.writeInt(mSize);
}
public void readFromParcel(Parcel in) {
mSize = in.readInt();
}
同时序列化遵循基本的TLV格式,也就是Tag-Length-Value,Tag代表类型,Length代表长度,Value代表值,当然一些特殊情况:
-
Length不描述:有固定长度的类型可以不描述length,比如long,int等等;
-
Tag不描述:bundle序列化时,key一定是string类型,所以不需要描述Tag。
回到对称读写这一块,如果这个代码不对称了会出现什么情况呢,google曾经在android源码中出现了很多类似不对称的错误,看一下下面几个案例。
4.2 Parcel 不对称读写案例
4.2.1 案例1
如下图,这是一个典型且明显的不对称,writeLog&readInt,为什么不对称,很简单,int和long对应的长度不一样。
图4.png4.2.2 案例2
这是一个比较隐晦的不对称案例,是Android原生的WorkSource类,这个不对称一眼无法看出,以致于最近的Android版本这个问题一直存在,这个类也是此次漏洞攻击真正被利用的一个类。
下面简单看一下WorkSource序列化和反序列化的流程。
- 序列化
如下述代码,WorkSource序列化时,如果mChains是一个长度为0的空list,那么就会走else分支,此时序列化会连续写两个0。
序列化:
public void writeToParcel(Parcel dest, int flags) {
dest.writeInt(mNum);
dest.writeIntArray(mUids);
dest.writeStringArray(mNames);
if (mChains == null) {
dest.writeInt(-1);
} else { // 当mChains不为空的时候,这时候写了两个0
dest.writeInt(mChains.size());// 写第一个0
dest.writeParcelableList(mChains, flags);// 写第二个0
}
}
- 反序列化
如下述代码,WorkSource反序列化时,当读到第一个0也就是numChains=0的时候,这个对应mChains长度为0,同样也会走else分支,此时mChains直接被置为null,但是序列化其实是写了两个0,这时候后面还有一个0没有读,这样序列化和反序列化就造成了不对称。
反序列化:
WorkSource(Parcel in) {
mNum = in.readInt();
mUids = in.createIntArray();
mNames = in.createStringArray();
int numChains = in.readInt(); // 读第一个0
if (numChains > 0) {
mChains = new ArrayList<>(numChains);
in.readParcelableList(mChains, WorkChain.class.getClassLoader());
} else { // 当读到numChains=0的时候,这时候直接就将mChains置为null,第二个0还没有读
mChains = null;
}
}
当然实际上不对称的类还有很多,大家可以看下网上泄露出来的漏洞利用源码,有很多这样的类,这里就不列出来了,知道了漏洞的本质是因为Parcel读写不对称,我们接下来看一下其中的原理。
4.3 parcel 漏洞原理
了解parcel漏洞真正的原理之前,首先来看一下系统校验intent的序列化流程。
4.3.1 系统校验序列化流程
首先攻击者手动会序列化一次需要传给系统的bundle,然后系统会反序列化一次进行校验,校验完之后又会重新序列化交给设置,然后设置真正去打开页面的时候会再次反序列化,这样就经历了两次序列化与反序列化,因为其中读写不对称,所以给了攻击者有机可趁的机会。
图5.png4.3.2 漏洞原理简介
这个漏洞核心就是前后一共经历了两次序列化和反序列化。我们以上面4.2.1案例1的不对称举例(readInt()对应writeLong()),当出现不对称读写之后,两次序列化与反序列化会有什么后果?如下图所示可以看到:
第一次序列化:输入两个int 1;
第二次反序列化:读的时候是readInt(),读出两个int 1;
第三次序列化:写的时候是writeLong(),这是分别写了long 1和int 1,long的长度是int长度的双倍;
第四次反序列化:读的时候是readInt(),第一个long 1会被分成两个int来读,所以就一次读成了101。
图6.png而攻击者也正是借助这个不对称,导致实际输入和输出不一样,隐藏了恶意的KEY-INTENT,从而绕过了系统的校验,以此打开任意一个页面,实现startAnyWhere。
4.3.3 漏洞原理实践
因为案例1比较明显,google早已经修复该漏洞,而WorkSource因为比较隐晦,所以该漏洞一直存在,我们接下来看一下如何利用WorkSource来构造攻击实现。
下面一张图带你搞明白如何通过两次序列化和反序列化达到我们的目的:
图7.png由上述文章可知,最终给到系统校验的是一个bundle类型的数据结构,bundle是存储key-value类型的,而我们目的就是要将恶意的KEY-INTENT隐藏起来然后绕过系统的校验。接下来详细讲一下实现步骤:
1、手动序列化:
如上图左侧第一列,手动序列化这个bundle,这个bundle序列化时携带了三个key-value:
-
第一个key-value:WorkSource相关的;
-
第二个key-value:经过精心构造;
-
第三个key-value:隐藏恶意的KEY-INTENT。
第一次序列化后的bundle通过16进制打印出来如下图所示:
图8.png2、系统进行反序列化
经过系统第一次反序列化,没有触发不对称,系统是读不到这个恶意的KEY-INTENT的,所以自然校验通过。
3、系统重新序列化
系统校验完需要重新序列化,这时候由于读写不对称,最终红色区域【1,-1】两个值变成了【0,0】。
4、setting反序列化
setting再次反序列化,上面也讲到了,由于不对称,原本两个0只读了1个0。
5、解析最终的key-value
-
读第一个key-value:由于上述WorkSource的不对称,原本两个0只读了1个0;
-
读第二个key-value:由于读第一个时少读了一个0,剩余的0变成了第二个key-value的内容,整体内容错位,由于遵循TLV的格式,错位之后,0和13变成了第二个key-value的key,恶意KEY-INTENT前的所有值都变成了第二个key-value的value;
-
读第三个key-value:此时真正恶意的KEY-INTENT变成我们需要的第三个key-value。
五、漏洞攻击实战
通过上面两节,我们可以看到,借助startAnyWhere和parcel漏洞,可以绕过系统校验任意打开一个页面,下面来看两个真实案例:
5.1 实战案例1
可以看到在虚拟机上,通过这个漏洞直接就打开了锁屏密码的设置页面,然后可以直接绕过密码校验将锁屏密码改掉。
图9.gif5.2 实战案例2
案例1已经足以反应出问题和风险,但是实际上国内的手机经过改造,基本不会存在这个问题,那么我们来看一下真机上的使用案例:
在讲这个案例之前,我们要先额外讲一下XXSDK中存在的一个AsistActivity,里面存在一段代码,如下所示。
这个代码很简单,就是接受外部的intent的然后直接startActivity了,这里面又提到了startActivity,上面文件共享也是这样调用的,正好符合了FileProvider的使用逻辑。
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
//此处省略部分代码
Intent intent = getIntent().getParcelableExtra(Intent.EXTRA_INTENT);
int intExtra = intent == null ? 0 : intent.getIntExtra("", 0);
//此处省略部分代码
startActivityForResult(intent, intExtra);
}
借助这个类,我们便可以模拟一个完整的攻击流程,如下图所示:
图10.png-
第一步:攻击APP构造一个intent1,这个intent1的意图是打开上述AssistActivity;intent1中携带了恶意的intent2,这个intent2的意图打开攻击APP的指定页面,然后让应用共享指定文件了;
-
第二步:调用andorid系统添加账号页面;
-
第三步:业务APP中由于集成了AssistActivity,接受恶意的intent2会直接startActivity进行共享文件;
经过以上三步,直接就把APP的一些隐私文件共享给攻击APP,同时攻击APP可以在恶意intent中授权直接修改文件。
5.3 恶意intent的代码
下面看一下恶意intent的代码:
private Intent makeFileIntent() {
Intent intent1 = new Intent().setComponent(new ComponentName("XXX", "xxx.xxx.AssistActivity")); // 打开AssistActivity
Uri uri = Uri.parse("content://xxxx/xxx_info");
Intent intent2 = new Intent(mContext, SecondActivity.class); // 打开攻击者的页面并且共享指定URI的文件
intent2.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent2.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent2.setType(mContext.getContentResolver().getType(uri));
intent2.setData(uri);
intent1.putExtra("key", intent2);// 恶意intent2放入intent1中
return intent;
}
5.4 恶意序列化的代码
目前漏洞均已修复,为避免风险,不展示所有代码。
private static Bundle makeEvilIntent(Intent intent) {
Bundle bundle = new Bundle();
Parcel obtain = Parcel.obtain();
Parcel obtain2 = Parcel.obtain();
Parcel obtain3 = Parcel.obtain();
obtain2.writeInt(3);// bundle中key-va长度
obtain2.writeString("firstKey");
obtain2.writeInt(4); //VAL_PARCELABLE
obtain2.writeString("android.os.WorkSource");
obtain2.writeInt(-1);//mNum
obtain2.writeInt(-1);//mUids
obtain2.writeInt(-1);//mNames
obtain2.writeInt(1);//mChains.length
obtain2.writeInt(-1);
...此处省略一些构造代码
bundle.readFromParcel(obtain);
return bundle;
}
以下是视频演示,通过上面这一段攻击代码,拿到了手机上某个APP存在应用私有目录下的账号信息, 同样为了隐私,此处部分脱敏。
图11.gif六、漏洞利用影响
通过上文的介绍,我们知道借助这个漏洞可以实现对系统任意文件的修改,下面列出了漏洞带来的影响:
-
读取用户隐私信息;
-
安装恶意应用;
-
改写动态加载的代码;
-
改写系统配置;
-
获取特殊权限。
七、漏洞修复措施
除了发现问题更重要的是解决问题,下面列出了修复这个漏洞对应的一些方案:
系统层:
-
修复pacel漏洞的不对称;
-
系统校验的时候,做两次序列化与反序列化;
应用层:
-
FileProvider增加路径限制;
-
接受intent的Activity要着重注意校验,设置黑白名单。
八、漏洞预防措施
漏洞其实是不可避免的,下面是面对层出不穷漏洞的一些预防措施:
-
组件能不导出就不导出;
-
可导出的组件建议增加签名或者包名校验;
-
接受intent或者url参数务必校验;
-
文件共享务必遵循最小化原则;
-
敏感内容需要进行加密。
九、总结
接下来简单回顾一下,本文主要讲了5方面内容:
-
第1方面:主要描述了FileProvider,阐述了其出现背景、设计原理、使用方式、优缺点等;
-
第2方面:主要描述了startAnyWhere,阐述了其实现原理、实现方式;
-
第3方面:主要描述了Parcel不对称漏洞,阐述了Parcel的设计原理、不对称漏洞、漏洞案例、漏洞原理以及漏洞利用方案;
-
第4方面:主要描述了漏洞攻击实战,从模拟器到真机,从原理到代码,演示了通过漏洞攻击手机、获取用户隐私信息的流程;
-
第5方面:主要是讲了漏洞带来的影响、漏洞的修复和预防措施。
整体来讲,这个漏洞波及了所有的Android手机,无论是对用户,对企业都造成了巨大的损失。
作为开发者的我们需要从自身做起,守护好每一个环节,避免让攻击者有隙可乘。