最近在开发A应用的时候对接了合作方的一个B应用,对方很快就把接口文档发了过来,约定好我们之间通过B应用提供的XXXContentProvider
来获取相关的数据。一切看起来是如此的普通与简单,但是从刚开始调试的那一刻起,诡异的事情就发送了。九十岁老太为何起死回生?数百头母猪为何半夜惨叫?女生宿舍为何频频失窃?超市方便面为何惨招毒手?在这一切的背后,是人性的扭曲,还是道德的沦丧?事件的最后,让我发现了Android系统的一个大坑!滴滴~ 老司机马上开车,带你一同踏上这段难忘的踩坑经历~
在AndroidManifest.xml
里面声明ContentProvider
的时候,我们是可以指定对应的readPermission
与writePermission
的,这样就可以限制第三方应用程序,必须声明指定的读写权限,才能进行下一步的访问,提高安全性。
".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"/>
但是首先,我们得先通过
定义好相关应用的权限,且你可以通过android:protectionLevel
来定义权限的访问等级。常用的有以下几种,更多参数介绍详见官网permission-element。
- signature: 调用App必须与声明该permission
的App使用同一签名
- system: 系统App才能进行访问
- normal: 默认值,系统在安装调用App的时候自动进行授权
<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" />
在调用App中,通过
声明好调用需要的权限,然后通过getContentResolver().query()
方法进行数据查询,就这么简单两步。这个时候,程序居然崩溃了,抛出了SecurityException
。这尼玛我不是按照接口文档声明好权限了么?怎么会报安全问题呢?一定是我打开的方式不对。
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
的确没错,而且ContentProvider
的authorities
属性也是正确的,exported
属性也是true。
当时一下子没细想,为了快点把数据联调好,我们暂时把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呢?想想总感觉不对劲。
合作方告知,当初一直在4.4的机器上调试的,一直没出现过这个问题。这次在5.1的机器上跑,才发现会奔溃。经过了各种尝试与调试(此处省略一万字),还是没能找到报错的原因,甚至曾一度开始怀疑人生了。这个时候,只能去啃啃源码了,看能不能发现什么端倪。
ContentProvider
的源码位于frameworks/base/core/java/android/content/ContentProvider.java
,没有系统源码的也可以直接翻SDK的源码文件。直接查看Log中报错的位置enforceReadPermissionInner()
方法。
这段方法比较短,还是比较好理解的,其实就是在类似query()
这些操作前会做一个检查,确认调用方是否具有某些permission
。如果没授权,就会直接抛出SecurityException
。
/** {@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
// 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 means we lose
// default access.
allowDefaultRead = false;
missingPerm = pathPerm;
}
}
}
}
// if we passed checks above, and no default
// 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
就不会被赋值。
if (mExported && checkUser(pid, uid, context)) {
......
}
前面已经确认过mExported
肯定是true的,那么没执行的原因就是checkUser()
方法返回了false。(之前有提到在Android4.4是不会出现这个SecurityException
的,为什么呢?因为在Android5.0+后ContentProvider
才增加了这段多用户检查的代码,泪奔~)
我们来看下checkUser()
这个方法,种种迹象表明,就是因为它返回了false,导致missingPerm
没赋值,并最终throw了SecurityException
。
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+的机器都会失效。不信?自己试试~
/** @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~
点击此处阅读原文:https://codezjx.github.io/2018/03/30/content-provider-security-exception-issue/