Android hide api反射方案合集

730 阅读7分钟

最近工作需要,反射了一下 android.app.QueuedWork 这个类,但是这个类的一些 Field 在Android P之后是不允许APP反射的,所以需要通过一些绕过系统限制的方法。借此机会了解了一下各个绕过hide限制的反射方案。

原理

我们先从原理上理解为什么Android可以实现限制我们反射某个api,反射不是Java的基础功能吗。以 getDeclaredMethod 为例,他最后会调用 java_lang_Class.cc里的 Class_getDeclaredMethodInternal:image.png 这里如果 ShouldDenyAccessToMember返回true,那么久直接返回null了。所以Android就是修改了这部分的代码来加上访问限制。他会调用一个重载函数,传入 getHiddenapiAccessContextFunction函数,这个函数会直接调用获取调用方的上下文。重载的 ShouldDenyAccessToMember代码比较长,我直接把流程梳理成流程图: image.png 所以我们只要弄清楚Context的domain和每个domain的规则是什么,基本就能弄清楚是怎么被拦截的了。

domain限制

Domain分为下面三种:

Domain是创建 AccessContext 的时候计算的, 方法叫ComputeDomain,有好几个重载方法调用,我也懒得贴代码了,直接整理成图: image.png 上面代码我们可以得到结论:当我们调用hide api的时候,需要调用方的domain比被调用方的domain小。如果是应用去调用hide api,就会反过来,那么就会拒绝。

栈帧回溯决定上下文

那么调用方的上下文是怎么创建出来的?这里就回到了前面提到的 getHiddenapiAccessContextFunction函数,他对应调用的方法是 hidden_api.cc里面的 GetReflectionCallerAccessContex, 关键代码在: image.png 这里的 WalkStack 函数就是在回溯当前的调用栈帧。根据调用栈帧获取到的信息来判断当前上下文。每个调用点的栈帧会执行 visitor 的 visitFrame函数,这个函数代码也不少,具体不做什么分析了,反正总结起来结论就是:

  • 如果当前attach的是一个jni线程,那么因为没有java栈帧的信息,所以没法确认java环境,所以caller是null
  • 如果一直回溯完,java环境的类加载器都是 BootstrapClassLoader,那么caller也是null

BootstrapClassLoader的判断条件就是classloader是不是为null: image.png

豁免列表

接下来看调用方caller是kApplication的时候的处理。

EnforcementPolicy policy = runtime->GetHiddenApiEnforcementPolicy();
if (policy == EnforcementPolicy::kDisabled) {
    return false;
}
return detail::ShouldDenyAccessToMemberImpl(member, api_list, access_method);

如果policy是kDisabled,那么不会拒绝。 ShouldDenyAccessToMemberImpl 内部也有一个地方会返回false:

if (member_signature.DoesPrefixMatchAny(runtime->GetHiddenApiExemptions())) {
    MaybeUpdateAccessFlags(runtime, member, kAccPublicApi);
    return false;
}

这里的意思是runtime的getHiddenApiExemptions里面前缀和现在调用的函数签名前缀一样就豁免。说明系统自己其实是有一份hide api的豁免名单的。

综合上面的源码,从思路上推导出的方案包括

  • 欺骗方案:模拟系统调用
  • hook方案:修改policy为kDisabled
  • 修改豁免名单

常见方案

模拟系统调用
元反射方案

把反射调用者的ClassLoader修改成系统调用,也就是BootstrapClassLoader或者ClassLoader位null: weishu的FreeReflection方案通过元反射的方式去反射设置豁免名单,反射代码维护在 me.weishu.reflection.BootstrapClass里面: image.png 前缀设置成了 "L",也就是所有的反射都被豁免: image.png 可以看到他其实是通过反射去调用了Class.forName("dalvik.system.VMRuntime"),forName是通过反射获取的Class的方法,这样因为Class这种系统类是BootstrapClassLoader加载的类,系统会认以为是自需要反射调用VMRuntime。 但是他的这个方案只有在Android10之前才能正常工作,后面就失效了。因为在Android9的时候,他会调用 hidden_api.h 里面的 IsCallerTrusted 函数,如果是BootClassLoader加载的,会直接信任: image.png 但是在Android10之后,嵌套反射会在回溯调用栈帧的时候去检查java.lang.reflect包名下的调用,元反射的方案这里会继续往上回溯,所以和直接反射本质上就没有了区别: image.png 然后生成caller的上下文的时候会根据dexfile来算他的domain: image.png 而dexfile的上下文是应用加载的时候就已经决定了的,当classloader是null的时候,返回的是 Domain::kPlatformimage.png 所以为了适配高版本,FreeReflection使用DexFile加载class(me.weishu.reflection.BootstrapClass)的时候设置 ClassLoader 为null来欺骗系统: image.png 这里他把 BootstrapClass 单独写入一个dex文件,然后维护了dex文件的base64,在初始化的时候写入dex文件,通过加载这个dex文件去加载BootstrapClass 类。从 DexFile 源码可以看到他的 loadClass 直接调用了 native 方法,如果classloader是null,那么在前面的反射流程里,他也是可以被信任的。 image.png 这里会classloader为null的话在 ClassLinker:InsertClassTableForClassLoader 的时候就会算成 BootClassLoader: image.png 经过测试,这个方案目前在高版本上是可以运行的,但是DexFile其实也慢慢的加入了废弃API的行列,这个方案在未来是否会一直可用,也是一个未知数。

jni调用

从前面的代码可以得到jni层面的两个调用方案:

  • 创建一个jni线程,然后在jni线程里面进行反射设置豁免
  • System.loadLibrary 的调用domain很低,可以在对应的so的 JNI_OnLoad 回调里面去反射设置豁免

反射逻辑基本是一样的: image.png 区别是如果是通过创建线程的方式需要把jni的线程attach到JavaVM上面:

#include "pthread.h"

JavaVM* _vm = nullptr;

jint JNI_OnLoad(JavaVM* vm,void* reserved){
    _vm=vm;
    return JNI_VERSION_1_6;
}

void* func(void* arg) {
    JNIEnv* env;
    _vm->AttachCurrentThread(&env, nullptr);
    // 反射豁免条件逻辑
    ...
    // 释放逻辑
    _vm->DetachCurrentThread();
    pthread_exit(nullptr);
}

extern "C"
JNIEXPORT void JNICALL
Java_com_example_reflecthide_NativeCaller_init(JNIEnv *env, jclass clazz) {
    pthread_t pid;
    int pthread = pthread_create(&pid,nullptr, func, nullptr);
}

经过测试,这类方案在高版本是完美运行的。

内存符号修改方案

通过dlopen、dlsym可以加载并修改so里面的符号地址,例如

  • libart 里的 ZN3artL32VMRuntime_setHiddenApiExemptionsEP7_JNIEnvP7_jclassP13_jobjectArray,设置豁免名单
  • runtime.h 里面的 hidden_api_policy_,设置成 EnforcementPolicy::kDisabled

这类方案思路是比较直接容易理解的,但是实际写起来比较复杂,并且每个Android版本,不同的Android厂商可能符号或者符号位置会有差异,兼容性比较难保证。 Github上还有一个叫 AndroidHiddenApiBypass的框架,通过Java Unsafe来直接操作内存,达到相同的目的。这个方案更适合不希望引入native代码或者so的开发。

inlinehook方案

看到符合内存地址的修改,可以想到另一个更加简单粗暴的方式:inlinehook 思路其实很简单,ShouldDenyAccessToMember 直接hook下返回结果都返回false。但是inlinehook一个是框架和原理学习起来有一定成本,并且inlinehook在线上不能100%保证稳定性。在常规方法能解决问题的前提下,内存符合修改和inlinehook都不应该成为第一考虑。所以这里就简单提出一下,不做深入尝试。

总结

这次梳理了常见Android9之后的反射hide api的方案,通过这次学习,梳理了场景的方案的方向和思路。一方面我们需要做到理解Android限制我们调用hide api的原理,在真正需要这个方案的时候更好的做出选择,另一方面,我们也应该认识到Google设置这个规则的目的,那就是不到万不得已的时候不要随意去反射hide api,以免给app带来稳定性和安全性的问题。 短时间里研究清楚这几个方案还是需要感谢一下网上能搜到的这几篇文章: