同事遇到一个问题找我来看,是一个空指针的问题,看起来样子平平无奇。
事发场景
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包上才会有这个问题。 然后大致先说一下这件事的始末:
- 我以为上面的代码就是崩溃的代码,遂查看字节码发现
addTaskCountListener
的调用在smali中不见了。 - 查看mapping.txt和usage.txt发现
TorrentDownloadHelper.kt
这个类被优化了 - 百思不得其解,在这儿困了一天,已经开始怀疑自己的知识体系。
- 后来同事说他强制把
getTorrentModule()
置为了null,所以被shrink了😭😭😭 - 后来查看正常包发现
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文件解读