模拟栈帧修复
填坑,模拟堆栈的复原。
有关栈帧汇编复习
1 2 3 4 5 6 7 8 9 endbr64 ;用于标记程序中间接调用和跳转的有效跳转目标地址 End Branch 64 bit ;间接跳转的接应指令,其实Intel为了防止控制流被劫持,正常跳转的第一条指令,如果不是,那么CPU会报错,引发#cp异常,错误跳转。 ;直接调用 call + 偏移 ;间接调用 call + 绝对地址 往往会将结果保存在某寄存器中 push xxx ;可解析为 sub esp,4 mov dword ptr ss:[esp],xxx ;或是 lea esp,dword ptr ss:[esp-4] 再 mov pop xxx ;与push类似
push和pop可由mov和sub等两步操作组成,所以开辟堆栈时一般是sub esp,xxx 之后通过mov来使用栈空间,而不是push。这样能提高指令的执行效率。
常见栈帧的模板,以一个debug版本的加法为例。
练习的话最好自己跟一个函数用excel画一下堆栈图。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 push 2 //参数入栈 push 3 call func //函数内容 push ebp mov ebp,esp sub esp,40h //开辟栈空间 push ebx //保存现场 push esi push edi lea edi,dword ptr ss:[esp-40] //开辟空间 填充0xcc(int 3) 防止溢出 mov ecx,0x10 mov eax,0xcccccccc rep stos dword ptr ss:[edi] mov eax,dword ptr ss:[ebp+8] //调用参数完成加法 add eax,dword ptr ss:[ebp+0ch] pop edi //恢复现场 pop esi pop ebx mov esp,ebp pop ebp ret add esp,8 //外平衡堆栈
其实栈帧本身有系统默认的esp和ebp来维护,如果我们将其换用其他的寄存器,在内存的基础上模拟堆栈,那么IDA反编译的效果会大打折扣,从而起到保护作用,NaCl一题就是运用了该类技术。
例题
事先可以用Lumina和Finger恢复一下库函数符号,减少不必要的调试操作。
1 2 3 4 5 ;test_input: 012345abcdefghijklmnopqrstuvwxyz .text:00000000080017B9 call read .text:00000000080017BE lea rax, [rbp-30h] .text:00000000080017C2 mov rdi, rax ;输入的32字符 .text:00000000080017C5 call sub_8080900;主要的check函数
跳过来endbr64
检测是否劫持,与原本函数开头的push ebp
和 sub esp,xxx
大不相同,这里是对r15进行操作,为了方便观察右上开了一个子窗口专用来跟踪R15。
1 2 3 4 5 6 SFI:0000000008080904 sub r15d, 28h ; '(' ;开辟栈空间 SFI:0000000008080908 lea r15, [r13+r15+0] ;r13值为0 SFI:000000000808090D mov [r15], rdi ;栈顶存入输入地址 SFI:0000000008080910 mov dword ptr [r15+24h], 0 ;第二个位置0 SFI:0000000008080918 mov dword ptr [r15+24h], 0 SFI:0000000008080920 jmp loc_8080A40 ;跳转
之后又发现类似结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 SFI:0000000008080940 loc_8080940: ; CODE XREF: sub_8080900+145↓j SFI:0000000008080940 mov eax, [r15+24h] SFI:0000000008080944 shl eax, 3 SFI:0000000008080947 movsxd rdx, eax SFI:000000000808094A mov rax, [r15] ;取输入 SFI:000000000808094D add rax, rdx SFI:0000000008080950 mov [r15+18h], rax ;存到此处 SFI:0000000008080954 mov rax, [r15+18h] SFI:0000000008080958 mov rdi, rax ;放入rdi SFI:000000000808095B nop dword ptr [rax+rax+00h] ;nop SFI:0000000008080960 lea r15, [r15-8] ;栈顶上移 SFI:0000000008080964 lea r12, loc_8080980 SFI:000000000808096B mov [r15], r12 ;将下一个执行的指令入栈 SFI:000000000808096E jmp loc_8080720 ;跳转到该处执行 SFI:000000000808096E ; ------------------------------------------------- SFI:0000000008080973 align 20h SFI:0000000008080980 SFI:0000000008080980 loc_8080980: ; DATA XREF: sub_8080900+64↑o SFI:0000000008080980 mov [r15+10h], rax
该处的亮点在于使用r15模拟了一个call loc_8080720的操作,call之后执行8080980处代码,中间数据为垃圾指令。
观察右侧窗口即为调用函数时入栈的retadr
和ebp
,并且对于函数调用的代码块用到了endbr64来标识,之后则是开辟新的栈帧。
程序是64位的,所以rbp和返回地址以及对栈的操作都是以8字节为单位。其实这个rbx的内容也不是栈底,应该是保持结构。
逻辑段中也插入了一些垃圾数据。
函数调用的结构已经知晓,接下来看他调用完是如何恢复堆栈的。
1 2 3 4 5 6 SFI:00000000080802E0 lea r15, [r15+8] ;pop SFI:00000000080802E4 mov edi, [r15-8] ;返回地址放入EDI SFI:00000000080802E8 and edi, 0FFFFFFE0h SFI:00000000080802EB lea rdi, [r13+rdi+0] SFI:00000000080802F0 jmp rdi ;跳回 SFI:00000000080802F0 ; END OF FUNCTION CHUNK FOR sub_8080900
所以整体堆栈由r15来维护,并且模拟了call和ret,同时逻辑段中还有垃圾指令。
我们预期是恢复堆栈由esp和ebp来控制,恢复call和ret函数并且去掉逻辑段中的垃圾指令,这项工作将通过使用ipy来解决。
1 2 3 4 5 6 7 8 9 10 11 12 GetDisasm(adr) GetMnem(adr) next_head(adr) GetOpnd(adr,long n) get_bytes(adr,end-adr) tmp=get_bytes(adr,end-adr) tmp=tmp.replace(b'\x74\x03\x75\x01\xE8' ,b'\x90' *5 )
本来是要这把r15修成rsp的,但是机器指令的长度不一致,改动的话会比较大,个人看到了PZ师傅的分享,只需要把控制流的call和ret修的清楚一些就能生成可读的反编译代码了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 ;ret 处的硬编码 其中夹杂着垃圾数据 SFI:000000000807FF5A 66 0F 1F 44 00+ nop word ptr [rax+rax+00h] SFI:000000000807FF5A 00 SFI:000000000807FF60 4D 8D 7F 08 lea r15, [r15+8] SFI:000000000807FF64 41 8B 7F F8 mov edi, [r15-8] SFI:000000000807FF68 83 E7 E0 and edi, 0FFFFFFE0h SFI:000000000807FF6B 49 8D 7C 3D 00 lea rdi, [r13+rdi+0] SFI:000000000807FF70 FF E7 jmp rdi ret=[0x41, 0x8B, 0x7F, 0xF8, 0x83, 0xE7, 0xE0, 0x49, 0x8D, 0x7C, 0x3D, 0x00, 0xFF, 0xE7] ;call处 由于返回地址处不一致所以操作起来比 SFI:0000000008080A60 4D 8D 7F F8 lea r15, [r15-8] SFI:0000000008080A64 4C 8D 25 15 00+ lea r12, sub_8080A80 SFI:0000000008080A64 00 00 SFI:0000000008080A6B 4D 89 27 mov [r15], r12 SFI:0000000008080A6E E9 AD F4 FF FF jmp loc_807FF20 ;操作后的align 20h夹杂着垃圾数据
根据上述硬编码的格式给出Patch
脚本,感谢P师傅的思路分享。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 beg=0x807FEC0 end=0x8080ad2 print ('Patch Beg' )def Patch (l,r ): while l<r: PatchByte(l,0x90 ) l+=1 adr_ins=[0 ]*5 call_mod = ["lea" , "lea" , "mov" , "jmp" ] ret_mod = ["lea" , "mov" , "and" , "lea" , "jmp" ] adr=beg while adr<end: adr_ins[0 ]=adr for i in range (1 ,5 ): adr_ins[i]=next_head(adr_ins[i-1 ]) for i in range (4 ): if GetMnem(adr_ins[i])!=call_mod[i]: break else : Patch(adr_ins[0 ],adr_ins[3 ]) PatchByte(adr_ins[3 ],0xe8 ) l=next_head(adr_ins[3 ]) r=next_head(l) Patch(l,r) adr=r continue for i in range (5 ): if GetMnem(adr_ins[i]) != ret_mod[i]: break else : Patch(adr_ins[0 ],adr_ins[4 ]) PatchWord(adr_ins[4 ],0x90 ) PatchWord(adr_ins[4 ]+1 ,0xc3 ) l=next_head(adr_ins[4 ]) r=next_head(l) Patch(l,r) adr=r continue adr=next_head(adr) print ('Patch End' )""" 去一些垃圾数据的话 tmp=get_bytes(beg,end-beg) junk1=[0x66, 0x66, 0x2E, 0x0F, 0x1F, 0x84, 0x00, 0x00, 0x00, 0x00, 0x00, 0x66, 0x66, 0x2E, 0x0F, 0x1F, 0x84, 0x00, 0x00, 0x00, 0x00, 0x00, 0x66, 0x0F, 0x1F, 0x84, 0x00, 0x00, 0x00, 0x00, 0x00] new_code=tmp.replace(bytes(junk1),b'\x90'*len(junk1)) for i in range(len(new_code)): if tmp[i]!=new_code[i]: PatchByte(beg+i,new_code[i]) """
运行后,将Patch应用到一个附件上,IDA重新进行解析,反编译后结合调试信息恢复结构体。
对比上一篇文章调试汇编得到的加密,结构基本一致。
1 2 3 4 5 6 def encode (a,b ): for i in range (44 ): a,b=(rol(a,1 )&rol(a,8 ))^rol(a,2 )^b^tb[i],a print (hex (a), hex (b)) a,b=b,a return a,b
来喝杯茶吧🍵🍵🍵
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 *(v3 - 0x24 ) = round; *(v3 - 0x30 ) = enc; *(v3 - 0x18 ) = &unk_80AFB40; *(v3 - 8 ) = *(v2 + *(v3 - 0x30 )); *(v3 - 0xC ) = *(v2 + *(v3 - 0x30 ) + 4 ); *(v3 - 0x10 ) = 0 ; *(v3 - 0x1C ) = 0x10325476 ; struct en_st { _QWORD Enc; _QWORD Round; _QWORD Delta; _DWORD* Xkey; _DWORD Sum; _DWORD V1; _DWORD V0; _DWORD I; }
修复结构体后函数如下。
解密就不在赘述了,在上篇文章中有写。
由于本题的逻辑代码块较少,并且Tea加密的汇编比较有特点,所以通过调试读汇编的手段也能快速解题,但一旦代码量大起来就G了。
End
其实上述操作并没用完全解决栈指针换寄存器的问题,我们只是凭借对call和ret的修复使程序控制逻辑恢复,达到反编译的效果。这样做的缺点是我们不得不把 r15模拟的栈空间当成结构体来处理,其实修复起来也是有一定难度的,如果函数调用比较深,那么还是没能简化分析。
彻底修复是把r15换成rsp,不过通过IDApython的话感觉会比较繁,使用r15和rsp的指令长度可能会差1,改动量会比较大,目前的尝试以失败告终。😭😭😭
Intel-x64汇编指令机器码对应列表 – 更具体的硬编码可以通过在x64dbg或OD中写汇编来观察。