模拟栈帧修复

填坑,模拟堆栈的复原。

有关栈帧汇编复习

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函数

image-20220420232345847

跳过来endbr64检测是否劫持,与原本函数开头的push ebpsub 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处代码,中间数据为垃圾指令。

image-20220420234051834

观察右侧窗口即为调用函数时入栈的retadrebp,并且对于函数调用的代码块用到了endbr64来标识,之后则是开辟新的栈帧。

程序是64位的,所以rbp和返回地址以及对栈的操作都是以8字节为单位。其实这个rbx的内容也不是栈底,应该是保持结构。

image-20220420235428879

逻辑段中也插入了一些垃圾数据。

函数调用的结构已经知晓,接下来看他调用完是如何恢复堆栈的。

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) #得到adr地址的一条汇编代码

GetMnem(adr) #得到adr地址的操作码

next_head(adr) #取下一条指令的地址

GetOpnd(adr,long n) #获取操作数 第一个操作数 n是0 第二个n是1 ...

get_bytes(adr,end-adr) #获取一片空间的字节
tmp=get_bytes(adr,end-adr)
tmp=tmp.replace(b'\x74\x03\x75\x01\xE8',b'\x90'*5) #批量patch

本来是要这把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 #真个SFI段
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): #取4个指令判断是否是call_mod
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]) #Patch align
r=next_head(l)
Patch(l,r)
adr=r
continue

for i in range(5): #取5个指令判断是否是ret_mod
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]) #Patch align
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重新进行解析,反编译后结合调试信息恢复结构体。

image-20220421182303300

对比上一篇文章调试汇编得到的加密,结构基本一致。

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;  //8
*(v3 - 0x30) = enc; //0
*(v3 - 0x18) = &unk_80AFB40; //0x18
*(v3 - 8) = *(v2 + *(v3 - 0x30));//0x28
*(v3 - 0xC) = *(v2 + *(v3 - 0x30) + 4);//0x24
*(v3 - 0x10) = 0;//0x20
*(v3 - 0x1C) = 0x10325476;//0x14
//如何计算偏移 其实-0x30 指向的是上一个结构体数据 并且结构体大小为0x30,那么可以通过 -0x24 = -0x30 +8 来计算偏移
struct en_st{
_QWORD Enc;
_QWORD Round;
_QWORD Delta;
_DWORD* Xkey;
_DWORD Sum;
_DWORD V1;
_DWORD V0;
_DWORD I;
}

修复结构体后函数如下。

image-20220421193241470

解密就不在赘述了,在上篇文章中有写。

由于本题的逻辑代码块较少,并且Tea加密的汇编比较有特点,所以通过调试读汇编的手段也能快速解题,但一旦代码量大起来就G了。

End

其实上述操作并没用完全解决栈指针换寄存器的问题,我们只是凭借对call和ret的修复使程序控制逻辑恢复,达到反编译的效果。这样做的缺点是我们不得不把 r15模拟的栈空间当成结构体来处理,其实修复起来也是有一定难度的,如果函数调用比较深,那么还是没能简化分析。

彻底修复是把r15换成rsp,不过通过IDApython的话感觉会比较繁,使用r15和rsp的指令长度可能会差1,改动量会比较大,目前的尝试以失败告终。😭😭😭

Intel-x64汇编指令机器码对应列表 – 更具体的硬编码可以通过在x64dbg或OD中写汇编来观察。