最近刚接触到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会优化,不利于初始学习
class A
{
public:
int fun(int iNum) { return m_iA * iNum; }
int m_iA = 100;
};
A* pkA = new A;
int iRet = pkA->fun(10);
// int iRet = pkA->fun(10); 对这句进行反汇编,注这里是debug吧,如果是Release版本,编译器很强的,
// 这种过于简单的代码,可能直接函数调用开销都没有了,直接给结果。
00122978 push 0Ah //将10压入栈
0012297A mov ecx,dword ptr [pkA] ;//将pkA地址放到ecx中,也就this*
0012297D call A::fun (0103A91h) ;// pkA->fun(10)函数调用,实际为A::fun(pkA,10);
00122982 mov dword ptr [iRet],eax // 函数返回值放到iRet中,完成int iRet = pkA->fun(10);
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 A
{
public:
int m_iA = 111;
virtual int fun(int iA) { return m_iA*iA; }
virtual float fun2(float iF) { return m_iA * iF; }
virtual int funA(int iA) { return m_iA * iA; }
};
class B : public A
//class B : virtual public A
{
public:
int m_iB = 222;
virtual int fun(int iA) { return m_iB * iA; }
virtual float fun2(float iF) { return m_iB * iF; }
virtual int funB(int iA) { return m_iB * iA; }
};
class C : public B
{
public:
int m_iC = 333;
virtual int fun(int iA) { return m_iC * iA; }
virtual float fun2(float iF) { return m_iC * iF; }
virtual int funC(int iA) { return m_iC * iA; }
};
int main()
{
C* pkC = new C;
printf("size:%d",sizeof(C));
pkC->funB(1);
retrun 0;
}
我们可以借助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:00401090 ; int __cdecl main()
.text:00401090 _main proc near
.text:00401090 push esi;放入栈区,为了保护ESI
.text:00401091 push 10h;这时new C的大小16字节,为了给new传参
.text:00401093 call ??2@YAPAXI@Z ; 这里实际是调用new分配内存
.text:00401098 mov esi, eax;参见第2条,eax是返回值,即new分配的地址,放esi
.text:0040109A push 10h;传参16,这里是为了printf函数
.text:0040109C push offset _Format ; 取常量字符串"size:%d"的地址到栈区
.text:004010A1 mov dword ptr [esi+4], 6Fh;初如化pkC ->m_iA = 111;m_iA = this +4
.text:004010A8 mov dword ptr [esi+8], 0DEh;初如化pkC->m_iB = 222;m_iB = this +8
.text:004010AF mov dword ptr [esi], offset ??_7C@@6B@;取虚表地址到pkC地址
.text:004010B5 mov dword ptr [esi+0Ch], 14Dh;初如化pkC->m_iC = 333;m_iC=this+12
.text:004010BC call _printf;调用系统printf函数打印sizeof(C)
.text:004010C1 mov edx, [esi];取得虚表所在地址到edx中
.text:004010C3 add esp, 0Ch;维护栈,平衡上面push
.text:004010C6 mov ecx, esi;让pkC放到ecx,也就是为this call第一个参数准备
.text:004010C8 push 1;传funB(int);参数为1
.text:004010CA call dword ptr [edx+0Ch];调表虚表+12对应函数代码段,pkC->funB(1)
;对比上面layout打印虚表<3|&B::funB,也就偏12个字节>,完全符合
.text:004010CD xor eax, eax;eax清零
.text:004010CF pop esi;弹出栈区,保护ESI不受污染,参见第8条
.text:004010D0 retn;结束
.text:004010D0 _main endp
由上面分析可知,在汇编层不一定和书写完全一致,有时会遇到编译器很强大的优化。我们再解释一下内存分布,以便更加强化理解,本例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::vector<long*> vNum;
vNum.push_back((long*)100);
std::cout << vNum.size() << std::endl;
我们进行IDA反汇编静态分析
.text:004011BA lea ecx, [ebp+Memory];取栈区std::vector<long> vNum的地址到ecx
.text:004011BD mov [ebp+var_20], 64h;将100放到栈区
.text:004011C4 call sub_4012D0;很容易推出这名完成(&vNum)->push_back(100)调用
.text:004011C9 mov eax, [ebp+Memory+4];vNum地址偏移4个字节,也就是_Mylast
.text:004011CC mov esi, [ebp+Memory];vNum地址着地址,也就是_Myfirst
.text:004011CF sub eax, esi;地址相减(_Mylast - _Myfirst)
.text:004011D1 mov ecx, ds:?cout@std@@3V?$basic_ostream@DU?@std@@@1@A ;cout
.text:004011DC sar eax, 2;vNum.size()= eax>>2=eax/4,32位指针是4字节,64位就除8
.text:004011DF push eax;size()传参压栈
.text:004011E0 call ds:??6?$basic_ostream@DU?$@std@@@std@@QAEAAV01@I@Z ;调用cout
从上面反汇编可看出,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 aaa
{
int ia;
int ib;
std::vector<void*> vb;
};
void* TestFunCrash(aaa* pkaaa)
{
if (!pkaaa) return 0;
int iSize = pkaaa->vb.size();
for (int i = 0; i < iSize; i++)
{
if (i == 2)
{
return pkaaa->vb[i];
}
}
}
我们崩溃在0000000000002C0C,用IDA进行Arm64反汇编,一行一行的分析
.text:0000000000002BD4 ; TestFunCrash(aaa *)
.text:0000000000002BD4 EXPORT _Z12TestFunCrashP3aaa
.text:0000000000002BD4 _Z12TestFunCrashP3aaa ; DATA XREF: LOAD:0000000000000990↑o
.text:0000000000002BD4 CBZ X0, locret_2C04;对pkaaa进行判空
.text:0000000000002BD8 LDP X8, X9, [X0,#8];取pkaaa偏移8个字节,就pkaaa
;->vb,由STL知识可知,x8放vector的_Myfirst,x9放_Mylast
.text:0000000000002BDC SUB X9, X9, X8;获得vector容器地址差
.text:0000000000002BE0 LSR X9, X9, #3;除指针大小8,获得size()
.text:0000000000002BE4 CMP W9, #1;size和1比较
.text:0000000000002BE8 B.LT locret_2C08;小于的话,可以结束了
.text:0000000000002BEC MOV W10, WZR;设i = 0
.text:0000000000002BF0
.text:0000000000002BF0 loc_2BF0 ; CODE XREF: TestFunCrash(aaa *)+2C↓j
.text:0000000000002BF0 CMP W10, #2;将i和2比较
.text:0000000000002BF4 B.EQ loc_2C0C;相等,跳到loc_2C0C
.text:0000000000002BF8 ADD W10, W10, #1
.text:0000000000002BFC CMP W10, W9
.text:0000000000002C00 B.LT loc_2BF0
.text:0000000000002C04
.text:0000000000002C04 locret_2C04 ; CODE XREF: TestFunCrash(aaa *)↑j
.text:0000000000002C04 RET
.text:0000000000002C08 ; -----------------------------------------------------
.text:0000000000002C08
.text:0000000000002C08 locret_2C08 ; CODE XREF: TestFunCrash(aaa *)+14↑j
.text:0000000000002C08 RET
.text:0000000000002C0C ; ----------------------------------------------------------
.text:0000000000002C0C
.text:0000000000002C0C loc_2C0C ; CODE XREF: TestFunCrash(aaa *)+20↑j
.text:0000000000002C0C LDR X0, [X8,#0x10];这里崩溃了也就是pkaaa->vb[i]
;这行,取容器第三个元素。查看崩溃时寄储器,x8 = 0,x9 = 0x3FFFFFFF,x8= 0必崩啊,x9这个也说明野指针。
.text:0000000000002C10 RET
B:空指针
这种问题是比较简单,但实际工程量大了,代码量大了,比如1000万行以上,要判断的地方太多,或者有的地方理论上就不应该为空,都进行判断有时也挺讨厌,这种问题对工程大的相对有点恶心,但这种问题修复起来比较难,因为问题根源不在这,只是这里先发生而已。简单示意一下,略去前面要推的代码,只取最核心的。
struct aaa
{
int ia;
int ib;
std::vector<void*> vb;
};
struct bbb
{
long l1;
long l2;
aaa* pkA;
};
struct ccc
{
float* pkFloat;
bbb* pkB;
};
int TestEmptyPointer(ccc* pkC)
{
if (!pkC) return 0;
return pkC->pkB->pkA->vb.size();
}
我们崩溃在0000000000002C20,用IDA进行Arm64反汇编,一行一行的分析
.text:0000000000002C14 ; =============== S U B R O U T I N E =====================
.text:0000000000002C14
.text:0000000000002C14
.text:0000000000002C14 ; TestEmptyPointer(ccc *)
.text:0000000000002C14 EXPORT _Z16TestEmptyPointerP3ccc
.text:0000000000002C14 _Z16TestEmptyPointerP3ccc ; DATA XREF: LOAD:0000000000000560
.text:0000000000002C14 CBZ X0, locret_2C2C;羊断pkC是否为空
.text:0000000000002C18 LDR X8, [X0,#8];取pkC->pkB,pkB在ccc中偏移8字节
.text:0000000000002C1C LDR X8, [X8,#0x10];取pkC->pkB->pkA,pkA偏移16字节
.text:0000000000002C20 LDP X9, X8, [X8,#8];crash,本意pkA->vb.size()计算
;查看此时x8寄储器为空,说明struct bbb中的pkA为空,所以x8+8必然崩
.text:0000000000002C24 SUB X8, X8, X9
.text:0000000000002C28 LSR X0, X8, #3
.text:0000000000002C2C
.text:0000000000002C2C locret_2C2C ;
.text:0000000000002C2C RET
.text:0000000000002C2C ; End of function TestEmptyPointer(ccc *)
.text:0000000000002C2C
通过汇编分析我们可知pkA为空指针,要进行进一步修补。
文章就介绍到这里,希望和大家一起进步。