敲响天堂之门
“天堂之门”介绍:
天堂之门技术(Heaven’s Gate)是依靠Windows提供的在不同位数CPU进行跨架构的指令调用(SysWoW64),使得32位和64位的指令环境可以放在同一个程序中,即在32位WoW64进程中执行64位代码,但是目前的调试器包括IDA在内很少有能跨架构调试的,这使得调试器的调试不能正常进行,以达到反调试的目的。
一个在动态调试层面恐怕是最有难度的逆向反调试
架构之间的切换:
首先我们要知道32位寄存器cs的值是0x23,64位cs寄存器的值是0x33,我们将cs的值在这两者间切换,便达到了跨架构运行程序的目的。
那么我们如何更改寄存器cs的值呢?
我们知道,mov指令是无法直接改变cs寄存器的值的,需要借助call
和retf
来实现cs段的切换
call far
和retf(ret far)
是可以同时改变cs和ip的:
1 |
|
或者使用ljmp(jmp far)
(仅在32位到64位时):
1 |
|
此外还有一种方式,在后面实现中将会提到;
这样就实现了将cs改为0x33的功能,即从32位跳转到64位。
对应的从64位跳转到32位的指令是:
1 |
|
天堂之门的实现建立在对cs的更改可以让程序在32位和64之间切换的基础上。
MSVC实现(Intel格式),推荐使用:
1 |
|
GCC实现(AT&T格式),不推荐用:
1 |
|
实现示例
前面只介绍了对cs的更改,即cpu架构的切换,但是在我们的exe编译完成后,汇编的架构已经确定(32位),我们需要另嵌入64位的opcode到源代码中,然后让天堂之门的入口指向我们的x64_opcode才可正常执行。
下面给出一个简单的示例:
环境配置:
- VSMicrosoft Visual C++ v.14 - 2015;
- ida64+32;
- Release版本;
- x86和x64编译器结合使用;
- 更改VS设置:
项目-项目属性中更改以下设置:
关闭随机基址:
-链接器-高级-随机基址:否保留未引用的函数/数据:
-链接器-常规-启用增量链接:否
-链接器-优化-链接时间代码生成:使用链接时间代码生成/LTCG
-链接器-优化-链接时间代码生成:引用:否关闭其他优化:
-C/C++-优化-优化:已禁用
-C/C++-代码生成-运行库:多线程(/MT)
-C/C++-高级-调用约定:__fastcall
代码实现:
用x64编译器编写一个简单的加密:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18#include <cstdio>
void __fastcall XOR(int& a) // 统一使用__fastcall调用约定,方便传参
{
a += 2;
a ^= 0x20;
}
int main()
{
int a = 0x30;
XOR(a);
printf("%d\n", a);
return 0;
}将编译出的exe放进ida64中扒出XOR函数的opcode:
shift+E,提取出opcode
将代码中的XOR函数用opcode替换,main函数不作改动,用x86编译器编译运行,再次放入ida(此时运行会出错,正常现象)。
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#include <cstdio>
void __fastcall XOR(int& a)
{
__asm {
_emit 0x48
_emit 0x89
_emit 0x4c
_emit 0x24
_emit 0x8
_emit 0x48
_emit 0x8b
_emit 0x44
_emit 0x24
_emit 0x8
_emit 0x8b
_emit 0x0
_emit 0x83
_emit 0xc0
_emit 0x2
_emit 0x48
_emit 0x8b
_emit 0x4c
_emit 0x24
_emit 0x8
_emit 0x89
_emit 0x1
_emit 0x48
_emit 0x8b
_emit 0x44
_emit 0x24
_emit 0x8
_emit 0x8b
_emit 0x0
_emit 0x83
_emit 0xf0
_emit 0x20
_emit 0x48
_emit 0x8b
_emit 0x4c
_emit 0x24
_emit 0x8
_emit 0x89
_emit 0x1
_emit 0xc3
}
}
int main()
{
int a = 0x30;
XOR(a);
printf("%d\n", a);
return 0;
}记录下这个32位程序的传参过程和opcode的起始地址,注意整个函数的起始地址跟我们的opcode的起始地址不同(也可以使用裸函数头避免这个问题)。
删去直接调用XOR的语句,使用
jmp far
进门,并提前push好返回地址(预留),再使用retf
出门,其他部分不变。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#include <cstdio>
#include <windows.h>
void __fastcall func(int& a)
{
__asm {
_emit 0x48 // <---- address: 40108A
_emit 0x89
_emit 0x4c
_emit 0x24
_emit 0x8
_emit 0x48
_emit 0x8b
_emit 0x44
_emit 0x24
_emit 0x8
_emit 0x8b
_emit 0x0
_emit 0x83
_emit 0xc0
_emit 0x2
_emit 0x48
_emit 0x8b
_emit 0x4c
_emit 0x24
_emit 0x8
_emit 0x89
_emit 0x1
_emit 0x48
_emit 0x8b
_emit 0x44
_emit 0x24
_emit 0x8
_emit 0x8b
_emit 0x0
_emit 0x83
_emit 0xf0
_emit 0x22
_emit 0x48
_emit 0x8b
_emit 0x4c
_emit 0x24
_emit 0x8
_emit 0x89
_emit 0x1
_emit 0xc3
}
}
int main()
{
int a = 0x30;
__asm {
_emit 0x8D // lea ecx, [ebp + a] ; a
_emit 0x4D
_emit 0xF8
}
__asm {
_emit 0x68 // push 00000000 因为64的retn弹出8字节,而32位下的push只入栈4字节,需要手动多push 4字节的填充
_emit 0x00
_emit 0x00
_emit 0x00
_emit 0x00
_emit 0x68 // push ******** 预留好的返回地址
_emit 0xAB
_emit 0xCD
_emit 0xEF
_emit 0xAB
_emit 0xEA // ljmp 0x33, 40108A (x64_opcode初地址)
_emit 0x8A
_emit 0x10
_emit 0x40
_emit 0x00
_emit 0x33
_emit 0x00
}
__asm {
_emit 0xE8 // call $+5
_emit 0x00
_emit 0x00
_emit 0x00
_emit 0x00
_emit 0xC7 // mov dword [rsp + 4], 0x23
_emit 0x44
_emit 0x24
_emit 0x04
_emit 0x23
_emit 0x00
_emit 0x00
_emit 0x00
_emit 0x83 // add dword [rsp], 0xD
_emit 0x04
_emit 0x24
_emit 0x0D
_emit 0xCB // retf
}
printf("%d\n", a);
return 0;
}32位编译后再次放进ida中,找到我们需要的返回地址,ida对
jmp far
的分析有误,不用在意注意这里变量a在内存中的地址改变了,我们也需要更改
完整代码:
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#include <cstdio>
void __fastcall XOR(int& a)
{
__asm {
_emit 0x48 // <--- address: 40108A
_emit 0x89
_emit 0x4c
_emit 0x24
_emit 0x8
_emit 0x48
_emit 0x8b
_emit 0x44
_emit 0x24
_emit 0x8
_emit 0x8b
_emit 0x0
_emit 0x83
_emit 0xc0
_emit 0x2
_emit 0x48
_emit 0x8b
_emit 0x4c
_emit 0x24
_emit 0x8
_emit 0x89
_emit 0x1
_emit 0x48
_emit 0x8b
_emit 0x44
_emit 0x24
_emit 0x8
_emit 0x8b
_emit 0x0
_emit 0x83
_emit 0xf0
_emit 0x20
_emit 0x48
_emit 0x8b
_emit 0x4c
_emit 0x24
_emit 0x8
_emit 0x89
_emit 0x1
_emit 0xc3
}
}
int main()
{
int a = 0x30;
__asm {
_emit 0x8D // lea ecx, [ebp + a] ; a
_emit 0x4D
_emit 0xFC // <--- 注意更改传参
}
__asm {
_emit 0x68 // push 00000000 因为64的retn弹出8字节,而32位下的push只入栈4字节,需要手动多push 4字节的填充
_emit 0x00
_emit 0x00
_emit 0x00
_emit 0x00
_emit 0x68 // push 4010E2 预留好的返回地址,指向jmp far后的第一条指令
_emit 0xE2
_emit 0x10
_emit 0x40
_emit 0x00
_emit 0xEA // ljmp 0x33, 40108A (x64_opcode初地址)
_emit 0x8A
_emit 0x10
_emit 0x40
_emit 0x00
_emit 0x33
_emit 0x00
}
__asm {
_emit 0xE8 // call $+5 <--- address: 4010E2
_emit 0x00
_emit 0x00
_emit 0x00
_emit 0x00
_emit 0xC7 // mov dword [rsp + 4], 0x23
_emit 0x44
_emit 0x24
_emit 0x04
_emit 0x23
_emit 0x00
_emit 0x00
_emit 0x00
_emit 0x83 // add dword [rsp], 0xD
_emit 0x04
_emit 0x24
_emit 0x0D
_emit 0xCB // retf
}
printf("%d\n", a);
return 0;
}运行验证结果:
其他实现
上面是使用了jmp far
进门方式,下面介绍另外两种进门方式:
利用call far
进门
1 |
|
这种方法需要注意的是:call far
会将cs一起入栈,一共入栈8字节,
所以我们直接改掉就好,也不需要再push 0了
利用retf
进门
1 |
|
这种方式最为简单,只需要预留好偏移量即可,但也最容易被发现。
以上三种跳转方式结合使用可以进一步增加天堂之门的逆向难度
出题技巧
- 单独起一个天堂之门跳转后检测调试,如果检测到调试更改掉 key(用反调试隐藏反调试);
- 利用花指令、SEH异常处理、SMC等混淆手段隐藏掉天堂之门入口处的跳转指令;
- 多种跳转方式结合使用;
实战
西湖论剑2023 Dual personality
这里对题目的分析更偏向于各种跨架构的跳转,具体的加密代码分析及逆向过程仅作简略说明
32位无壳,拖进ida,
不出意外一片红,在输入后下断点:
单步跳,4013E3处call完sub_401120这个函数后发现更改了从4013E8开始的后面一部分的内存,undefine + make code让ida重新识别后发现jmp far指令,这就是门的入口了:
那么sub_401120就是利用了SMC开门的函数,标记一下,
看字节码找到jmp到的位置:4011D0也就是上面push进栈的地址,
ida反汇编失败,因为jmp far已经更改了cs,所以4011D0这里都是x64的opcode了,ida32显然不能正确识别,
需要我们更改exe的魔术子,改用ida64分析,
PE文件里这个位置的010B是32位文件标志字,020B是64位文件标志字,我们把01改成02,放进ida64:
在这里更改基地址为400000,然后go 0x4011D0,C+P识别为函数:
看到这里检测调试,然后如果不在调试才给dword_407058赋正确的初始值,然后jmp到407000的内存内容的位置,
回到ida32中查看407000内存的内容:
那么这个4011D0函数运行完后会jmp到5E0000的位置,跳过去看看,
如果eax的值不为零就跳转到4013EF,那么下面就go 4013EF,
这里把dword_407058减去了一个值,然后继续jmp,继续跟,
401417这里是第一个加密函数,是对dword_407058做的操作,我们把dword_407058更名成key:
上面的操作执行完后会跳转到这个位置:
这里看到了我们熟悉的call far,跳转的目的地就是byte_40700C的内容:401200
可见这里也同时改了cs,是第二个门,我们在ida64中go 401200看第二段加密:
声明函数后可以看到简陋的反汇编,这里也判断了调试的标志位,那么正确的加密应该是else里的内容,
这个函数进行完后就直接retf了,我们回到上一张图的位置继续看:
又用到了我们第一次进门用的entry_gate函数,push的地址为401290,那么第三个门就是401290,到ida64中go 401290:
进行了一些位运算,注意这里的操作是错位的,
然后回到汇编层面:
把0x4014C5赋给dword_407000,那么5E0000的地方就是jmp到4014C5,
注意此时dword_407000里带着0x23,那么下次jmp就会把cs改回0x23也就是回到32位,
最后就是check flag了。