最近刚接触到Android的C++编译出的.so文件崩溃,从后台上报系统能得到ARM64平台下发生crash时pc寄储器地址和函数名称,但没有行号,以及当时栈帧对应的31个寄储器的值,,第一次用IDA调试,边用边百度,不到2小时就上手,很就查出野指针问题了。本文引入两个实战,一个野指针Crash,一个空指针Crash。
由于以前做windows多,有C++反汇编基础,也有STL略底层点知识,只是没有用过IDA。win32指令简单一些,先介绍一下win32反汇编的一些知识,只要简单学习一下,就可以很快扩展到arm64平台中,两者很相通。
win32平台下10个寄储器,加粗的比较重要
- EAX, EBX,ECX,EDX 通用寄储器,
- ESI,EDI ,索引寄储器
- EIP ,指令指针寄储器
- ESP,EBP ,栈寄储器,ESP表标栈顶指针
- EFL,标志寄存器
Win32常用指令:只取常用的,也不强记,不过用的多就能记住了,平时可以随时查
- mov 传送指令
- push 压栈指令
- pop 出栈指令
- lea 取地址
- jmp 无条件跳转
- je 相等跳转
- jle 小于跳转
- call 函数调用
- ret 返回
- cmp比较指令
- add 相加
- sub 相减
- inc 加1
- mul 相乘
- div 相除
- and 与运算
- or 或运算
- xor 异或运算
- not 取反
- test 测试
- 其它他有很多指令,就单纯跳转就有很多,不再一一列举了。
上面的随意可以百度到,下面来说些对刚接触的来说相对比较有价值点或者比较难搜索到的:
1.要深入理解点栈,栈对汇编来说非常非常重要,也是函数调用关系的基础。一些高级用法,比如协程,比如自定义子程序调度,都离不开这个和跳转指令。栈可以理解为一个固定大小的地址空间,win32没有记错是1M,当然也可以在VS编译器设置。esp表示栈顶指针,最开始esp,ebp相同的,栈的内存增长和一般的不同,是向下生长,先进后出,就是push进去,地址越来越小,pop出来,地址变大。
2.EAX常用作函数返回值,非常重要
3.ECX常用作C++普通成员函数函数第一个参数,也就是this指针,C++代码编写时默认隐了this指针,编译器生成代码时,第一个参数是this*,这个参数是不能省掉的,这种也有人称为this call。
4.如果寄储器不够传参时,会用栈来传参
//关于2,3,4验证,VS2019 debug版本,Release会优化,不利于初始学习
5.EIP指向下一条指令代码,不能直接修改,可以用call,jmp,ret等修改
6.理解Push ,Pop 指令的等效过程
push eax;
// 等同两条伪指令
1.mov esp,eax; // 将eax 放到esp中,伪指令,仅供理解
2.sub esp,4; // 将esp减4,因为栈向下生长的,伪指令,仅供理解
pop eax;
// 等同两条伪指令
1.mov eax,esp; // 将esp 放到eax中,伪指令,仅供理解
2.add esp,4; // 将esp加4,因为栈向下生长的,伪指令,仅供理解
7.深入理解call,jmp,ret指令的等效过程
call 内存/立即数/寄储器;
//1.原下个EIP对应指令地址入栈;(esp-4)
//2.修改EIP为新的;
//3.跳转EIP执行;
jmp 内存/立即数/寄储器;
//1.修改EIP为新的;
//2.跳转EIP执行; 注:无条件执行,不改变栈
ret;
//1.修改EIP为新的栈顶数据;
//2.esp +4;(恢复栈)
//3.跳转EIP执行;
8.为什么有时看到函数反汇编开头会push ebp,push ebx,push esi等?
答:因为为了保护这些寄储器的值在函数调用结束不被错误修改。如果研究的比较深,比如函数加上__declspec(naked)修饰 ,或者完全自己写汇编,的确是可以不进行这样操作,只要能维护好就行。下面给个简单伪代码,举例为什么push edi
int a = fun1()+fun2(1,2);
// 伪代码,edi用来保存fun1()的结果,然后与fun2()返回值相加,所以edi最好要在fun2内部过程保护好
call fun1_address; // 调用fun1()
mov edi eax; // fun1()返回值放到edi中
push 2;
push 1;// 放入参数(1,2)
call fun2_address; // 调用fun2(1,2)
add edi eax; // 执行fun1()+fun2(1,2),并放到edi中
mov ptr [a] edi; // 放到a对应栈内存中
9.C++虚函数的与反汇编
我们知道如果一个class没有虚函数时,开始的第一个字段为第一个成员,但有了虚函数,第一个字段就变成虚表指针的地址,如果有多种继承与虚继承,这个复杂些,加入各个vbptr以及各个的vfptr,平同编译器策略还是不同的,限于篇幅与主题,这个不细讨论了,有兴趣可以自己写个demo调试一下。这里先以VS2019平台编译的win32分析
mainclass
我们可以借助VS自带工具查看类的内存分布,tools->command line->Developer Command Prompt打开,输入cl -d1reportSingleClassLayoutXXXX XXXX.cpp便可得到类的layout信息,可以观测到C是16字节,第一个4字节内存是虚函数表vftab,然后是继成A的成员m_iA,再次之B的成员m_iB,最后是C的对象m_iC。其中虚表函数分布也是简单易看。
class C size(16):
+---
0 | +--- (base class B)
0 | | +--- (base class A)
0 | | | {vfptr}
4 | | | m_iA
| | +---
8 | | m_iB
| +---
12 | m_iC
+---
C::$vftable@:
| &C_meta
| 0
0 | &C::fun
1 | &C::fun2
2 | &A::funA
3 | &B::funB
4 | &C::funC
C::fun this adjustor: 0
C::fun2 this adjustor: 0
C::funC this adjustor: 0
我们用IDA打开这个exe进行查看,32位就用32位IDA,64位就用64位的
File->open->生成exe文件路径
定位到main函数,查看反汇编代码
我们来逐行解析IDA的反汇编
.text:
由上面分析可知,在汇编层不一定和书写完全一致,有时会遇到编译器很强大的优化。我们再解释一下内存分布,以便更加强化理解,本例pkC在堆上分配,pkC的虚表放在只读数据段,里面有5个函数地址,(0 | &C::fun 1 | &C::fun2 2 | &A::funA 3 | &B::funB 4 | &C::funC),代码段当然就是各种函数的指令实体,这5个函数实体也放在这里。
10. STL的知识,最好看一下STL源码解析那本书,没看过也问题不大。以vector.size()反汇编解析。
std
我们进行IDA反汇编静态分析
.text:
从上面反汇编可看出,std::vector<long> vNum对象分配在栈区,本例在ebp+Memory中,大小是12个字节,是3个指针占用_Myfirst,_Mylast,_Myend,这要看源码才清楚;vector容器的元素对象实际在堆区分配,这里放了一个long型100,占用8个字节,由STL的Alloc分配器new出来,Alloc的代码也是值得研读。这里size()函数根本没有调用,由内联复制过来了,且中间还被取std::cout地址插了一脚。size计算两个指针地址相减,然后除以4,这些略为背后点知识,可能都要深入了解一下STL。
好了,有了上面知识准备,开始进行Android Arm64调试,这里有几个知识点
1.Arm64有31个通用整型寄存器,r0-r30。当使用64bits时候,命名x0-x30;使用32bits时,命名w0-w30
2.ESP变成sp,EIP变成pc
3.一般函数可以用r0到r7传递时不用栈,不够时也用栈传
4.返回值一般用r0,有时也用r8
5.常用指令,指令也是挺多的,记不住的也可以查。
- MOV x1,x0; 传送指令,x0->x1
- ADD x0,x1,x2; 相加,x1+x2 ->x0
- SUB x0,x1,x2; 相减,x1-x2->x0
- AND x0,x0,#8; 相与,(x0&8)->x0
- ORR x0,x0,#8; 相或,(x0|8)->x0
- EOR x0,x0,#8; 相或,(x0^8)->x0
- LDR x0,[x1,#8]; 取内存(x1+8)地址放到x0
- STR x0, [sp, #8]; 取x0数据到SP+8内存地址
- STP x3, x4, [sp, #8]; 入栈
- LDP x3, x4, [sp, #8]; 出栈
- CMP x3,x4;比较x3,x4值
- CBZ x3, xxxxxx; x3为0,就跳到xxxxxx代码段
- CBNZ x3, xxxxxx; x3不为0就跳到xxxxxx代码段
- B/BL ; 绝对跳转,返回地址保存到LR(x30)
实战:1例野指针,一例没有判空
1.打开IDA选择New,打开要调试的so文件
2.打开主界面后,看崩溃的pc代码段值,比如0x0000000014A3287C,按G键跳转过到0x0000000014A3287C,然后手动找到函数开头,也可以按空格键看调用关系图。
3.对着代码反推反汇编,这个需要一些经验,对指令熟悉的越多,通常会越快,也和复杂度正相关,对于比较重要的地方,可以按;键加一些注释帮助理解。
4.由于实际项目代码非常复杂且不便公开,要推较长时间,我这里写些假的代码,只取最关键部分,模拟一下崩溃环境。
A:野指针,直接看这代小代码,也问题不大,该判断的也都判断了
struct
我们崩溃在0000000000002C0C,用IDA进行Arm64反汇编,一行一行的分析
.text:
B:空指针
这种问题是比较简单,但实际工程量大了,代码量大了,比如1000万行以上,要判断的地方太多,或者有的地方理论上就不应该为空,都进行判断有时也挺讨厌,这种问题对工程大的相对有点恶心,但这种问题修复起来比较难,因为问题根源不在这,只是这里先发生而已。简单示意一下,略去前面要推的代码,只取最核心的。
struct
我们崩溃在0000000000002C20,用IDA进行Arm64反汇编,一行一行的分析
.text:
通过汇编分析我们可知pkA为空指针,要进行进一步修补。
文章就介绍到这里,希望和大家一起进步。