因为第二天有xctf所以这道题从晚上九点出来到12点我没有做出来就睡了,看了官方的wp记录一下。
引用
Nu1L官方wp
某大佬的博客
花指令
关于花指令
https://blog.csdn.net/whklhhhh/article/details/88677670
https://blog.csdn.net/whklhhhh/article/details/88730934
在没有去除花指令之前,打开ida的关键代码如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| void sub_123413B0()
{
unsigned int v0; // ST40_4
unsigned int v1; // ST44_4
int v2; // ST78_4
nullsub_11();
sub_12341340(0);
v0 = strlen("npointer{");
v1 = strlen("}");
v2 = sub_123416AD(v0 + v1 + 33);
nullsub_12();
sub_12341020("Input the correct keys: ");
nullsub_24();
sub_12341050("%s", v2, v0 + v1 + 33);
JUMPOUT((char *)&loc_1234148F + 1);
}
|
JUMPOUT后面的代码没有反编译出来,查看汇编代码
1
2
3
4
5
6
7
8
| .text:12341487 E8 04 00 00 00 call near ptr loc_1234148F+1
.text:1234148C 77 EB ja short loc_12341479
.text:1234148E 07 pop es
.text:1234148F
.text:1234148F loc_1234148F: ; CODE XREF: sub_123413B0+D7↑p
.text:1234148F 88 36 mov [esi], dh
.text:12341491 83 04 24 01 add [esp], 1
.text:12341495 C3 retn
|
call指令等于这样两条指令,一是把自身所在位置的下一条指令的地址压入堆栈,二是jmp到call的地址处,而ret指令可以理解为jmp到call指令压入堆栈的地址,因此,可以用call指令这样来写花指令:
1.call一个地址,在call下面随便写一点花指令,但是要注意一点与jmp版花指令不同的,我们要记得自己写的花指令占了多少个字节,比如,占了2字节
2.在call里面,也就是函数里面,首先pop出压入的地址,然后把这个地址减去花指令占用的字节数,这里是2字节,再重新push进堆栈,然后就ret,这样,call结束以后执行的下一条指令就是我们想要去的位置了,也就是花指令下面的正常的指令了
对于这样一个花指令,call一个函数内的地址然后再retn返回,IDA会认为被call的地址是一个新的函数,当前函数就被截断了,影响到了IDA的分析。
图中call指令之后在call函数里将栈顶的值加了1,所以call指令后面的77可以忽略直接到EB 07
这条指令,这个是jmp 7
的意思,可以直接跳到retn指令后面。
官方wp中说主程序中穿插的花指令形式如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| #define JUNK2(idx) __asm{ \
__asm call next1_junk2_##idx \
__asm __emit 0x77 \
__asm jmp next_junk2_##idx \
__asm __emit 0x88 \
__asm next1_junk2_##idx: \
__asm add dword ptr ss:[esp], 1 \
__asm ret \
__asm next_junk2_##idx: \
}
#define JUNK1(idx) __asm{\
__asm jmp jlabel##idx \
__asm __emit 0x88 \
__asm jlabel_##idx : \
__asm ret \
__asm __emit 0xba \
__asm jlabel##idx : \
__asm call jlabel_##idx \
}
|
这两个花指令的二进制形式如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| '''
EB0388C3BAE8F9FFFFFF
E80400000077EB07883683042401C3
'''
stripd = ""
with open('gatesXgame_un.exe', "rb") as f:
raw = f.read()
i = 0
while i < len(raw):
if raw[i:i+10] == "\xEB\x03\x88\xC3\xBA\xE8\xF9\xFF\xFF\xFF":
stripd += "\x90"*10
i = i + 10
elif raw[i:i+15] == "\xE8\x04\x00\x00\x00\x77\xEB\x07\x88\x36\x83\x04\x24\x01\xC3":
stripd += "\x90"*15
i = i + 15
else:
stripd += raw[i]
i += 1
with open('gate_str.exe', 'wb') as f:
f.write(stripd)
|
去除花指令后的代码
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
| int sub_123413B0()
{
unsigned int v0; // ST40_4
unsigned int v1; // ST44_4
char *Buf1; // [esp+6Ch] [ebp-Ch]
sub_12341340(0);
v0 = strlen("npointer{");
v1 = strlen("}");
Buf1 = (char *)sub_123416AD(v0 + v1 + 33);
print("Input the correct keys: ");
sub_12341050("%s", Buf1, v0 + v1 + 33);
if ( !memcmp(Buf1, "npointer{", strlen("npointer{"))
&& Buf1[strlen(Buf1) - 1] == asc_12343738[0]
&& (Buf1[strlen(Buf1) - 1] = 0, sub_12341090(sub_12343798, 0x30D3u, (int)&Buf1[strlen("npointer{")])) )
{
sub_12341340(1);
print("Congrats!\n");
}
else
{
print("Sorry, the gate remains closed.\n");
}
return system("pause");
}
int __cdecl sub_12341090(void *Src, SIZE_T dwSize, int a3)
{
HANDLE v4; // eax
int v5; // ST38_4
LPVOID Dst; // [esp+1Ch] [ebp-14h]
Dst = VirtualAlloc(0, dwSize, 0x3000u, 0x40u);
if ( !Dst )
return 0;
memcpy(Dst, Src, dwSize);
v4 = GetCurrentProcess();
FlushInstructionCache(v4, Dst, dwSize);
sub_12341240();
v5 = ((int (__fastcall *)(_DWORD, int, unsigned int))Dst)(0, a3, strlen((const char *)a3));
VirtualFree(Dst, 0, 0x8000u);
return v5;
}
|
所以主要的验证部分在sub_12343798处,在进入检测代码之前设置了一些参数信息:
1
2
3
4
| //edx --> char* flag
//ecx --> int idx
//edi --> payload_base
//ebx --> MAX_STEP (len(flag))
|
天堂之门
在Windows64操作系统下,所有的32位程序会被装载到WoW64子系统中运行。而某些windows kernel调用,WoW64会将其钩取为64位调用。在这个过程中,运行的程序会从兼容模式暂时地切换成64位模式运行。利用这个特性,我们可以在程序运行过程中主动切换为64位模式来执行64位代码,以达到某种保护程序(如使静态分析失败、动态跟踪混乱)的目的。
这种保护方法被称为Heaven’s Gate,直译就是天堂之门。架构的切换对于运行在 Wow64 环境下的程序是必不可少的,运行在 64 位 Windows 系统下的 32 位程序在进入系统调用前需要完成下述操作:
1
| 32-bit ntdll.dll -> wow64cpu.dll’s Heaven’s Gate -> 64-bit ntdll.dll -> syscall into the kernel
|
WoW64根据段寄存器cs的值来确定程序的运行模式,如果cs的值为0x33,则当前是64位模式;如果cs的值为0x23,则当前为兼容模式。
这道题采用了这样的方式来切换运行模式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| // open_gate_template_x86
push 0x33 // cs:0x33
sub esp, 4
mov dword ptr ss:[esp], grid_dst_ip // next grid
add qword ptr ss:[esp], edi // payload_base
inc ecx // step++
retf
// open_gate_template_x64
push 0x23 // cs:0x23
sub rsp, 8
mov qword ptr ss:[rsp], grid_dst_ip
add qword ptr ss:[rsp], rdi
inc rcx // step++
retfq
|
因为架构的切换过程加上验证部分中间依旧穿插了部分花指令,x86到x64的切换大致过程如下
1
2
3
4
5
6
7
8
9
10
11
12
13
| b = random.randint(0, 0x7f)
a = 0x33 ^ b
open_gate_template_x86_mutated = f"""
open_gate_label:
push {hex(a)}
xor dword ptr ss:[esp], {hex(b)}
call next_label // sub esp, 4
.byte {random.randint(0,255)}
next_label:
mov dword ptr ss:[esp], grid_dst_ip
add dword ptr ss:[esp], edi
inc ecx
retf"""
|
再弄明白这个架构的切换之后,可以看出来验证部分主要是一个迷宫(图搜索),不过需要确定最最开始的两条路径(即最前面两个字母需要时f4)然后后面的路径过程基本一致,可以写出遍历程序求解。
补一些关于天堂之门的学习链接
http://rce.co/knockin-on-heavens-gate-dynamic-processor-mode-switching/
https://medium.com/@fsx30/hooking-heavens-gate-a-wow64-hooking-technique-5235e1aeed73
https://www.malwaretech.com/2014/02/the-0x33-segment-selector-heavens-gate.html
这部分代码调试需要使用WinDbg(x64),这个调试器可以自动完成32到64位的切换,我试了一下使用这个调试器,在执行retf后,寄存器从eax变成了rax,在执行retfq后,寄存器从rax变成了eax.
solve
膜Thiner
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
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
| #!/usr/bin/python
"""
Solution Author: Thiner @ NeSE
The challenge is solved with some luck.
"""
"""
note here
rbx=total
rcx=0
rdx=buffer without npointer{}
rdi=&code
init is 32bit
return value is check result
offset : 0x2198
virtaddr: 0x12343798
code len: 0x30d3
"""
# gatesXgame_un.exe is the upx unpacked binary
import capstone as cs
with open('gatesXgame_un.exe') as f:
raw = f.read()
code = raw[0x2198:0x2198+0x30d3]
# """
for i in range(len(code)-5):
if code[i:i+5] == '\xe8\x01\x00\x00\x00':
code = code[:i+5]+'\xf1'+code[i+6:]
if code[i:i+5] == '\xe8\x02\x00\x00\x00':
code = code[:i+5]+'\xcd\x81'+code[i+7:]
if code[i:i+5] == '\xe8\x03\x00\x00\x00':
code = code[:i+5]+'\xcd\x82\xf1'+code[i+8:]
# code=code.replace('e803000000e4de78'.replace())
md32 = cs.Cs(cs.CS_ARCH_X86, cs.CS_MODE_32)
md64 = cs.Cs(cs.CS_ARCH_X86, cs.CS_MODE_64)
def dis32(addr):
print ' code 32 {:#x} '.format(addr).center(80, '-')
for h, i in enumerate(md32.disasm(code[addr:addr+0x100], addr)):
print '{:3d}{:>#8x} {:>7s} {}'.format(h, i.address, i.mnemonic, i.op_str)
def dis64(addr):
print ' code 64 {:#x} '.format(addr).center(80, '-')
for h, i in enumerate(md64.disasm(code[addr:addr+0x100], addr)):
print '{:3d}{:>#8x} {:>7s} {}'.format(h, i.address, i.mnemonic, i.op_str)
def ext32(addr):
#dis32(addr)
head = list(md32.disasm(code[addr:addr+0x80], addr))
# 3+2k
l = []
for i in range(10):
if head[3+2*i].mnemonic != 'cmp':
break
ch = int(head[3+2*i].op_str.split(',')[-1], 0)
tar = int(head[4+2*i].op_str, 0)
inst = list(md32.disasm(code[tar:tar+0x30], tar))[4]
addr = int(inst.op_str.split(',')[-1], 0)
l.append((ch, addr))
return l
def ext64(addr):
#dis64(addr)
head = list(md64.disasm(code[addr:addr+0x80], addr))
# 3+5k
l = []
for i in range(10):
if head[3+5*i].mnemonic != 'cmp':
break
ch = int(head[3+5*i].op_str.split(',')[-1], 0)
tar = int(head[4+5*i].op_str, 0)
inst = list(md64.disasm(code[tar:tar+0x30], tar))[5]
addr = int(inst.op_str.split(',')[-1], 0)
l.append((ch, addr))
return l
flag = 'f4'
addr = 0x558
aset = set([0, 0x49e, 0x558])
switch = 0
def dfs(flag, addr, aset, switch):
if switch == 0:
# 32bit
extract = ext32
else:
extract = ext64
try:
ca = extract(addr)
except IndexError:
print "flag may be"
print 'npointer{'+flag+'}'
return
for c, a in ca:
if a not in aset:
dfs(flag+chr(c), a, aset | set([a]), switch ^ 1)
else:
#print('dead at',repr(flag))
pass
dfs(flag, addr, aset, switch)
|
代码最前面的修改部分是用来修改花指令的,但是使用int指令代替nop主要是因为int指令可以不改变一条指令的长度
感想
我好菜我好菜我好菜,出题人原话"本题作为逆向公开赛的第一题,其难度较低",哎继续努力吧.
补一个控制转移指令的总结
Call指令
一条call指令的字节数可以是(2,5,6)byte,对应不同的操作数,下面分情况讨论。
- 假设我们在函数src中调用函数dst,对应的call指令是的机器码长度是5 byte,其中第一个字节是e8代表指令,后面四个字节是一个相对偏移offset,dst=src_next+offset,其中src_next是下一条指令的地址,也可以看出是当前指令的地址加上该指令的长度。可以称这种情况为直接转移。
- 假设有一个全局函数指针变量g_pDst指向dst函数,我们在函数src中call这个变量,则对应的机器码长度是6 byte,前两个字节是ff 15代表指令,后面四个字节是一个绝对地址即变量g_pDst的地址。可以称这种情况为内存间接寻址。
- 假设函数src中有一个函数指针变量pDst指向dst函数,在函数src中call该变量,因为这个指针式保存在栈中的,编译时无法知道它的全局位置,只知道它相对于栈底的位置,所以根据pDst距离栈底ebp的距离。当距离小于0xff时,机器码长度为3,前两个字节是ff 55代表指令,后面一个字节代表相对ebp的偏移(注意偏移都是负数,比如f8,则ebp-8即为实际内存地址)。当距离大于0xff时,机器码长度为6,前两个字节为ff 95表指令,后四个字节是相对ebp的偏移,计算方法与上面相同。可以称这种情况为栈间接寻址。
- call寄存器,比如call eax、call ebx;还有call eax + 4。可以称这种情况为寄存器直接寻址。
- call寄存器间接转移,比如call [eax],call [ebx];以及call [eax] + 4。可以称这种情况为寄存器间接寻址。
注:对于第一种情况,还有短转移,也就是偏移为一个字节,但是有些编译器(如MSVC)不会编译这种短的call,总之现在比较少见。为什么没有call functionA + 10 这类代码呢,这是因为编译器在编译的时候会自动计算相加后的地址。
Ret指令
ret指令比较简单,只有两种情况,ret和ret n。
Jmp指令
直接的jmp分3种
Short Jump(短跳转)机器码 EB rel8
只能跳转到256字节的范围内
Near Jump(近跳转)机器码 E9 rel16/32
可跳至同一个段的范围内的地址
Far Jump(远跳转)机器码EA ptr 16:16/32
可跳至任意地址,使用48位/32位全指针
要注意的是,短跳转和近跳转指令中包含的操作数都是相对于(E)IP的偏移,而远跳转指令中包含的是目标的绝对地址,所以短/近跳转会出现跳至同一目标的指令机器码不同,不仅会不同,而且应该不同。而远跳转中包含的是绝对地址,因此转移到同一地址的指令机器码相同
条件转移指令
条件转移类指令与jmp指令的分类大体类似,但是有一个重要的区别就是,条件转移类指令只有直接转移,没有后面四种,所以条件转移类指令全部是相对偏移的,每种指令可分为长跳转和短跳转。