突破 Android 9.0 + 受限制系统 Api

197 阅读10分钟

前言

公司内部APM上报了很多关于SharedPreferenceANR问题,因此由于迁移SPDataStore的时间与测试资源有限,只能采用反射的方式将QueuedWork#waitToFinish中的任务队列清空来绕过阻塞等待。但在Android9+的版本,系统限制了系统API反射,由于android9+的设备已经是常规机型了,这样要使用一些非常规的手段去突破这个限制。网络上很多文章,我也发过类似的文章,本文重新整理回顾一下。

系统是怎么限制的?

image.png

当我们使用getDeclaredField()方法的时候,AS会提示我们反射已被禁用。这里有个注意点是如果我们是debug模式的话,系统是允许我们通过反射调试的,但是当我们在release模式的话,就会抛NoMethodExeception。 那么我们通过getDeclaredMethod()方法看一下系统是如何禁用的。

Class.java # getDeclaredMethod()

Method result = recursivePublicMethods ? getPublicMethodRecursive(name, parameterTypes): getDeclaredMethodInternal(name, parameterTypes);

如果recursivePublicMethods=false则调用getDeclaredMethodInternal()

/**
 * Returns the method if it is defined by this class; {@code null} otherwise. This may   * return a
 * non-public member.
 *
 * @param name the method name
 * @param args the method's parameter types
 */
@FastNative
private native Method getDeclaredMethodInternal(String name, Class<?>[] args);

这个方法是一个native方法,那么我们去系统源码全局搜索一下,还是比较好找到位置的,C层的代码是 java_lang_Class.cc.

红框里边ShouldDenyAccessToMember()如果返回了true,那么直接return null,这样外部就会抛noSuchMethodException.

hidden_api.cc # ShouldDenyAccessToMember()

在这个方法中,我们看到第一个if如果判断条件为true,那么直接返回一个false给到调用方,那么反射的方法就直接可用了,那么我们是不是可以直接hook这个判断条件呢?答案是肯定的。 先看下GetHiddenApiExemptions()这个方法,它在runtime.h中。

从方法上来看,首先是set() get(), 然后见名知意是豁免的意思。哈哈由于提供了set get方法岂不是美滋滋,可以随意设置了,但是set方法中传入string的数组,那么应该传入什么呢? 我们退到调用方看下DoesPrefixMatchAny()这个方法。

这里仅仅将String数组进行了遍历,真正的判断是使用DoesPrefixMatch()方法,我们再看下。

看见这部分的代码逻辑是对Java字节码类型的签名前缀进行判断是否匹配。compare函数当值为0的时候说明两个字符串相同。 那么这个String要设置成什么呢?我们知道类型签名在Java中一直是‘L’开头的,哈哈 我们直接设置一个 前缀为L的字符串不就行了。

如何Hook豁免函数?

inlink hook 方法

文档

weishu.me/2018/06/07/…

开源项目

github.com/tiann/FreeR…

元反射方法

文档

weishu.me/2019/03/16/…

开源项目

github.com/tiann/FreeR…

Unsafe内存探索法

文档

lovesykun.cn/archives/an…

开源项目

github.com/LSPosed/And…

Tip

方案中使用了MethodHandles去获取对象,MethodHandles它比单纯的反射性能高出很多。

zhuanlan.zhihu.com/p/524591401…

总结

相对代码量少的方式是采用元反射+使用顶层classloader的方式。

设置类的 classloadernull 成为 BootClassPath 中的类以解除限制。此法看似可行,然并非万全:

首先在有隐藏 API 限制的情况下修改自己的 classloader 非常困难(但是仍可行),类是否有隐藏 API 限制是由其在加载时候就设置好的 Domain 决定,所以在加载类之后再修改 classloader 就没有用了。

利用 DexFile 加载一个没有 classloader 的类可以(甚至可以通过 base64 加载一个预制在 java 字符串中的 dex 文件),但是 DexFile 已经 deprecated 掉,并且在不日加入隐藏 API 列表。谷歌方面已经开始着手不信任 classloadernull 的类了。

稳定的方案是使用unsafe类去操作内存,对方法句柄进行内存地址的修改,以达成目的。

综合考虑,选择Unsafe方式去Hook豁免函数,来跳过对系统api的拦截检测。

问题延展

在Android 11 + ,元反射(套娃反射)是如何被禁掉的?

  blog.csdn.net/mldxs/artic…

  blog.csdn.net/yudan505/ar…

因为增加了调用者上下文判断机制,机制给调用者跟被调用的方法增加了domian,调用者domain要等于或者小于 被调用的方法domian,才能允许访问。

元反射被禁了,为什么使用DexFile.loadClass()就可以解决?

  在 DexFileloadClass 方法中,如果你将 null 传递给 classLoader 参数,会使用默认的系统类加载器(即 ClassLoader.getSystemClassLoader())来加载类。这意味着加载的类将使用默认的类加载器进行加载。

  例如,假设你有一个名为 MyClass 的类,你可以这样使用 DexFileloadClass 方法:

private static final String DEX = "dex file by base64";

private static boolean unsealByDexFile(Context context) {
    byte[] bytes = Base64.decode(DEX, Base64.NO_WRAP);
    File codeCacheDir = getCodeCacheDir(context);
    if (codeCacheDir == null) {
        return false;
    }
    File code = new File(codeCacheDir, System.currentTimeMillis() + ".dex");
    try {
        try (FileOutputStream fos = new FileOutputStream(code)) {
            fos.write(bytes);
        }
        // Support target Android U.
        // https://developer.android.com/about/versions/14/behavior-changes-14#safer-dynamic-code-loading
        try {
            //noinspection ResultOfMethodCallIgnored
            code.setReadOnly();
        } catch (Throwable ignore) {}
        @SuppressWarnings("deprecation")
        DexFile dexFile = new DexFile(code);
        // This class is hardcoded in the dex, Don't use BootstrapClass.class to reference it
        // it maybe obfuscated!!
        Class<?> bootstrapClass = dexFile.loadClass("me.weishu.reflection.BootstrapClass", null);
        Method exemptAll = bootstrapClass.getDeclaredMethod("exemptAll");
        return (boolean) exemptAll.invoke(null);
    } catch (Throwable e) {
        e.printStackTrace();
        return false;
    } finally {
        if (code.exists()) {
            //noinspection ResultOfMethodCallIgnored
            code.delete();
        }
    }
}

  在这个例子中,null 被传递给了 classLoader 参数,因此会使用默认的系统类加载器加载 MyClass 类。这个行为与在Java中直接使用类名加载类时的行为类似。

  然而,需要注意的是,使用默认的系统类加载器来加载类可能会导致一些限制和问题,特别是在 Android 的上下文中。在Android应用中,通常推荐使用当前应用的类加载器,即通过 getClassLoader() 方法获取的类加载器,来加载类。这样可以确保类的加载在应用的上下文中进行,避免不必要的问题。

  这样就满足第一点的调用者domain要等于被调用的方法domian

.java or .kt 文件如何直接转.dex?

首先build工程,获取到.class 文件。

dx --dex --output=xxx.dex xxx.class
// 注意!:Class文件的路径要包含包名路径,也就是com.example.viewdemo,不然转换时会报包名不匹配。
dx --dex --output=test.dex /Users/edisonli/Desktop/dapp-android-component/hidden_reflect/build/intermediates/javac/debug/classes/com/deliverysdk/hiddenapibypass/HiddenApiBypass.class
  • Unsafe顾明之意是不安全的类,那么为什么Java很多并发类中使用了UnsafeCAS等操作?如果说不安全为什么系统底层会去使用呢?

  •   UnsafeJava 中一个被标记为不安全的类,但它被用于一些系统底层操作和高性能并发编程。尽管名字含有 "不安全",但这不仅仅是指潜在的安全风险,更多的是指它对于不受限制地访问内存和执行底层操作的能力。

  •   Java 的设计哲学之一是提供一个相对安全的环境,以避免潜在的内存错误、并发问题和安全漏洞。然而,某些情况下,为了实现更高性能、更精确的控制和底层的操作,必须放宽一些限制,这就是 Unsafe 类的作用所在。

  •   在并发编程中,Unsafe 类的一些底层操作(如 CAS 操作)可以实现非阻塞的线程安全算法,从而避免传统锁所带来的性能开销。虽然这些操作可以用来构建高性能的并发数据结构,但需要小心使用,因为错误的使用可能导致内存问题和难以调试的 Bug。

为什么系统底层会使用 Unsafe 类呢?

性能优势: Unsafe 允许直接访问内存,执行原子操作(如 CAS)等,这些操作在某些情况下可以比传统的 Java 并发库提供更高的性能。这在一些对性能要求非常高的场景中很有价值。

细粒度控制: Unsafe 允许开发者直接操作内存和底层硬件特性,这对于实现一些复杂的算法和数据结构非常有用,但也需要小心处理。

底层库支持: 一些底层库和框架可能需要直接与操作系统或硬件进行交互,使用 Unsafe 可以方便地实现这些功能。

需要强调的是,使用 Unsafe 需要高度的技术责任感,因为它允许执行不受限制的操作,可能会导致各种问题,包括内存泄漏、线程安全性问题和崩溃。大多数情况下,不需要直接使用 Unsafe,而是可以利用 Java 并发库提供的高级抽象来处理并发问题。只有在深刻理解 Unsafe 的工作原理和风险的情况下,才应该在特定场景中使用它。

Unsafe类在Android中,应用层无法使用,那么有什么办法可以调用Unsafe中的方法吗?

类加载的双亲委派模型,先将系统代码复制一份放到一个 module 中并确保包名路径与系统路径保持一致。

Google Play 会扫描安装包来探查是否使用了unsafe?或者说我们规避反射黑名单,会被市场下掉吗?

  谷歌方面已经承诺 Unsafe 不会被彻底隐藏,而剩下使用的都是 Java 公开接口,更不可能被隐藏。依赖的 mirror 类的 offset 都被谷歌小心翼翼地维护,改动可能性也不大。因而可以说方法稳定且通用,并且大概率不会被谷歌后续掐掉。

ART 的模型是 Java 代码中的重要类和 native 代码中的一个类相互镜像:即共享同一块内存, 这句话怎么理解?

  Android Runtime (ART) 的模型允许 Java 代码中的重要类(Java 类)和 native 代码中的一个类(C/C++ 类)在内存中互相镜像,也就是说它们可以在内存中共享同一块数据区域。

  具体来说,这个概念是 ART 在运行时的一种优化策略。在传统的 Dalvik 虚拟机中,Java 类和 native 类在内存中是分开存储的,它们各自有自己的数据结构和内存分配。这可能导致一些性能和内存开销,因为 Java 代码和 native 代码可能需要频繁地进行数据转换和拷贝。

  而在 ART 中,这种隔离被部分打破,一些 Java 类和对应的 native 类可以在内存中共享同一块数据区域。这样做的好处在于:

  • 性能优化: 通过共享数据区域,可以减少 Java 代码和 native 代码之间的数据转换和拷贝,从而提高执行效率。

  • 内存节省: 由于共享数据区域,一些数据结构不需要在内存中重复存储,从而减少了内存占用。

  • 更紧密的集成: Java 代码和 native 代码之间的数据共享可以使跨语言开发更加紧密和高效。

  这种内存共享模型需要一些底层的技术支持,如内存布局的设计和内存访问的处理。它通常适用于某些特定的场景,比如 Android 应用中的某些关键类(如 Android 系统库中的一些类)以及与底层系统交互的 native 类。并不是所有的 Javanative 类都会在内存中共享同一块数据区域,这取决于系统的实现和优化策略。