关于一个平平无奇的NPE引申出来的部分proguard知识点的理解

同事遇到一个问题找我来看,是一个空指针的问题,看起来样子平平无奇。

事发场景

php 复制代码
Fatal Exception: java.lang.NullPointerException:
       at xxx.utils.TorrentDownloadHelper.addTaskCountListener(TorrentDownloadHelper.java:120)
       at xxx.view.OpenTorrentDownloadView.onAttachedToWindow(OpenTorrentDownloadView.kt:65)
       at android.view.View.dispatchAttachedToWindow(View.java:22479)
       ...

报错代码如下:标记①

kotlin 复制代码
# 调用者,类名:OpenTorrentDownloadView.kt
override fun onAttachedToWindow() {
    super.onAttachedToWindow()
    if (!EventBus.getDefault().isRegistered(this)) {
        EventBus.getDefault().register(this)
    }
    TorrentDownloadHelper.addTaskCountListener(context, taskCountListener)
}

# 崩溃处,类名:TorrentDownloadHelper.kt
fun addTaskCountListener(context: Context, listener: TorrentTaskCountListener) {
    try {
        val start = getTorrentModule(context)!!.javaClass.getDeclaredMethod( // line 120
            "addTaskCountListener",
            TorrentTaskCountListener::class.java
        )
        start.isAccessible = true
        start.invoke(obj, listener)
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

fun getTorrentModule(context: Context): Any? {
    if (obj == null) {
        initTorrentDownload(context)
    }
    return obj
}

fun initTorrentDownload(context: Context) {
    if (TorrentBridge.isLoaded()) {
        try {
            val clazz = Class.forName(TorrentBridge.CLASS_NAME_TORRENT_MODULE)
            val getInstance = clazz.getDeclaredMethod("get", Context::class.java)
            getInstance.isAccessible = true
            obj = getInstance.invoke(clazz, context)
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }
}

好像稀松平常,明显是line 120处getTorrentModule(Context)为空,改掉就可以了。

但是要注意的是这里用try-catch包裹住了,我们暂时抛开!!+try-catch在这里使用是否合理,单纯从道理来讲这个NPE应该是可以被catch住的。而且只有在release包上才会有这个问题。 然后大致先说一下这件事的始末:

  1. 我以为上面的代码就是崩溃的代码,遂查看字节码发现addTaskCountListener的调用在smali中不见了。
  2. 查看mapping.txt和usage.txt发现TorrentDownloadHelper.kt这个类被优化了
  3. 百思不得其解,在这儿困了一天,已经开始怀疑自己的知识体系。
  4. 后来同事说他强制把getTorrentModule()置为了null,所以被shrink了😭😭😭
  5. 后来查看正常包发现addTaskCountListener未被优化,找到真正的调用逻辑。

花了一天的时间查了个寂寞,心情五味杂陈,不过总归学到了点东西。

理解混淆的输出

Reading ProGuard's Outputs,这里有一篇简洁的文章来讲打包后关于seeds.txt / usage.txt / mapping.txt的由来和作用

  • seeds.txt 列出没有混淆的类和成员
  • usage.txt 列出从apk中删除的代码
  • mapping.txt 提供原文件对应混淆后的类、方法和字段名称

所以当我们遇到我们需要查看release包里面到底是什么样子的时候。我比较习惯直接apk拉到AndroidStudio中直接查看dex,就不用apktool了。

这个位置可以选择mapping.txt文件,AS帮我们做了一下转换可以不用查mapping找obfuscate后的abcxxx了。

找到对应的类或者方法后可以直接右键选择Show Bytecode, 之前写过一篇# 方法调用栈混乱引起的Proguard内联学习,有更详细的介绍,不熟悉的可以移步。

关于mapping.txt文件的格式解析可以查看# Android R8 mapping.txt文件解读

getTorrentModule()为null的情况

也就是这种情况,

kt 复制代码
fun getTorrentModule(context: Context): Any? {
    return null
}

所以在getTorrentModule()!!下就直接抛空了,看到有其他类似的例子,踩到一个R8代码压缩工具的坑

在smali中会看到下面的信息,具体指令可以查询Smali指令白皮书,后面也会找一段例子完全标注。

arduino 复制代码
    .line 18
    invoke-virtual {v0, p0}, Lorg/greenrobot/eventbus/EventBus;->register(Ljava/lang/Object;)V

    .line 19
    .line 20
    .line 21
    :cond_14
    invoke-virtual {p0}, Landroid/view/View;->getContext()Landroid/content/Context;

    .line 22
    .line 23
    .line 24
    move-result-object v0

    .line 25
    if-nez v0, :cond_1b

    .line 26
    .line 27
    return-void

    .line 28
    :cond_1b
    const/4 v0, 0x0

    .line 29
    throw v0
.end method

最后两行,创建了一个空对象,然后就直接throw了。

原因就是R8在shrink的时候发现这段代码后面的代码不会被执行到,并且只要执行到这里就必定为null,所以就直接省掉后面的代码直接抛出了一个空指针。

查看getTorrentModule()正常的情况

这里让我找了好久,上面提到的TorrentDownloadHelper.addTaskCountListener()也被内联掉了,但是具体的代码放到了com.google.android.play.core.splitinstall.uuz里面。

至于为什么叫uuz,是因为它本来就叫uuz。这个是谷歌的库,它提供给我们使用的aar里面就叫这个名字,它已经混淆过了。但实际上这个类没几行代码,但是proguard硬生生给塞了一堆inline代码进去,使得这个类在我们的工程里面看起来庞大无比。它足足有646680-645787=893个方法在里面。这个是我没想到的。

也就是标记①时候的Smali,完全标注,一行不漏:

bash 复制代码
.method public static com.mxtech.videoplayer.bridge.torrent.utils.TorrentDownloadHelper.addTaskCountListener(Landroid/content/Context;Lcom/mxtech/videoplayer/bridge/torrent/view/TorrentTaskCountListener;)V
    .registers 8

    .line 1
    :try_start_0
    sget-object v0, Lcom/google/android/play/core/splitinstall/zzu;->com.mxtech.videoplayer.bridge.torrent.utils.TorrentDownloadHelper.obj:Ljava/lang/Object;
# TorrentDownloadHelper.obj赋值给v0, 标记try_start_a [ 这个标记的作用可以看 line40,用于标记try-catch的范围 ]

    .line 2
    .line 3
    const/4 v1, 0x0

    .line 4
    const/4 v2, 0x1
# 初始化v1 v2, v1=0, v2=1

    .line 5
    if-nez v0, :cond_29
# v0不为空则跳转到cond_29,在下面的line 41,为空则继续走初始化

    .line 6
    .line 7
    sget-boolean v0, Lkotlin/jvm/internal/CollectionToArray;->com.mxtech.videoplayer.bridge.torrent.TorrentBridge.moduleLoaded:Z
    :try_end_8
    .catch Ljava/lang/Exception; {:try_start_0 .. :try_end_8} :catch_47
# 判断isLoaded(),boolean值变量结果存到v0,标记抛异常的范围。

    .line 8
    .line 9
    if-eqz v0, :cond_29
# 判断新布尔值v0, false跳到cond_29

    .line 10
    .line 11
    :try_start_a
    const-string v0, "com.mxtech.torrent.TorrentModule"
# v0存个字符串

    .line 12
    .line 13
    invoke-static {v0}, Ljava/lang/Class;->forName(Ljava/lang/String;)Ljava/lang/Class;
# 获取字符串类名指向的Class

    .line 14
    .line 15
    .line 16
    move-result-object v0
# 结果继续存v0

    .line 17
    const-string v3, "get"
# 字符串存v3

    .line 18
    .line 19
    new-array v4, v2, [Ljava/lang/Class;
# 上面line4 中 v2 = 1, 所以新建一个Class的数组长度为1,存到v4 

    .line 20
    .line 21
    const-class v5, Landroid/content/Context;
# Context.class存到v5

    .line 22
    .line 23
    aput-object v5, v4, v1
# 将v5存的Context.class值存到v4的数组中,index = v1, v1在line4中初始化为0

    .line 24
    .line 25
    invoke-virtual {v0, v3, v4}, Ljava/lang/Class;->getDeclaredMethod(Ljava/lang/String;[Ljava/lang/Class;)Ljava/lang/reflect/Method;
# 调用v0中存的Class(com.mxtech.torrent.TorrentModule)的getDeclaredMethod方法,传入两个参数,v3中存的字符串"get", v4中存的Context.class数组,返回值为Method对象

    .line 26
    .line 27
    .line 28
    move-result-object v3
# 返回值Method对象存到v3

    .line 29
    invoke-virtual {v3, v2}, Ljava/lang/reflect/AccessibleObject;->setAccessible(Z)V
# 调用v3的setAccessible方法,传入v2,0x1表示true

    .line 30
    .line 31
    .line 32
    new-array v4, v2, [Ljava/lang/Object;
# v4新建Object数组,size=1

    .line 33
    .line 34
    aput-object p0, v4, v1
# p0表示this指针,将p0存到引用位于v4的数组中,index偏移量为v1=0

    .line 35
    .line 36
    invoke-virtual {v3, v0, v4}, Ljava/lang/reflect/Method;->invoke(Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;
# 调用v3=Method对象的invoke方法,传参为v0=TorrentModule.class, v4=this自己

    .line 37
    .line 38
    .line 39
    move-result-object p0
# 返回的Object对象存到p0

    .line 40
    sput-object p0, Lcom/google/android/play/core/splitinstall/zzu;->com.mxtech.videoplayer.bridge.torrent.utils.TorrentDownloadHelper.obj:Ljava/lang/Object;
    :try_end_29
    .catch Ljava/lang/Exception; {:try_start_a .. :try_end_29} :catch_29
# p0赋值给TorrentDownloadHelper.obj,标记try_end_47,从标记try_start_a到标记try_end_29中间抛异常直接跳转到catch_29

    .line 41
    .line 42
    :catch_29
    :cond_29
    :try_start_29
    sget-object p0, Lcom/google/android/play/core/splitinstall/zzu;->com.mxtech.videoplayer.bridge.torrent.utils.TorrentDownloadHelper.obj:Ljava/lang/Object;
# 获取TorrentDownloadHelper.obj赋值给p0,标记try_start_29

    .line 43
    .line 44
    invoke-virtual {p0}, Ljava/lang/Object;->getClass()Ljava/lang/Class;
# 调用p0.getClass()

    .line 45
    .line 46
    .line 47
    move-result-object p0
# 结果存到p0, 此时p0存的是TorrentDownloadHelper.obj.class

    .line 48
    const-string v0, "addTaskCountListener"
# v0存字符串 "addTaskCountListener"

    .line 49
    .line 50
    new-array v3, v2, [Ljava/lang/Class;
# 创建一个Class数组,存到v3,长度v2=1

    .line 51
    .line 52
    const-class v4, Lcom/mxtech/videoplayer/bridge/torrent/view/TorrentTaskCountListener;
# v4存TorrentTaskCountListener.class

    .line 53
    .line 54
    aput-object v4, v3, v1
# 把v4 = TorrentTaskCountListener.class存到v3的Class数组中,index偏移量为v1=0

    .line 55
    .line 56
    invoke-virtual {p0, v0, v3}, Ljava/lang/Class;->getDeclaredMethod(Ljava/lang/String;[Ljava/lang/Class;)Ljava/lang/reflect/Method;
# 调用p0=TorrentDownloadHelper.obj.class的getDeclaredMethod方法,传参为v0="addTaskCountListener",v3=Class数组,返回Method对象

    .line 57
    .line 58
    .line 59
    move-result-object p0
# Method对象存到p0

    .line 60
    invoke-virtual {p0, v2}, Ljava/lang/reflect/AccessibleObject;->setAccessible(Z)V
# 调用p0=Method的setAccessible()方法,传参v2=true

    .line 61
    .line 62
    .line 63
    sget-object v0, Lcom/google/android/play/core/splitinstall/zzu;->com.mxtech.videoplayer.bridge.torrent.utils.TorrentDownloadHelper.obj:Ljava/lang/Object;
# 获取obj对象存v0

    .line 64
    .line 65
    new-array v2, v2, [Ljava/lang/Object;
# 创建size=1的数组存v2

    .line 66
    .line 67
    aput-object p1, v2, v1
# p1对象存到v2数组中,偏移量为v1

    .line 68
    .line 69
    invoke-virtual {p0, v0, v2}, Ljava/lang/reflect/Method;->invoke(Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;
    :try_end_47
    .catch Ljava/lang/Exception; {:try_start_29 .. :try_end_47} :catch_47
# 调用p0=Method对象的invoke方法,传参v0, v2,标记try_end_47,从标记try_start_29到标记try_end_47中间抛异常直接跳转到catch_47

    .line 70
    .line 71
    .line 72
    :catch_47
    return-void
# 执行结束,无返回值
 
.end method

这里把上述的Smali代码一行不漏的注释了一下,其实这样看来其实Smali其实并不难理解和阅读。大家后续查看的时候也可以直接查看Smali,迫不得已的时候可以用jd-gui翻译。

关于mapping中的RewriteFrame. one moe thing.

有时候我们在mapping文件中我们能看到像这样的信息,这其实也是我们关注的代码被内联的

java 复制代码
some.Class -> a:
  4:4:void other.Class.inlinee():23:23 -> a
  4:4:void caller(other.Class):7 -> a\n"
  # { id: 'com.android.tools.r8.rewriteFrame', "
      conditions: ['throws(Ljava/lang/NullPointerException;)'],
      actions: ['removeInnerFrames(1)'] }

r8的文档上R8, Retrace and map file versioning,我们能看到用法。

RewriteFrame信息表示retrace工具在异常回溯到这一帧代码的时候需要重写一下,有以下的信息:

java 复制代码
# { id: 'com.android.tools.r8.rewriteFrame', "
      conditions: ['throws(<exceptionDescriptor>)'],
      actions: ['removeInnerFrames(<count>)'] }

很明显,规定了当发生throws(<exceptionDescriptor>)这种情况的时候需要采取removeInnerFrames(<count>)这种对应的措施。

  • throws(<exceptionDescriptor>): 将会为true,如果发生这种 <exceptionDescriptor>

可以通过向列表添加更多项目来组合条件。添加多种条件是实现了AND,如果要实现OR就应该复制多条信息,而不是添加多个条件。

  • removeInnerFrames(<count>):将从最内层帧开始删除帧数。指定高于所有帧的计数是错误的。

下面举一个例子,如果抛出NPE异常,就删除部分内联的代码:

kt 复制代码
some.Class -> a:
  4:4:void other.Class.inlinee():23:23 -> a
  4:4:void caller(other.Class):7 -> a\n"
  # { id: 'com.android.tools.r8.rewriteFrame', "
      conditions: ['throws(Ljava/lang/NullPointerException;)'],
      actions: ['removeInnerFrames(1)'] }

如果没有RewriteFrame,崩溃栈应该是下面的样子:

java 复制代码
Exception in thread "main" java.lang.NullPointerException: ...
  at other.Class.inlinee(Class.java:23)
  at some.Class.caller(Class.java:7)

使用上述内联信息修改最后一个映射会指示回溯器丢弃上面的帧,从而产生回溯结果:

java 复制代码
Exception in thread "main" java.lang.NullPointerException: ...
  at some.Class.caller(Class.java:7)

rewriteFrame仅当正在回溯的行直接位于异常行下方时,才会应用该信息。

参考:
# 理解混淆的输出
# 踩到一个R8代码压缩工具的坑
# Android逆向基础:Smali语法
# Android R8 mapping.txt文件解读

相关推荐
姑苏风20 分钟前
《Kotlin实战》-附录
android·开发语言·kotlin
数据猎手小k4 小时前
AndroidLab:一个系统化的Android代理框架,包含操作环境和可复现的基准测试,支持大型语言模型和多模态模型。
android·人工智能·机器学习·语言模型
你的小104 小时前
JavaWeb项目-----博客系统
android
风和先行5 小时前
adb 命令查看设备存储占用情况
android·adb
AaVictory.5 小时前
Android 开发 Java中 list实现 按照时间格式 yyyy-MM-dd HH:mm 顺序
android·java·list
似霰6 小时前
安卓智能指针sp、wp、RefBase浅析
android·c++·binder
大风起兮云飞扬丶6 小时前
Android——网络请求
android
干一行,爱一行6 小时前
android camera data -> surface 显示
android
断墨先生7 小时前
uniapp—android原生插件开发(3Android真机调试)
android·uni-app
无极程序员8 小时前
PHP常量
android·ide·android studio