不知道你有没有发现,在我们电脑的C盘里的windows文件夹下有一个SysWoW64,看起来像是一个开发者的感叹,他的功能是保存在64位电脑上仍需要运行的32位程序或者依赖。
这就引出了一个在动态调试层面恐怕是最有难度的逆向反调试--天堂之门(Heaven's Gate)
这一篇文章已经写了如何在C语言中使用天堂之门技术
https://bbs.kanxue.com/thread-270153.htm
所以在此我不会过多赘述实现机制,而是在反汇编层面进行解析
一、实现原理
天堂之门的实现主要依靠操作系统提供的在不同位数CPU进行跨架构的指令调用,这使得32位和64位的指令环境可以放在同一个程序中,但目前的调试器几乎没有能够跨架构的,所以程序能够标准的在操作系统中进行操作,但是在调试器中,当走到跨架构的指令时,就会因为指令无法识别而跳飞。
而在代码层面实现之后,在反汇编层面会有一些比较明显的指令,其中分为从64位跳转到32位,该种指令如下:
jmp far 33:地址
或者
push 0x33
call $+5
add dword [esp], 5
retf
我们稍微了解一下这些指令
首先jmp far和jmp的区别是jmp far会比jmp多执行一个指令,即在修改ip的值之外还会修改CS的值,修改的值就是far后面跟的数字,在硬编码层面表现如下:
EA 77 3F 41 00 33 00
可以看见,这个指令就是在jmp一个地址的硬编码后面加上四字节的数字,这个数字就是CS修改为多少,32位寄存器CS值是0x22,64位CS寄存器值是0x33。
在ida中,修改的值不会直接显示,我们需要设置option->general->Number of opcode bytes,将后面的框中的数字改为9或者其他值(这个数字的意思是每一行能够同时指令的几个硬编码)
下图是实例
这样就能在ida里面看见每条指令对应的硬编码了。
一旦理解了jmp far指令,push 0x33call $+5 和add dword [esp], 5 这三条指令理解起来就比较方便了
push 0x33
在栈中压入0x33,作为CS寄存器的新值。
call $+5
下一条指令的地址入栈,并继续执行下一条指令
add dword [esp], 5
栈顶的返回地址加5,指向retf的下一条指令
retf, 返回到下一条指令继续执行,同时会pop ip
和pop cs
32位转成64位的如下
call $+5
mov dword [rsp + 4], 0x23
add dword [rsp], 0xDretf
原理和上面的一样,利用retf将CS寄存器的值修改,这样就可以达到在程序实现32位代码和64位代码之间的转换。
二、例题
1、西湖论剑2023 Dual personality
下载附件,打开IDA,首先就发现一片数据和标红
在上面还调用了其他的函数,有一个函数是用来修改内存的,调试查看修改了什么
从f5反汇编之后得到的代码来看,这段代码不仅修改了返回地址,还修改了开辟出来的地址,返回地址,即call这个函数的下一个指令的地址在被修改之后变成了如下
这里是IDA反汇编错误,根据硬编码来看,应该是
jmp far 33:0x4011d0
所以这里天堂之门真正跳转的地址是0x4011d0,而4011d0也是我们刚才修改过内存的地址,我们直接G跳过去发现这段地址ida在乱编,显然没识别出来是什么指令
因为一开始用的是IDA32位,现在转到64位的环境无法反汇编,所以我们需要对程序的十六进制修改
红色框住的是PE结构的magic魔术字
说明文件类型:10B 32位下的PE文件 20B 64位下的PE文件
修改魔术字为20B,然后放到64位IDA中,修改基址为0x40000,会自动修复基址,之后直接G跳转到11d0
看上去就很正确了,这里有个反调试,gs:60h是PEBeginDebugging反调试位,如果检测到正在调试,该位为1,反之为0,这段代码在没有调试的情况下会把0x5DF966AE赋值给dword_407058。
之后跳转到0x1E0000,从这里在跳转回去减去0x21524111得到一个加秘密钥
得到key之后跳转到下面进行第一次加密
很容易分析并逆向出解密脚本
for (int i = 0; i < 8; ++i){
DWORD bak = ans[i];
ans[i] = (ans[i] - key) & 0xFFFFFFFF;
key ^= bak;
}
BYTE *p = (BYTE *)ans;
for(int i=0;i<0x20;++i)
printf("%c", *(p+i));
相同的跳转总的有3个
第二个
到40700地址上的值,直接返回去看32位程序的40700
跳转到了401200,而我们刚才就在64位的程序上看见了401200,P生成函数进行f5,就可以看见加密函数
这里也进行了PEBeginDebugging反调试,检测到调试器和没有检测到调试器的加密会不同,如下
第三个对应的跳转到401290,到64位上反汇编如下
所以总的解密代码如下
def xor_reverse(data, key):
for i in range(len(data)):
data[i] ^= key
return data
def rol_reverse(data, offset):
tmp_arr=[]
for i in range(4):
tmp_arr.append((data[i*2+1] << 32) + data[i*2])
for i in range(4):
tmp_arr[i] = ((tmp_arr[i] >> offset[i]) | ((tmp_arr[i] << (64-offset[i])) & 0xffffffffffffffff)) & 0xffffffffffffffff
for i in range(4):
data[2*i] = tmp_arr[i] & 0xffffffff
data[2*i+1] = tmp_arr[i] >> 32
return data
def deffusion_reverse(data, factor):
for i in range(len(data)):
tmp = data[i]
data[i] = (data[i] - factor) & 0xffffffff
factor ^= tmp
return data
def main():
cmp_data = [0xE20F4FAA, 0x549941E4, 0x7E842B2C, 0x788B8FBC, 0x5E8873D3, 0x708547AE, 0xCE09B331, 0xCA0DF513]
final_key = 0x4a827704
rol_offset = [0xc, 0x22, 0x38, 0xe]
diffusion_factor = 0x3CA7259D
flag_arr = xor_reverse(cmp_data, final_key)
flag_arr = rol_reverse(flag_arr, rol_offset)
flag_arr = deffusion_reverse(flag_arr, diffusion_factor)
print('DASCTF{', end='')
for i in flag_arr:
print(int.to_bytes(i, 4, 'little').decode(),end='')
print('}')
if __name__ == "__main__":
main()
#DASCTF{6cc1e44811647d38a15017e389b3f704}
灵感、解密脚本来源 https://kamasammohana.github.io/
2、DubheCTF 2024
从西湖论剑的题型可以看出,天堂之门这个反调试技术单独使用还是比较容易看得出来,还需要结合其他混淆和反调试技术一起使用,由于跨架构指令不能直接动调,所以逆向难度会极大的上升。
这个题就采用了SEH异常处理反调试+天堂之门的手法,隐藏掉天堂之门的入口指令和没有进入天堂之门前的一次加密,极大的提升了题目的难度。
打开附件,在主函数上有一个很明显的SEH异常处理函数
如果我们这个时候进行f5反编译,那么会是这样
但是根据汇编层面的观察,不可能只有这么点代码,往上面观察,可以看出程序一开始就加载了异常处理句柄
在接收了字符串之后马上写入了一个了try块,保存完关键地址后就int 3造成中断异常
异常之后当然是进行异常处理,而回调函数地址就是被push进栈中的loc_4140d7,这个地址被引用到异常处理结构体中
我们跳转到4140d7观察,看到很多数据,做题时以为是移动返回地址,复现时看了出题人的出题笔记才知道是花指令
下个硬断单步跟,但是首先要把其他隐藏在程序里面的花指令去掉,使用AntiDebuggerSeeker查就行
这一处是加载ntdll,直接把eax改成0
这一处是要把调用ZwSetInformationThread反调试的调用号给去掉,改成0
保存整个程序,利用OD进行调试,打开OD,f9到0x4140D7,快捷键Alt+O打开调试选项面板,在异常中去掉勾选忽略(传递给程序)以下异常:INT 3中断
这样int 3中断之后不会跳飞,这个时候再下一个断点到异常处理回调函数4140d7上,就可以单步调试代码了
调试几步后发现压入了一些数,0x32在后面可以知道是循环次数,0x5B4B9F9E很明显是一个常数,这个时候就可以猜测是TEA族
这里是将输入按照四字节分组压入栈
左移5位
左移2位
进行异或
看到这里就已经知道是XXtea了,到这里需要找到密钥key,重新打开IDA,在密文下面翻得到
由于栈展开之后会回调两次异常处理函数,所以这里的xxtea也会执行两次
到这里总算算是把入天堂之门前的加密代码解析成功,之后回调函数回来时执行except块,也就是
这里就是典型的门了,直接转x32Dbg,打开sharpOD,把能勾选的反反调试全勾上
然后一路调试到jmp far这个指令,直接跳转进jmp far后面的地址
将该地址用scylla dump下来放进ida就可以看到门后的加密了
可以看出是crc算法
所以加密算法是两次xxtea+一次crc,直接解密就行,exp如下
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#define MX (((z>>5^y<<2) + (y>>3^z<<4)) ^ ((sum^y) + (key[(p&3)^e] ^ z)))
#define DELTA 0x5B4B9F9E
void crcbreak(uint32_t* flagbuf, int key) {
for (int i = 0; i < 12; i++) {
uint32_t enc_num = *(flagbuf + i);
for (int j = 0; j < 32; j++) {
if (enc_num & 1)
{
enc_num ^= key;
enc_num /= 2;
enc_num |= 1 << 31;
}
else enc_num /= 2;
}
//printf("0x%X, ", enc_num);
//0x9E549543, 0x5E7CB348, 0xD9A84A2F, 0x85EB99DE, 0xB6825884, 0xC4F74EA1, 0x22B1828A, 0x290D7296, 0x198EE473, 0x9655B529, 0x38AC196A, 0x192B6236,
*(flagbuf + i) = enc_num;
}
}
void btea(uint32_t* v, int n, uint32_t const key[4])
{
uint32_t y, z, sum;
unsigned p, rounds, e;
//加密
if (n > 1)
{
rounds = 6 + 52 / n;
sum = 0;
z = v[n - 1];
do
{
sum += DELTA;
e = (sum >> 2) & 3;
for (p = 0; p < n - 1; p++)
{
y = v[p + 1];
z = v[p] += MX;
}
y = v[0];
z = v[n - 1] += MX;
} while (--rounds);
}
//解密
else if (n < -1)
{
n = -n;
rounds = 0x32;
sum = rounds * ((~DELTA) + 1);
y = v[0];
do
{
e = (sum >> 2) & 3;
for (p = n - 1; p > 0; p--)
{
z = v[p - 1];
y = v[p] -= MX;
}
z = v[n - 1];
y = v[0] -= MX;
sum -= ((~DELTA) + 1);
} while (--rounds);
}
}
int main() {
uint32_t flagbuf[] = {
0xA790FAD6, 0xE8C8A277, 0xCF0384FA, 0x2E6C7FD7, 0x6D33968B, 0x5B57C227, 0x653CA65E, 0x85C6F1FC,
0xE1F32577, 0xD4D7AE76, 0x3FAF6DC4, 0x0D599D8C
};
int key = 0x84A6972F;
uint32_t const k[4] = { 0x6B0E7A6B, 0xD13011EE, 0xA7E12C6D, 0xC199ACA6 };
crcbreak(flagbuf, key);
btea(flagbuf, -12, k);
btea(flagbuf, -12, k);
puts((const char *)flagbuf);
printf("\n\n");
return 0;
//DubheCTF{82e1e3f8-85fe469f-8499dd48-466a9d60}
}