前言
今天对某外国的机器中的某个音频解码库进行反汇编,发现了一些有趣的知识,故而记录下来,以防以后重复遇到。
现笔者使用的反汇编工具为“Ghidra”。可在GitHub上下载:NationalSecurityAgency/ghidra: Ghidra is a software reverse engineering (SRE) framework
Ghidra是由美国国家安全局研究局创建和维护的软件逆向工程 (SRE) 框架 。该框架包括一套功能齐全的高端软件分析工具,使用户能够在包括 Windows、macOS 和 Linux 在内的各种平台上分析编译代码。功能包括反汇编、汇编、反编译、绘图和脚本,以及数百个其他功能。
Ghidra由java编写,因此使用Ghidra前先配置java运行环境。
注:本文不介绍Ghidra的使用
adpcm_decoder1函数
C代码
笔者通过获取机器日志,发现了机器存在音频解码函数,通过grep命令查询到了,存有相应解码函数的库文件,并将库文件pull了出来,我们来看一下该库文件中相应的解码函数:
|
|
以上是反汇编后的结果,Ghidra将机器语言解析成汇编语言,再自动转换成相应的C语言。
Decryption函数并非ADPCM的一部分
可以看到,这是典型的ADPCM音频解码算法,笔者已经对比过经典的开源ADPCM音频解码算法的C语言版本,确实是相差无几,但在这段代码中,多出了以下内容:
|
|
在解释这一段内容前,读者需要先了解:该机器通过蓝牙接收语音数据,每语音帧20字节。但机器处理不一定是20字节一处理,根据音频收发的速率,丢包率以及buffer的大小决定。
现在笔者来解释一下这一段的内容:
- param_3除以20,将结果赋值给iVar3变量,这里计算出一共进来多少语音帧需要处理,每帧20字节
- 一个for循环,将每帧数据丢入
Decryption函数中进行处理 - Decryption函数接收两个参数,0x32,一个固定的常量值;param_1 + iVar6 * 0x14,param_1是语音数据的数组指针
Decryption函数的参数问题
这里解释一下Decryption函数接收的两个参数,实际上,Decryption函数接收三个参数,在这里,Ghidra在C中只给出了两个参数,笔者不清楚这是Ghidra的特性还是工具缺陷。对汇编不感兴趣的同学可以跳过这一部分,但推荐看看
下面会给出Decryption的函数体,在函数体中可以发现Decryption是接收三个参数的,这里我们讲解一下这一段for循环的汇编内容
C语言:
1 2 3for (iVar6 = 0; iVar6 < iVar3; iVar6 = iVar6 + 1) { Decryption(0x32,param_1 + iVar6 * 0x14); }汇编:
1 2 3 4 5 6 7 8 9 10 11 12LAB_00016624 00016624 bc 42 cmp r4,r7 00016626 09 da bge LAB_0001663c 00016628 14 22 movs r2,#0x14 0001662a 62 43 muls r2,r4 0001662c 01 9b ldr r3,[sp,#local_2c ] 0001662e 32 20 movs r0,#0x32 00016630 9a 18 adds r2,r3,r2 00016632 11 1c adds r1,r2,#0x0 00016634 ff f7 38 ff bl Decryption 00016638 01 34 adds r4,#0x1 0001663a f3 e7 b LAB_00016624这是这个for循环中涉及到的汇编语言,我们尝试在这段汇编中找到Decryption的三个参数
cmp指令表示比较,即比较r4与r7寄存器中的值,结合c代码,可以看出,r4,r7分别代表了iVar6和iVar3,但目前不清楚哪个对哪个。bge指令是分支指令,表示条件跳转(branch if greater or equal),即如果大于等于,则跳转。这两个指令合起来为,如果r4≥r7,则跳转到LAB_0001663c代码段,可以看出,已经超出了这个for循环的地址,即退出了for循环。movs指令表示移动,将立即数0x14移动到r2寄存器中,现在r2寄存器中存储了0x14这个值。muls指令表示乘法,即将r2与r4寄存器中的值相乘,结合c代码可以看到,代码中仅有一个乘法,即iVar6 * 0x14,可以得出,r2 = 0x14,r4 = iVar6。ldr指令表示加载寄存器(load register),语法为:ldr <寄存器>, [<基址寄存器>, <偏移量>],从基址寄存器中取出地址,加上偏移量,将结果加载到寄存器中,这里取出的地址加载到r3中,在c代码中,只有一个涉及到取址操作,即param_1,这是一个adpcm_decoder1函数接受的传参,传入的是一个指针,即param_1其实是一个数组指针。这里是获取param_1数组的地址,放到r3中。movs指令表示移动,将立即数0x32移动到r0寄存器中,现在r0寄存器中存放了0x32这个值。adds指令表示加法,将r3与r2相加,并存放到r2寄存器中,即,param_1的地址加上一个计算的偏移量,本质上是从param_1的语音数据中获取第n个语音帧的地址。adds指令表示加法,将r2寄存器的值加上立即数0x00存放到r1寄存器中,这操作让r2寄存器存放的内容与r1一致。bl指令表示跳转,即跳转到Decryption函数的位置去,执行Decryption函数,并把下一条指令的地址存放到lr寄存器中,以方便Decryption函数执行完能回来,相当于压栈,将Decryption函数压入函数栈。adds指令表示加法,将r4加上立即数0x01,并存放到r4中,即for循环的++操作。可以看到,在调用Decryption函数前,分别给三个寄存器存放了东西,分别为
r0存放了0x32,r1存放了param_1的偏移地址,r2存放与r1相同的东西。这三个寄存器就是Decryption函数用的三个参数。如果还不确定,可以跳转到Decryption函数中查看相应的param_1,param_2,param_3三个参数调用时所使用的寄存器,分别为r0,r1,r2,本文给出结论,不做赘述。
小结
总而言之,这里的音频解码,虽然使用的是ADPCM算法,但是在raw data与adpcm data之间还加入了一个加密解密过程,即,实际上的过程为:
原始音频数据raw data → adpcm压缩→ 加密算法 encryption → 蓝牙传输 → 解密算法 decryption → adpcm解压 → 原始音频数据 raw data
因此,接下来看如何用解密算法还原加密算法
decryption函数
C代码
下面是decryption函数
|
|
笔者依次解释上面的代码
数据本地化
|
|
这一段代码其实很好看出是在做什么,就是将传入的数据存在函数内部的局部变量中,iVar3变量是一个很重要的变量,在后续计算中会用到,记住它。在将所有数据搬完之后(一共20个字节,一语音帧20个字节),iVar3变量来到了0x13,十进制19
第19到1的数据解码
|
|
这里有一个很容易混淆的地方,就是标题采用的“第19到第1”这个概念,我们只能说,从while循环,从iVar3变量的角度,是“从19到1”,而从数据的角度说并不是这样。为了强化读者这个概念,我们先介绍local_30[*(int *)(&DAT_00019c0c + iVar4)]与local_30[*piVar1]这两个变量。
local_30[*(int *)(&DAT_00019c0c + iVar4)]
首先,从定义上看byte local_30 [20];,local_30是一个数组的局部变量,且具有20个元素的大小,每个元素的大小为1byte,即,该数组是专门存放音频数据的。
其次,我们先看local_30[*(int *)(&DAT_00019c0c + iVar4)],数组的内部index部分很复杂,我们来做介绍
&DAT_00019c0c是内存中的一个地址&DAT_00019c0c + iVar4表示这个地址 + 一个偏移量,组成一个新的地址(int *)(&DAT_00019c0c + iVar4)表示将这个地址被强制类型转换为int类型的指针*(int *)(&DAT_00019c0c + iVar4)表示取值这个int指针,即从这个地址中取值,这个值是int类型的
然后,我们来看这个iVar4变量,iVar4 = iVar3 * 4,在每一次循环中,iVar4都会被更新一次,且更新为iVar3 * 4,iVar3在每次循环中会被做-1操作。也就是说,第一次iVar4 = 19 * 4,第二次为iVar4 = 18 * 4,以此类推。
最后,我们查查这个DAT_00019c0c,以及它对应的偏移量中究竟存了什么东西,通过Ghidra的汇编地址中可以查到:
|
|
0x00019c0c地址存放的数据为0x00,隔4个字节后,0x00019c10地址存放的数据为0x0c……一直到19 * 4个字节,即0x00019c58存放的数据为0x08,通过查询并列出发现,其数据依次为:
0x0 0xc 0xd 0x3 0x4 0x9 0xa 0xb 0x10 0x11 0x12 0x13 0x5 0x6 0xe 0xf 0x1 0x2 0x7 0x8
刚好是0~19,即根据通过’iVar3’的依次从19到1的变化,local_30数组依次参与解码的数据为8,7,2,1,…,4,3,13,12,0,而非按顺序解码。
local_30[*piVar1]
在这里我们可以看到,该变量依然是local_30数组中的元素,只不过其index表示为*piVar1。
看piVar1 = &DAT_00019c08 + iVar3; 一些敏锐的同学可能注意到,在我们介绍的前一个变量中,地址都是四个字节一偏移,在内存地址中查询数据的分布,也是四个字节存一个数据,其它地方都是0,而按照我们的理解,piVar1 = &DAT_00019c08 + iVar3 * 4;似乎才是正确的。我们查看一下相应的汇编代码:
|
|
这一段代码,分别是做了*(int *)(&DAT_00019c0c + iVar4)的计算以及piVar1 = &DAT_00019c08 + iVar3的计算,我们来解释一下:
ldr指令表示加载寄存器,先计算r5与r7寄存器相加的值,再传给r6寄存器,ghidra已经给出了第一次循环中指向的地址,即0x00019c58subs指令表示减法,将r5寄存器中的值减去立即数0x04并传给寄存器r5- 与第一条指令相同,且
ghidra给出了第一次循环中指向的地址,即0x00019c54
这样可以看出,实际上,寄存器r5与r6都是读地址值且相差仅有4个字节。也就是说,piVar1比&DAT_00019c08 + iVar3 * 4仅仅相差了4个字节,例如:在第一次循环中,&DAT_00019c08 + iVar3 * 4中的值为0x8,piVar1中的值为0x7,在最后一次循环中,&DAT_00019c08 + iVar3 * 4的值为0xc,piVar1的值为0x0。
为什么C代码会与汇编相差如此之大呢,第一,在GCC编译C语言的过程中,本身会对代码进行优化,即调整指令顺序,甚至优化掉部分操作,以提升效率;第二,从已经编译过的二进制文件反推出C语言代码本身就是非常难的一件事情,正如数学中,求导的难度往往比求原函数的难度要低。所以有些细微的差池是可能的,因此在逆向工程中,除了要看C代码,还要检查汇编代码以及内存的走向。
解码过程
现在我们了解了那两个复杂的变量,现在我们来讲一讲解码的算法
|
|
在这条运算中,我们可以了解到local_30[*(int *)(&DAT_00019c0c + iVar4)]表示当前解码运算的数据,local_30[*piVar1]表示下一个解码运算的数据,例如:当*(int *)(&DAT_00019c0c + iVar4)=8时,*piVar1 = 7,这样上式就变成了:
|
|
依次类推,到最后一次循环时,上式变成:
|
|
我们假设,存在一个table[20]
|
|
那么整个while循环可以改写成:
|
|
这样,读者应该可以理解,我们从iVar3的角度上来讲,是“从第19到1的解码”,但从数据实际的解码顺序来讲,其实是“8,7,2,1,15,14,6,5,19,18,17,16,11,10,9,4,3,13,12”的顺序解码,0在后面单独解码。
这19个数据,每一个解码,都依赖于前一个还未解码的数据,即当解码第8个数据的时候,需要依赖第7个数据,当解码第12个数据时,需要依赖第0个数据。
现在我们仔细讲一下式子,在该式子中, bVar2 = (byte)iVar3;即,bVar2与iVar3相同,从19到1根据循环次数递减,param_1是一个常量,为0x32,是从adpcm_decoder1中传进来的第一个参数,如果不记得可往上翻阅。
在这条式子中,既有加法,又有异或运算,那么问题来了,该怎样计算呢?或者说,先算哪个呢?
这时熟悉C语言的同学们会说,在C语言中,加减法的优先级高于异或运算的优先级,因此先计算local_30[table[iVar3]] + local_30[table[iVar3-1]],再计算 ^ bVar2 ^ param_1,答案是没错的,在不同的语言中,异或运算与加减法的优先级可能会有所不同,因此这里的运算优先级要额外注意,但同理,在逆向工程中,不要过分相信逆向工具给你的C代码,它不一定是你想象的那样,因此我们还是来看一下它的汇编代码
|
|
因为式子中有一个“+”和两个“^”,因此我们只要着重看“add”指令与“eor”指令即可。
adds r2,r3,#0x0:这条指令并不是加法指令,而是将r3寄存器中的值赋值给r2寄存器,这个寄存器中实际存了什么值呢,我们看后续有一个subs r3,#0x1,因此可知,r3寄存器是在循环中递减的,这符合bVar2的特征,因此可以断定,r2寄存器存储的实际上就是bVar2。eors r2,r0:r2寄存器与r0寄存器的值做异或运算,将结果存放到r2中,在我们之前提到,r0寄存器存储的是param_1传参,即0x32固定参数,因此这条指令实际上就是bVar2 ^ param_1。ldrb r2,[r1,r5]:指令表示加载寄存器,r5与r6两个寄存器的内容我们上面分析过,[r1,r5]表示目标内存地址,这个地址是通过将寄存器r1与r5的值相加得来的,r1为param_2,即数据数组指针,r5为上面分析时讲到的乱序查表得到的偏移量。两者相加则索引到对应的数据。将其存放在r2中,下面ldrb r5,[r1,r6]一样。mov r12 ,r2:该指令将r2寄存器中的值转移到r12寄存器,因此,现在r12与r2的内容一样。add r5,r12:该指令将r12与r5相加,即式子中的两个local_30相加,并将结果存放到r5中。eors r5,r2:将r5寄存器与r2寄存器中的值做异或运算。
通过汇编指令,我们可以推算出运算的优先级为:
$$ r1 = bVar2 \oplus param_1\\ r2 = local\_30[*(int *)(\&DAT\_00019c0c + iVar4)] + local\_30[*piVar1]\\ res = r1 \oplus r2 $$第0个数据的解码
|
|
第0个数据不依赖于任何其它的数据,仅靠自身便可完成解码
其对应的汇编代码也很简单,如果读者理解了上面的过程,那么理解这一段汇编代码也是轻而易举,在此笔者不做过多赘述
|
|
返回数据
|
|
在此过程中,通过while循环将local_30中已经完成解码的数据全部放到param_3参数中,完成了数据的返回(因为param_3是一个指针,因此对指针数据做修改在函数退出时修改依然成立,而不会随着函数出栈而消失)。
总结
在这个反汇编的过程中,我们通过C代码与汇编代码的结合,还原出了解码过程的真实情况,因此,在反汇编中除了看反汇编工具提供的c语言代码,也需要看原来的汇编代码。所以,在逆向工程中,学会阅读汇编代码对你的帮助时巨大的,会避免因编译优化等问题带来的困扰,同时,笔者推荐读一读《深入理解计算机系统》,会让你对C和逆向有更深层次的理解,最后祝你变得更强!