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

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

事发场景

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)
       ...

报错代码如下:标记①

# 调用者,类名: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的由来和作用

1.png

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

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

proguard.png

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

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

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

getTorrentModule()为null的情况

也就是这种情况,

fun getTorrentModule(context: Context): Any? {
    return null
}

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

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

    .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,完全标注,一行不漏:

.method public static aaa.bbb.ccc.TorrentDownloadHelper.addTaskCountListener(Landroid/content/Context;Lcom/a/b/c/d/e/TorrentTaskCountListener;)V
    .registers 8

    .line 1
    :try_start_0
    sget-object v0, Lcom/google/android/play/core/splitinstall/zzu;->aaa.bbb.ccc.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;->aaa.bbb.ccc.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, "aaa.bbb.ccc.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(aaa.bbb.ccc.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;->aaa.bbb.ccc.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;->aaa.bbb.ccc.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, Laaa/bbb/ccc/ddd/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;->aaa.bbb.ccc.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文件中我们能看到像这样的信息,这其实也是我们关注的代码被内联的

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工具在异常回溯到这一帧代码的时候需要重写一下,有以下的信息:

# { 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异常,就删除部分内联的代码:

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,崩溃栈应该是下面的样子:

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

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

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

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

总结:

代码总是要被打到dex里按照字节码来执行,Android是基于寄存器的虚拟机。
崩溃栈有时候会跟我们看到的不一样,我们参照以下的原则来查看crash,肯定能水到渠成。

  1. 一般情况,直接查看代码,崩溃栈跟现有代码清晰一致,皆大欢喜。
  2. 出现崩溃栈跟现有代码对不上,在obfuscate阶段肯定发生了内联,先去usage.txt里查看“嫌疑人代码”有没有被内联掉
  3. 如果发生内联,去mapping.txt里面查找被内联到了哪里,可能是同一个类,也可能是不同的类。
  4. 去dex中查看真正的代码逻辑,肯定是能跟崩溃栈对的上的。

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,835评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,598评论 1 295
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,569评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,159评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,533评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,710评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,923评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,674评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,421评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,622评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,115评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,428评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,114评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,097评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,875评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,753评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,649评论 2 271

推荐阅读更多精彩内容

  • 小酌鸡汤 人生在勤,不索何获。 本文来源《Android 性能优化 全家桶》 什么是APK 分析器 ?  Andr...
    科技猿人阅读 778评论 0 4
  • 一、瘦身优化及 Apk 分析方案介绍 1.1 瘦身优势 我们首先来介绍下,为什么我们需要做 APK 的瘦身优化? ...
    凯玲之恋阅读 645评论 0 0
  • 入口 为了决定哪些代码要被保留哪些代码要出丢弃和混淆,必须指定入口点。这些入口点通常是 main方法,activi...
    佛系编码阅读 605评论 0 0
  • 小酌鸡汤 古人学问无遗力,少壮工夫老始成。 本文来源《Android 性能优化 全家桶》 ProGuard 和 R...
    科技猿人阅读 3,715评论 0 5
  • ProGuard入门 简单过程 shrinker:检测和移除无用的类、方法、变量和属性 optimizer:优化代...
    Geek帆哥阅读 587评论 0 0