Dalvik方法调用的字节码分析——逆向安卓 QQ 时小问题引起的思考
QQ 出了表情骂人的 bug 后,随手逆了一下,过程中发现有些调用过程颠覆了我的三观,出现了同名同参数、返回值不一样的两个方法,这样代码编译都不过,为什么会正常运行呢?
一、背景知识:
1、Java 开发的过程中,不可以出现同名通参数、返回值不一样的两个方法
举个例子,下面的代码
1 | void fun(){} |
这样在fun调用时候是不知道返回类型的,程序语言的开发者肯定不会出现这种愚蠢的行为,所以编译阶段就被报错,重复定义。
2、假设是混淆器将两个本来不一样的函数,函数名都混淆为了a
Progrard 默认肯定不会这么干的,假设是 QQ 开发者修改了混淆器吧,我猜程序运行过程中肯定不是根据符号来定位的,不然 QQ 早就跑挂了。 但又来了一个问题,使用 Xposed 进行 hook 时候是无法区分它们的,使用反射获取方法时也是无法区分它们的,这个该怎么解决? 解决方法其实是有的,就是反射拿到 Method 列表,就可以拿到 Method 的 object,再去 hook。
3、难道是 JEB 和 jadx 反汇编错了吗?
于是使用 Xposed 注入一下,反射打印所有方法的名字,发现同名函数并不是幻觉,确实是仅仅是返回值不同而已。
4、这就很尴尬了,这种代码居然可以正常运行?一旦反射,鬼都找不到吧?可能他就没有用到反射?
二、invoke-virtual字节码的分析
先丢两个官方链上来: dex 文件格式:https://source.android.cn/devices/tech/dalvik/dex-format 字节码定义:https://source.android.google.cn/devices/tech/dalvik/dalvik-bytecode 找了一下手头的工具,没有人写过从 dalvik bytecode 到 smali 的好用的轮子,于是主要用 IDA 来帮我们,JEB 并不能展示出每条指令对应的 bytecode。 步入正题,建议先去读一下字节码定义,直接跳到6e这个操作数,看一下定义:
A | B | C |
---|---|---|
6e..72 35c | invoke-kind {vC, vD, vE, vF, vG}, meth@BBBB 6e: invoke-virtual 6f: invoke-super 70: invoke-direct 71: invoke-static 72: invoke-interface |
A: argument word count (4 bits) B: method reference index (16 bits) C..G: argument registers (4 bits each) |
这里多出来一个 A,其实在整个介绍最开始,说过 vAA 是紧跟在opcode后面的目标寄存器,这里稍微脑补一下,其实 A 就在 opcode 后面,因为每个 opcode 都有 A,开发者懒得写了。 整个结构内存布局看起来是 0x6e + len(args) + argList + methodIdx,但其实并不是。
这就涉及到了第三个更恶心的文档。 指令格式:https://source.android.google.cn/devices/tech/dalvik/instruction-formats 不知道你们能不能看懂,反正我是看不懂,硬着头皮脑补一下吧。 6e这条指令格式是"35c", 3表示16bit 单元的数量, 5表示指令最多能接受的寄存器个数, c 表示引用, 这特喵是什么鬼,经过反反复复反反复复反反复复的阅读,终于明白了,表示指令长度是3个 unit,每个 unit 是16bit 也就是 short,其中最多有5个是寄存器。
A | B | C |
---|---|---|
`A | G | op BBBB F |
看到这里,是不是感觉有点晕,下面将是测试环节,这个更坑,就跟猜猜猜一样。
说个常识,函数调用的参数个数是255,寄存器个数上限是65535。
下面几个 testcase
1 | int main(){ |
经过反汇编,第一个调用是 6E 10 99 37 0A 00
连蒙带猜,6e是opcode,0x10中的1表示有1个参数,0x3799是方法的 index,0x0A 中的 A 表示第A 个寄存器,wtf,哪来的第 A 个。这时候观察一下,其实方法声明时候说这个方法有9个变量的寄存器,顺延一下,thisObject 就是第 A 个,如果有参数的话,参数就是第 A+1、A+2个了。
这里就又有一个小知识了,其实无参的函数调用,会默默传递一个thisObj过去,这个在 java 代码里、反射代码、method 定义里都是没有体现的,只是在invoke-virtual 里有体现。
(其实猜这个很难猜的,需要对比很多其他的、以及修改变量、中间踩的坑全都跳过)
再看func1, 6E 20 9A 37 0A 00
这个跟上面只差了一丁点,0x6e没变,0x10变为0x20,表示有2个参数,0x379A是方法的 index,0A00也没有变,因为这里使用的是第0个和第 A 个寄存器,3个0里面有1个0是叛徒。。。具体顺序以后再说
func2,6E 30 9B 37 0A 01,很好理解吧
func3,6E 40 9C 37 0A 21,很好理解吧
func4,6E 53 9D 37 0A 21 看看这个满载的方法,这下可以呼应官方文档里写的东西了,仔细分析一下哈。
A|G|op BBBB F|E|D|C
第一个short 0x6e53,应该是 A=5,G=3;
第二个short 0x379d,BBBB=0x37d9;
第三个short 0x210A,C=0xA, D=0, E=1, F=2;
将它们代入 invoke-kind {vC, vD, vE, vF, vG}, meth@BBBB
意思就是调用BBBB方法有5个参数,分别是v10, v0, v1, v2, v3,v10也就是p0。
由于它是按照16bit 来拆分各个 unit 的,这也印证了为什么参数顺序看起来非常乱,总之现在是比较清晰了。
但是,如果参数很多呢?
例如
1 | func6(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17); |
最多就5个参数,但 Java 肯定可以有很多,这里直接贴IDA 里的反汇编吧,一下就懂了74 12 9F 37 00 00
1 | CODE:00103B7E invoke-virtual/range {v0..v17}, <void MainActivity.func6(int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int) MainActivity_func6@VIIIIIIIIIIIIIIIII> |
这里有个叫invoke-vertual/range
的东西,可以指定寄存器的范围,在这之前把各个变量放到连续的寄存器里即可,当然连opcode都不一样。
三、初步结论
好了,字节码级别的调用就分析完了,结论是, 在运行过程中,dvm 是不关心方法签名的,只管被调用的方法位于当前 dex 的 method_id_list
中的哪个位置,用的时候直接跳过去
【2018.6.5更新】 dvm 直接获取到的 method_id_list 中的某个位置,拼接一个完整的函数签名,寻找后定位函数的opcode。 但是,这个不一定准确,因为本文基本是在打嘴炮,并没有看 dvm 加载过程;更何况现在都是 art 时代,优化后长什么样仍然不清楚。自我感觉我的猜想还是没错的。
四、疑点
1、对于单个 dex,就算不根据签名,也可以根据下标来寻找到;但如果是动态加载其他的 dex 文件,在 dexA 里调用了dexB里的一些方法,肯定就不是通过下标来寻找了。那么是通过什么样的方式寻找到的?这就留下一个很大的问题~
更新一下这个 小问题,和 tom 同学的交流后发现了我漏看的地方。回归到 invoke-virtual 上,参数是 index,实际上是 method_ids[index]
,而method_id里存放的内容包括classIndex(声明的类)、protoIndex(完整的定义)、nameIndex(方法名)。也就是说可以定位到一个独一无二的方法签名,之后再去寻找方法的真正位置。
这种解释也符合预期,QQ 同样可以区分出两个同名同参的方法,没有任何问题。
之后编译了 multidex 出来,在 dex1里发现method_ids_list其实是包含了一些不在 dex1里、在 dex2里的方法定义。也就是说一个方法能不能被找到,并不是看它在不在 method_ids_list 里被描述,而是判断加载过的这个类里是否声明了这个方法。
再说一句比较绕的话,每个 dex 的 method_ids_list 由两部分组成,一部分是当前 dex 声明的 class 里的方法,一部分是当前dex 声明的 class 里的方法执行体里要调用到的方法。dex 里声明的 class 的method_id、执行体一定都在当前的 dex 里。
2、同样,动态加载 dex 时候,dexA 里定义了 void func(),dexB 里定义了 int func(),而 dexC 里去反射调func(),拿到的是谁?
好了,有空的话再搞搞这两个疑点,本文到这里就结束了~~~嘻嘻~(*≧∪≦)