突破 Android 9.0 + 受限制系统 Api

1,644 阅读10分钟

前言

公司内部APM上报了很多关于SharedPreference的ANR问题,因此由于迁移SP到DataStore的时间与测试资源有限,只能采用反射的方式将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的方式。

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

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

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

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

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

问题延展

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

  blog.csdn.net/mldxs/artic…

  blog.csdn.net/yudan505/ar…

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

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

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

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

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很多并发类中使用了Unsafe的CAS等操作?如果说不安全为什么系统底层会去使用呢?

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

  •   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 类。并不是所有的 Java 和 native 类都会在内存中共享同一块数据区域,这取决于系统的实现和优化策略。