背景
在CTF中,逆向的玩法越来越多变,曾经出现过32位程序调用64位代码的情况,一般的静态分析和动态调试方法都会失效,让人十分头大,今天将通过2个案例来学习如何应对这种情况。
案例
2个案例包括1个windows程序和1个linux ELF程序,正好覆盖了2个常见的平台,下载地址 (提取码:nxwx)
- father and son (ELF),来源于2018年护网杯CTF
- GWoC (Windows),来源于2018年CNCERT CTF
基础知识
在x64系统下的进程是有32位和64位两种工作模式,这两种工作模式的区别在于CS寄存器。32位模式时,CS = 0x23;64位模式时,CS = 0x33。;
这两种工作模式是可以进行切换的,一般会通过retf指令,一条retf指令等效于以下2条汇编指令
pop ip
pop cs
如果此时栈中有0x33,则会将0x33弹出到CS寄存器中,实现32位程序切换到64位代码的过程。所以retf是识别32位程序调用64位代码的重要标志。
案例1:father and son
二进制文件father来自于一个流量包的内容(非本文焦点),是一个32位的ELF程序
$ file father
father: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=4351dc8fde1bd3404207e1540b84e3c577c81521, stripped
程序分析
核心代码如下
int sub_8048527()
{
signed int retaddr; // [esp+2Ch] [ebp+4h]
signed int retaddr_4; // [esp+30h] [ebp+8h]
if ( mmap((void *)0x1337000, 0x3000u, 7, 50, 0, 0) != (void *)0x1337000 )
{
puts("sorry");
exit(0);
}
if ( mmap((void *)0xDEAD000, 0x3000u, 7, 50, 0, 0) != (void *)0xDEAD000 )
{
puts("sorry");
exit(0);
}
memcpy((void *)0xDEAD000, &unk_804A060, 0x834u);
sub_80484EB(0xDEAD000, 0x834, 0x33); // sub_80484EB(内容,长度,异或的值)
retaddr = 0xDEAD000;
retaddr_4 = 0x33;
return MEMORY[0xDF7D000]();
}
用nmap开辟了两段RWX内存,并且将0x804A060的内容拷贝到其中一块RWX内存0xDEAD000处,并用sub_80484EB函数异或恢复代码。
最后的部分IDA没有识别出来,看汇编是用retf跳转到0xDEAD000处执行。
.text:08048629 C7 00 00 D0 EA 0D mov dword ptr [eax], 0DEAD000h
.text:0804862F 8D 45 E4 lea eax, [ebp+var_1C]
.text:08048632 83 C0 24 add eax, 24h
.text:08048635 89 45 E0 mov [ebp+var_20], eax
.text:08048638 8B 45 E0 mov eax, [ebp+var_20]
.text:0804863B C7 00 33 00 00 00 mov dword ptr [eax], 33h
.text:08048641 C9 leave
.text:08048642 CB retf
看到retf,又开到此时栈中有0x33,符合32位程序调用64位代码的模式。
执行分析
使用一般的逆向工具gdb,在0x08048642处设置断点
gdb ./father
pwndbg> b *0x08048642
Breakpoint 1 at 0x8048642
pwndbg> show architecture
The target architecture is set automatically (currently i386)
pwndbg> r
断点触发后,用ni单步执行指令执行下一步,可以看到指令已经跳转到0xDEAD000空间,CS寄存器的值从0x23变为0x33,进入64位代码的空间。
然而此时代码内容无法显示64位汇编
此时继续用ni单步执行指令,就会看到汇编指令没有一条条执行,而是几步一跳的执行,这是因为gdb认为这段代码是32位而不是64位的,即使使用set architecture i386:x86-64 命令,也会提示错误。
我也尝试过以下调试方法,均已失败告终。
- IDA+linux_server(IDA32位版本)进行调试,效果同gdb,无法识别64位汇编代码,可以单步执行,汇编指令也是几步一跳。
- IDA64+linux_server64(IDA64位版本),程序无法引导起来。
那么应该如何动态调试呢?
动态调试
为了可以正确执行64位指令,可以采用gdbserver+IDA64的调试方式。
gdbserver启动程序,并绑定到1234端口(冒号前不带ip使用本机ip)
gdbserver :1234 ./father
用IDA64打开程序,此时是无法使用F5查看伪代码的,但是可以看到IDA64识别了32位的程序,汇编能够正常显示。
在0x8048642的retf处设置断点,设置好连接gdbserver的参数(如图)
点击绿色三角形按钮启动调试,一次F9运行后,到达断点处。
再按F7进入64位代码,此时EIP显示已经进入了0xDEAD000,但是汇编窗口没有提示。即使使用G跳转到地址0xDEAD000也提示出错。
这是因为IDA和gdbserver连接时,内存并没有及时刷新导致。可以打开Debugger菜单中的Manual memory regions菜单项,右键Insert新建一个内存区域(这个动作每启动一次调试都要重新做)。
内存区域设置起始地址为0xDEAD000,结束地址默认即可,注意选择64-bit segment。
然后用G指令跳转到内存0xDEAD000,此时显示的是二进制数据。
按一下C识别为汇编指令,IDA调试器可以正确识别64位汇编,按F8单步执行也不会出现几步一跳的情况,可以正常调试啦。
注意1:gdbserver在一次调试结束后,第二次可能连接不上,需要kill掉再启动。
注意2:有的ELF程序可能并不需要Manual memory regions中增加内存区域,可以通过IDA的Edit->Segments->Change Segment Attributes修改内存为64位代码
静态分析
有了动态调试方法,还需要静态分析方法的配合,提高CTF中逆向的效率。
本案例采用了异或混淆,由于混淆不复杂,可以静态Dump出来异或恢复,也可以动态时再Dump出来。本文采用动态运行到retf指令时,利用脚本Dump出内存。
static main(void)
{
auto fp, begin, end, dexbyte;
fp = fopen("C:\father64.mem", "wb");
begin = 0xDEAD000;
end = 0xDEB0000;
for ( dexbyte = begin; dexbyte < end; dexbyte ++ )
fputc(Byte(dexbyte), fp);
}
在File菜单的Script Command菜单项中,选择IDC脚本,输入上述内容,点击Run按钮后就可以将0xDEAD000至0xDEB0000的内存导出到C盘的father64.mem文件
将father64.mem拖入IDA64进行静态分析,因为缺少ELF头,IDA64会提问选择哪种格式,此处选择64-bit mode分析代码。
此时代码基地址是0x0,可以用Edit->Segments->Rebase Segment重定义基地址,设置为0xDEAD000,这样动态调试时和静态调试时的汇编地址就一样了。
然后可以愉快的用F5生成C语言代码了。
逆向破解
由于本文侧重点在于如何识别和分析32位程序调用64位代码,因此案例的算法逆向篇幅部分会比较简略,有兴趣的朋友可以自行研究。
主流程sub_DEAD44B接收用户输入和输出结果,并且判断输入格式是否为hwbctf{…}。
__int64 __fastcall sub_DEAD44B(__int64 a1)
{
int v1; // eax
int v2; // eax
char v4; // [rsp+0h] [rbp-70h]
char v5; // [rsp+10h] [rbp-60h]
char v6[16]; // [rsp+20h] [rbp-50h]
char v7[19]; // [rsp+30h] [rbp-40h]
char v8[13]; // [rsp+50h] [rbp-20h]
int v9; // [rsp+6Ch] [rbp-4h]
v8[0] = 123;
...
v8[12] = 18;
sub_DEAD011(0x12, v8, 13); // v[i] ^= 0x12 恢复成为input_code:
sub_DEAD0D7(v8); // strlen
sub_DEAD073(); // write
sub_DEAD09C(22, v7, 0);
sub_DEAD05B(); // read
if ( sub_DEAD0D7(v7) > 18 ) // 长度大于0x12
{
v9 = 0;
v1 = sub_DEAD105(v7); // check input[:6] == 'hwbctf'
v9 += v1;
v9 += v7[6] != '{'; // check {}
v9 += v7[18] != '}';
v2 = sub_DEAD16F(&v7[7]); // 解方程,解得1'm n0t 4n5
v9 += v2;
if ( v9 )
{
sub_DEAD011(0x89, &v4, 12); // 此处有赋值,ida没有f5出来
sub_DEAD0D7(&v4);
sub_DEAD073();
}
else
{
sub_DEAD011(0xF1, &v5, 11);
sub_DEAD0D7(&v5);
sub_DEAD073();
}
}
else
{
v6[0] = 105;
...
v6[15] = 5;
sub_DEAD011(5, v6, 16); // length error!!!
sub_DEAD0D7(v6);
sub_DEAD073();
}
return sub_DEAD08B();
}
而sub_DEAD16F函数则是有13个方程组判断输入的内容
__int64 __usercall sub_DEAD16F@<rax>(_BYTE *a1@<rdi>)
{
int v1; // ST0C_4
v1 = ((a1[4] ^ a1[2] ^ a1[6]) != 119)
+ ((a1[1] ^ *a1 ^ a1[3]) != 54)
+ (a1[10] + a1[3] != 85)
+ (a1[2] + a1[9] != 219)
+ (a1[4] + a1[5] != 158)
+ (a1[2] + a1[1] + a1[5] != 196)
+ (a1[8] + a1[7] + a1[9] != 194)
+ (a1[5] + a1[3] + a1[9] != 190)
+ (a1[6] + a1[2] + a1[8] != 277)
+ (a1[10] + a1[1] + a1[7] != 124);
return (a1[5] != 48) + (a1[10] != 53) + ((a1[7] ^ a1[6] ^ a1[8]) != 96) + v1;
}
用Z3可以求解得flag为hwbctf{1’m n0t 4n5}
案例2: GWoC
GWoC是一个32位的Windows程序
原题程序中有较多花指令和反调试部分,利用0x90来nop掉,附件提供的是一个Patch后的代码
程序分析
将patch后的程序拖入IDA32位中,看到主流程如下
int __cdecl main(int argc, const char **argv, const char **envp)
{
const char *v3; // ST14_4
HANDLE v4; // eax
HANDLE v5; // eax
HANDLE v6; // eax
HANDLE v7; // eax
const char *v8; // eax
const char *v9; // edx
const char *v10; // edx
const char *v11; // edx
_DWORD *v13; // [esp+24h] [ebp-40h]
_DWORD *v14; // [esp+28h] [ebp-3Ch]
_DWORD *v15; // [esp+2Ch] [ebp-38h]
_DWORD *lpParameter; // [esp+30h] [ebp-34h]
BOOL Wow64Process; // [esp+3Ch] [ebp-28h]
DWORD ThreadId; // [esp+40h] [ebp-24h]
int v19; // [esp+44h] [ebp-20h]
int v20; // [esp+48h] [ebp-1Ch]
int v21; // [esp+4Ch] [ebp-18h]
HANDLE Handles; // [esp+50h] [ebp-14h]
HANDLE v23; // [esp+54h] [ebp-10h]
HANDLE v24; // [esp+58h] [ebp-Ch]
HANDLE v25; // [esp+5Ch] [ebp-8h]
if ( argc < 2 ) //程序判断是否有命令行参数
{
sub_C725E0("Error missing argument !n");
v3 = *argv;
sub_C725E0("%s inputn");
exit(0);
}
Wow64Process = 0;
IsWow64Process((HANDLE)0xFFFFFFFF, &Wow64Process);
if ( !Wow64Process ) //检测是否支持64位程序
{
sub_C725E0("System not supported ! Run me on 64bits Windows OSn");
exit(0);
}
if ( strlen(argv[1]) != 32 )
sub_C721C0();
sub_C721E0();
v4 = GetProcessHeap();
lpParameter = HeapAlloc(v4, 8u, 0x18u);
v5 = GetProcessHeap();
v15 = HeapAlloc(v5, 8u, 0x18u);
v6 = GetProcessHeap();
v14 = HeapAlloc(v6, 8u, 0x18u);
v7 = GetProcessHeap();
v13 = HeapAlloc(v7, 8u, 0x18u);
*lpParameter = 0xFAB; //初始化多线程参数1
lpParameter[1] = 0;
v8 = argv[1];
lpParameter[2] = *((_DWORD *)v8 + 2);
lpParameter[3] = *((_DWORD *)v8 + 3);
Handles = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)StartAddress, lpParameter, 0, &ThreadId); //启动多线程1
*v14 = 0xF0F0F0F0; //初始化多线程参数2
v14[1] = 0xF0F0F0F0;
v9 = argv[1];
v14[2] = *((_DWORD *)v9 + 4);
v14[3] = *((_DWORD *)v9 + 5);
v23 = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)StartAddress, v14, 0, (LPDWORD)&v19);//启动多线程2
*v13 = 0xF06B3430; //初始化多线程参数3
v13[1] = 0x136D7374;
v10 = argv[1];
v13[2] = *(_DWORD *)v10;
v13[3] = *((_DWORD *)v10 + 1);
v24 = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)StartAddress, v13, 0, (LPDWORD)&v20);//启动多线程3
*v15 = 0x43434343; //初始化多线程参数4
v15[1] = 0x434343;
v11 = argv[1];
v15[2] = *((_DWORD *)v11 + 6);
v15[3] = *((_DWORD *)v11 + 7);
v25 = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)StartAddress, v15, 0, (LPDWORD)&v21);//启动多线程4
WaitForMultipleObjects(4u, &Handles, 1, 0xFFFFFFFF); //线程通过
if ( lpParameter[4] != 0x7E352B1F || lpParameter[5] != 0x9B04D2D3 ) //判断线程1结果
sub_C721C0();
if ( v15[4] != 0x4D95D40C || v15[5] != 0xE14496F7 ) //判断线程4结果
sub_C721C0();
if ( v14[4] != 0x2E4CB743 || v14[5] != 0xA51E28EE ) //判断线程3结果
sub_C721C0();
if ( v13[4] != 1434694267 || v13[5] != 1991371616 ) //判断线程2结果
sub_C721C0();
sub_C71320();
return 0;
}
程序将32个字符的输入放入4个线程参数中,启动4个线程,每个线程都是调用同一个函数,只是参数不同。
.text:00C71330 ; DWORD __stdcall StartAddress(LPVOID lpThreadParameter)
.text:00C71330 StartAddress proc far ; DATA XREF: _main+191↓o
.text:00C71330 ; _main+1EC↓o ...
.text:00C71330
.text:00C71330 var_18 = dword ptr -18h
.text:00C71330 var_4 = dword ptr -4
.text:00C71330 lpThreadParameter= dword ptr 0Ch
.text:00C71330
.text:00C71330 push ebp
.text:00C71331 mov ebp, esp
.text:00C71333 push ecx
.text:00C71334 push ebx
.text:00C71335 push esi
.text:00C71336 push edi
.text:00C71337 mov [ebp+var_4], 0
.text:00C7133E mov ecx, [ebp+8]
.text:00C71341 push 33h
.text:00C71343 call $+5
.text:00C71348 add dword ptr [esp], 5
.text:00C7134C retf
.text:00C7134C StartAddress endp ; sp-analysis failed
.text:00C7134C
.text:00C7134D call loc_C72067
...
.text:00C72067 dec eax
.text:00C72068 mov [esp+8], ecx
.text:00C7206C dec eax
.text:00C7206D sub esp, 28h
.text:00C72070 dec eax
.text:00C72071 mov eax, [esp+30h]
.text:00C72075 dec eax
.text:00C72076 mov ecx, 97418529h
在这里又看到熟悉的push 33h和retf,就是进入64位代码的特征。进入的loc_C72067地址,无法正确识别64位汇编指令。
静态分析
因为这个案例中的代码可以静态dump出来,我们先进行静态分析。
使用案例1的Dump方法,拖入IDA64分析,可以恢复出代码,但会有一些内存引用的错误,这是因为缺少了上下文内存。
虽然也可以分析,但是在这个案例中,可以尝试使用更优雅的方式。
在010Editor中,用PE模板打开exe文件,偏移大概是0x118处,修改标识32位的0x10b为64位的0x20b。
然后放入IDA64中分析,Rebase Segment为0,再次看原来loc_C72067的地方(rebase后为0x2067),此时F5也可以识别出一些函数了,可以顺着分析sub_1C57和sub_1437等函数了。
signed __int64 __fastcall sub_2067(_QWORD *a1)
{
signed __int64 v1; // rax
unsigned __int64 v2; // rax
signed __int64 result; // rax
_QWORD *v4; // [rsp+30h] [rbp+8h]
v4 = a1;
v1 = *a1 ^ 0x1234567897418529i64;
if ( v1 == 0xE2C4A68867B175D9i64 )
a1[2] = sub_1C57(a1[1]);
if ( *v4 == 0xFABi64 )
v4[2] = sub_1437(v4[1]);
v2 = *v4 % 0x11111111111111ui64;
if ( v2 == 0x10101010101010i64 )
v4[2] = sub_1C37(v4[1]);
result = *v4 & 0x111000111000111i64;
if ( result == 0x101000010000010i64 )
{
result = sub_1F77(v4[1]);
v4[2] = result;
}
return result;
}
4个线程都是调用这个函数,但是由于输入参数的不同,会选取不同的函数调用。
例如之前*lpParameter = 0xFAB
对应的是这里的*v4 == 0xFABi64
判断 ,所以这部分输入调用的是sub_1437 函数,v4[1]就是实际输入串中第8个到第15个字符,即input[8:16] 。
进入分析sub_1437 ,发现是一个流式加密,根据F5的结果逆向比较复杂,还想结合动态运行结果进行逆向。
sbox[0] = ...
...
sbox[254] = 0xF9u;
sbox[255] = 0xF8u;
LOBYTE(v7) = 0;
memset(&v7 + 1, 0, sizeof(__int64));
for ( i = 0; i < 8; ++i )
*(&v7 + i) = *(&v9 + i);
v3 = v7;
for ( j = 0; j < 256; ++j )
{
v8 = sbox[(sbox[j % -8 + 248] + v3)];
v1 = *(&v7 + (j + 1) % 0xFFFFFFF8) + v8;
v3 = (v1 >> 7) | 2 * v1;
*(&v7 + (j + 1) % -8) = v3;
}
return v7;
动态调试
在WIndows下,IDA32、IDA64和Ollydbg这些调试器在retf指令执行后都无法正常运行,在师傅的指点下,采用windbg作为动态调试工具。
用Windbg 64位打开目标程序File->Open Executable,注意输入命令行参数
在View菜单打开Disassembly(汇编)、Registers(寄存器)、Memory(内存)和Command(命令)窗口,布局如下
一开始我们要在retf处设置断点,怎么设置呢?IDA中,rebase segment为0后,可以看到retf的地址为0x134c,所以在windbg的Disassembly窗口输入GWoC+0x134c,确定也是retf,按F9设置断点。
按F5执行到断点处,再按F8单步进入执行,此时CS寄存器可以看到已经变成0x33,进入64位代码块
此时再在我们想调试的sub_1437 函数加入断点,在Disassembly窗口输入GWoC+0x1437,按F9加断点,然后F5运行到断点处,就能愉快的开始调试了。
IDA也有链接Windbg的功能,但是本文所采用的IDA 7.0版本并未能成功连上windbg进行调试,只能IDA用于静态分析,Windbg进行动态调试,两边结合逆向。
逆向破解
以输入12345678901234567890123456789012为例
算法1
- 输入参数:0xfab
- 输入内容:input[8:16] = “90123456”
- 调用函数:sub_1437
- 算法内容:流式加密,根据结果逆推即可
- 逆向结果:F@AzOpFx
算法2
- 输入参数:0xf0f0f0
- 输入内容:input[16:24] = “78901234”
- 调用函数:sub_1C57
- 算法内容:低4位和高4位分开运算,多次位移和异或运算,可用暴力破解
- 逆向结果:Cq!9x9zc
算法3
- 输入参数:0x136D7374F06B3430
- 输入内容:input[:8] = “12345678”
- 调用函数:sub_1F77
- 算法内容:低4位和高4位分开进行快速幂取模操作,就是RSA,分解因数解密RSA即可
- 逆向结果:flag{RpC
算法4
- 输入参数:0x43434343434343
- 输入内容:input[24:] = “56789012”
- 调用函数:sub_1C37
- 算法内容:输入异或0x9C70A3C478EF826A,根据结果异或即可
- 逆向结果:fVz5354}
所有字符串拼接在一起得到flag{RpCF@AzOpFxCq!9x9zcfVz5354}
小结
本文通过windows和linux的案例,整理了32位程序调用64位代码的识别方法、静态分析和动态调试技巧。
识别方法
- retf是切换32位和64位的关键指令。
- retf前有push 0x33(33h)类似的指令。
push 33h
add dword ptr [esp], 5
retf
或者
mov dword ptr [eax], 33h
leave
retf
- retf后CS寄存器从0x23变为0x33。
- 程序中可能有进行支持64位的检查,如GWoC。
- 当一块可执行的内存,调试时无法识别汇编或者几步一跳时,有可能在是执行64位的代码。
- 32位代码调用函数的方式和64位代码有差异,32位程序大多通过入栈方式传参,64位程序一般用寄存器传参。
- 32位和64位的syscall的含义和参数有所不同。
静态分析
- 修改PE/ELF头位64位,让IDA64识别其中64位的部分代码。
- 静态/动态dump出内存中的64位代码片段,拖入IDA64分析代码。
- 有时候可以通过IDA中Change Segment Attributes 设置为64位,进行汇编分析。
- 使用Rebase Segment对齐基地址方便进行静结合分析
动态调试
- Linux ELF程序可使用gdbserver和IDA64的组合进行调试。
- Windows程序使用Windbg进行动态调试,使用IDA64进行静态分析,动静结合逆向。
参考
32位程序下调用64位函数——进程32位模式与64位模式切换
32位程序调用64位函数——开源代码rewolf-wow64ext学习笔记
ELF头结构
PE头结构