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

前言

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

先简单回顾下Android的permissions机制

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

<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的时候自动进行授权
<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。这尼玛我不是按照接口文档声明好权限了么?怎么会报安全问题呢?一定是我打开的方式不对。

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

/** {@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就不会被赋值。

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~

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,530评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,403评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,120评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,770评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,758评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,649评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,021评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,675评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,931评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,751评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,410评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,004评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,969评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,042评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,493评论 2 343

推荐阅读更多精彩内容