关于一个平平无奇的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文件解读

相关推荐
Devil枫2 小时前
Kotlin高级特性深度解析
android·开发语言·kotlin
ChinaDragonDreamer2 小时前
Kotlin:2.1.20 的新特性
android·开发语言·kotlin
雨白12 小时前
Jetpack系列(二):Lifecycle与LiveData结合,打造响应式UI
android·android jetpack
kk爱闹14 小时前
【挑战14天学完python和pytorch】- day01
android·pytorch·python
每次的天空16 小时前
Android-自定义View的实战学习总结
android·学习·kotlin·音视频
恋猫de小郭16 小时前
Flutter Widget Preview 功能已合并到 master,提前在体验毛坯的预览支持
android·flutter·ios
断剑重铸之日17 小时前
Android自定义相机开发(类似OCR扫描相机)
android
随心最为安17 小时前
Android Library Maven 发布完整流程指南
android
岁月玲珑17 小时前
【使用Android Studio调试手机app时候手机老掉线问题】
android·ide·android studio
还鮟21 小时前
CTF Web的数组巧用
android