从ContentProvider报SecurityException分析出Android5.0+的一个隐藏大坑

前言

最近在开发A应用的时候对接了合作方的一个B应用,对方很快就把接口文档发了过来,约定好我们之间通过B应用提供的XXXContentProvider来获取相关的数据。一切看起来是如此的普通与简单,但是从刚开始调试的那一刻起,诡异的事情就发送了。九十岁老太为何起死回生?数百头母猪为何半夜惨叫?女生宿舍为何频频失窃?超市方便面为何惨招毒手?在这一切的背后,是人性的扭曲,还是道德的沦丧?事件的最后,让我发现了Android系统的一个大坑!滴滴~ 老司机马上开车,带你一同踏上这段难忘的踩坑经历~

先简单回顾下Android的permissions机制

AndroidManifest.xml里面声明ContentProvider的时候,我们是可以指定对应的readPermissionwritePermission的,这样就可以限制第三方应用程序,必须声明指定的读写权限,才能进行下一步的访问,提高安全性。

1
2
3
4
5
6
<provider
android:name=".provider.XXXContentProvider"
android:authorities="com.aaa.bbb.ccc.provider.authorities"
android:readPermission="com.aaa.bbb.ccc.provider.permission.READ_PERM"
android:writePermission="com.aaa.bbb.ccc.provider.permission.WRITE_PERM"
android:exported="true"/>

但是首先,我们得先通过<permission/>定义好相关应用的权限,且你可以通过android:protectionLevel来定义权限的访问等级。常用的有以下几种,更多参数介绍详见官网permission-element

  • signature: 调用App必须与声明该permission的App使用同一签名
  • system: 系统App才能进行访问
  • normal: 默认值,系统在安装调用App的时候自动进行授权
    1
    2
    3
    4
    5
    6
    <permission
    android:name="com.aaa.bbb.ccc.provider.permission.READ_PERM"
    android:protectionLevel="normal" />
    <permission
    android:name="com.aaa.bbb.ccc.provider.permission.WRITE_PERM"
    android:protectionLevel="normal" />

What the fuck? SecurityException?

在调用App中,通过<uses-permission />声明好调用需要的权限,然后通过getContentResolver().query()方法进行数据查询,就这么简单两步。这个时候,程序居然崩溃了,抛出了SecurityException。这尼玛我不是按照接口文档声明好权限了么?怎么会报安全问题呢?一定是我打开的方式不对。

1
2
3
4
5
6
7
03-29 12:08:12.839 4255-4271/com.codezjx.provider E/DatabaseUtils: Writing exception to parcel
java.lang.SecurityException: Permission Denial: reading com.aaa.bbb.ccc.XXXProvider uri content://com.aaa.bbb.ccc.xxx/getxxx/ from pid=22529, uid=10054 requires null, or grantUriPermission()
at android.content.ContentProvider.enforceReadPermissionInner(ContentProvider.java:539)
at android.content.ContentProvider$Transport.enforceReadPermission(ContentProvider.java:452)
at android.content.ContentProvider$Transport.query(ContentProvider.java:205)
at android.content.ContentProviderNative.onTransact(ContentProviderNative.java:112)
at android.os.Binder.execTransact(Binder.java:500)

上面这段Log是在ContentProvider所在的应用发出来的,我们都知道ContentProvider中的各种操作其实底层都是通过Binder进行进程间通信的。如果Server发生异常,会把exception写进reply parcel中回传到Client,然后Client通过android.os.Parcel.readException()读出Server的exception,然后抛出来。没错,就是这么暴力~

这个时候我开始怀疑接口文档的准确性了,马上撸起我的jadx对目标apk进行了反编译,查了下对方的AndroidManifest.xml文件。里面声明的permission的确没错,而且ContentProviderauthorities属性也是正确的,exported属性也是true。

SecurityException再次出现

当时一下子没细想,为了快点把数据联调好,我们暂时把permission给去掉了。哎呀妈,心想这下子可以安心的联调了。没想到,诡异的事情再次发生了。程序运行,SecurityException又再次出现了,还是跟上面的Log一模一样。这尼玛权限不都去掉了吗?为什么还报这个异常呢?

java.lang.SecurityException: Permission Denial: reading com.aaa.bbb.ccc.XXXProvider uri content://com.aaa.bbb.ccc.xxx/getxxx/ from pid=22529, uid=10054 requires null, or grantUriPermission()

仔细分析了上面这段关键的Log,发现requires null这个关键的字眼。一般在ContentProvider出现权限问题的时候,会通过requires告诉你到底缺了什么permission。然而这里为什么是null呢?想想总感觉不对劲。

Read the Fucking Source Code

合作方告知,当初一直在4.4的机器上调试的,一直没出现过这个问题。这次在5.1的机器上跑,才发现会奔溃。经过了各种尝试与调试(此处省略一万字),还是没能找到报错的原因,甚至曾一度开始怀疑人生了。这个时候,只能去啃啃源码了,看能不能发现什么端倪。

ContentProvider的源码位于frameworks/base/core/java/android/content/ContentProvider.java,没有系统源码的也可以直接翻SDK的源码文件。直接查看Log中报错的位置enforceReadPermissionInner()方法。

这段方法比较短,还是比较好理解的,其实就是在类似query()这些操作前会做一个检查,确认调用方是否具有某些permission。如果没授权,就会直接抛出SecurityException

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
/** {@hide} */
protected void enforceReadPermissionInner(Uri uri, IBinder callerToken)
throws SecurityException {
final Context context = getContext();
final int pid = Binder.getCallingPid();
final int uid = Binder.getCallingUid();
String missingPerm = null;

if (UserHandle.isSameApp(uid, mMyUid)) {
return;
}

if (mExported && checkUser(pid, uid, context)) {
final String componentPerm = getReadPermission();
if (componentPerm != null) {
if (context.checkPermission(componentPerm, pid, uid, callerToken)
== PERMISSION_GRANTED) {
return;
} else {
missingPerm = componentPerm;
}
}

// track if unprotected read is allowed; any denied
// <path-permission> below removes this ability
boolean allowDefaultRead = (componentPerm == null);

final PathPermission[] pps = getPathPermissions();
if (pps != null) {
final String path = uri.getPath();
for (PathPermission pp : pps) {
final String pathPerm = pp.getReadPermission();
if (pathPerm != null && pp.match(path)) {
if (context.checkPermission(pathPerm, pid, uid, callerToken)
== PERMISSION_GRANTED) {
return;
} else {
// any denied <path-permission> means we lose
// default <provider> access.
allowDefaultRead = false;
missingPerm = pathPerm;
}
}
}
}

// if we passed <path-permission> checks above, and no default
// <provider> permission, then allow access.
if (allowDefaultRead) return;
}

// last chance, check against any uri grants
final int callingUserId = UserHandle.getUserId(uid);
final Uri userUri = (mSingleUser && !UserHandle.isSameUser(mMyUid, uid))
? maybeAddUserId(uri, callingUserId) : uri;
if (context.checkUriPermission(userUri, pid, uid, Intent.FLAG_GRANT_READ_URI_PERMISSION,
callerToken) == PERMISSION_GRANTED) {
return;
}

final String failReason = mExported
? " requires " + missingPerm + ", or grantUriPermission()"
: " requires the provider be exported, or grantUriPermission()";
throw new SecurityException("Permission Denial: reading "
+ ContentProvider.this.getClass().getName() + " uri " + uri + " from pid=" + pid
+ ", uid=" + uid + failReason);
}

我们来关注下为什么会是requires null,其实就是因为missingPerm没有被赋值。再仔细分析,如果下面这大段代码没有被执行的话,那么missingPerm就不会被赋值。

1
2
3
if (mExported && checkUser(pid, uid, context)) {
......
}

前面已经确认过mExported肯定是true的,那么没执行的原因就是checkUser()方法返回了false。(之前有提到在Android4.4是不会出现这个SecurityException的,为什么呢?因为在Android5.0+后ContentProvider才增加了这段多用户检查的代码,泪奔~)

我们来看下checkUser()这个方法,种种迹象表明,就是因为它返回了false,导致missingPerm没赋值,并最终throw了SecurityException

1
2
3
4
5
6
boolean checkUser(int pid, int uid, Context context) {
return UserHandle.getUserId(uid) == context.getUserId()
|| mSingleUser
|| context.checkPermission(INTERACT_ACROSS_USERS, pid, uid)
== PERMISSION_GRANTED;
}

通过反射与其他方式,我们可以逐个验证checkUser()方法中各个boolean条件的值:

  • (UserHandle.getUserId(uid) == context.getUserId()) -> false
  • mSingleUser -> false
  • (context.checkPermission(INTERACT_ACROSS_USERS, pid, uid) == PERMISSION_GRANTED) -> false

前面在踩坑的时候,自己写了一套测试的demo,在正常情况下UserHandle.getUserId(uid) == context.getUserId()是会返回true的,其中返回的userId都是0(因为我测试机器就一个用户)

种种迹象表明,合作方提供的问题应用中context.getUserId()返回值并不是0。在强烈的好奇心驱使下,我又撸起了jadx对目标apk再次进行了反编译,全局搜索了下getUserId()方法,发现还真TM有类似的方法,在BaseApplication中,有这么一个getUserId()方法,用来返回注册用户的id。

而在ContentProvider中,mContext也就是Application这个Context实例,也就是说getUserId()方法被无意识的进行了重写。因此,解决这个SecurityException异常最简单的方法就是把BaseApplication中的getUserId()方法换个名字就好了。至此,整个踩坑经历终于到了尾声。

总结

通过这次踩坑,发现了Android系统中一个隐藏的问题。在自定义的Application中,如果你声明了public int getUserId()这个方法,并且返回的不是当前用户的userId,那么你的ContentProvider在Android5.0+的机器都会失效。不信?自己试试~

1
2
3
4
5
/** @hide */
@Override
public int getUserId() {
return mBase.getUserId();
}

因为这个是一个@hide方法,所以通常这个重写行为都是无意识的,IDE并不会提示你重写了Application中的这个方法。但如果你比较幸运,刚好用了带hidden-api的Android SDK Jar包,那么IDE会给你一个提示,但除了系统应用开发,一般很少人会导入hidden-api吧~

Missing `@Override` annotation on `getUserId()` more…

好了,这次的分析先到这里,希望大家以后遇到这个诡异的SecurityException异常的时候,不至于再跳进这个隐藏的大坑里~ Over~