ctfshow:武穆遗书
REVERSE 武穆遗书
这题前前后后花了快三天时间才琢磨出来的,翻了好多个WP,不认真写个WP放松一下都对不起自己
0x00 整体思路
- 识别UPX,脱壳
- 找到主函数,大致阅读主函数逻辑
- 找到关键压缩数据,解压后得flag
附加:动态调试发现压缩数据并没有被成功解压,通过修正输出缓冲区长度,最后在缓冲区查看到解压后得flag
0x01 UPX脱壳
打开一看,要我输入一个密码,那还说啥了,直接进IDA看看吧……完了无从下手,咋回事
看一眼WP,呜呜🥹,要脱壳,我是飞舞不会,完蛋了(
学完回来,看一眼文件头:

鉴定为UPX加壳,如果魔改了那再说,先用UPX工具脱壳:
cmd
upx.exe -d your_program
0x02 阅读主函数逻辑
现在再进我的IDA就能正常找到main了,也能分析出函数(之前只有一个start,根本看不懂)

直接通过字符串定位核心判断代码,继续开心的逆向~
c
int __cdecl main(int argc, const char **argv, const char **envp)
{
const char *v4; // [esp+0h] [ebp-3Ch]
_BYTE v5[4]; // [esp+4h] [ebp-38h] BYREF
char v6[52]; // [esp+8h] [ebp-34h] BYREF
sub_4011D0();
sub_401200();
sub_401280();
if ( sub_401040() )
exit(0);
v4 = (const char *)operator new(0x1Cu);
sub_401390(v4, v5, &unk_4070F4, 28);
if ( sub_4010E0() )
exit(0);
while ( 1 ) // 前面的初始化不用管,根据我们的推测,询问密码绝对跑到了
{
printf("input the password less than 50 char:\n");
gets(v6);
fflush((FILE *)iob[0]._ptr);
if ( !strcmp(v4, v6) )
break;
printf("password error!!! please try again!\nyour input is %s \n", v6);
}
printf("win!!!the password and your input are all %s\n", v6);
system(Command);
return 0;
}
以下是我分析时的心流及踩坑记录:
初步分析一下,感觉可以先试下直接动态调试改条件,说不定会直接给我flag
接着发现有反调试,在IDA直接退出了,看来前面初始化的部分必须检查一下(
我先进第一个函数sub_4011D0看看:
c
int sub_4011D0()
{
int result; // eax
if ( (unsigned __int8)sub_401190() ) // 这里有个退出进程的判断
ExitProcess(0);
result = sub_4011B0();
if ( (_BYTE)result == 112 )
ExitProcess(0);
return result;
}
发现两个反调试的判断,判断成功会直接退出进程,我们在这里下断点,调试的时候手动修改ZF标志就行了
c
uint8_t sub_401190()
{
return NtCurrentPeb()->BeingDebugged;
}
进第一个就会发现这是标准的PEB反调试,读取进程环境块PEB的BeingDebugged字段,如果被调试就返回1,这里得下个断点
然后下一个函数:
c
char sub_4011B0()
{
return NtCurrentPeb()->NtGlobalFlag;
}
这也是个标准的PEB反调试,NtGlobalFlag默认为0,在被调试的时候第一个字节会被置为0x70,就是条件中的112
现在确定两个反调试了,我们动态调试绕过后,但是还是进不去,说明还有,再读下一个函数(
c
HMODULE sub_401200()
{
HMODULE result; // eax
int (__stdcall *v1)(_DWORD, _DWORD, _DWORD, _DWORD, _DWORD); // esi
HANDLE CurrentProcess; // eax
int v3; // [esp+14h] [ebp-4h] BYREF
v3 = -1;
result = LoadLibraryA(LibFileName);
if ( result )
{
result = (HMODULE)GetProcAddress(result, ProcName);
v1 = (int (__stdcall *)(_DWORD, _DWORD, _DWORD, _DWORD, _DWORD))result;
if ( result )
{
CurrentProcess = GetCurrentProcess();
result = (HMODULE)v1(CurrentProcess, 7, &v3, 4, 0);
if ( !result )
{
result = (HMODULE)v3;
if ( v3 )
exit(0);
}
}
}
return result;
}
这个就不管那么多细节了,反正吧exit给绕过就行了,其他内容我就不管了,但是最后还是出现一个神人报错:

这里程序试图向一个不能被写的内存写入,但是没调试的时候正常能跑,又是反调试,气死我了,继续往后看:
c
void sub_401280()
{
SetUnhandledExceptionFilter(TopLevelExceptionFilter);
MEMORY[0] = 0;
JUMPOUT(0);
}
这个感觉就是导致我内存出问题的地方,好像是一种异常处理反调试
可以看到,设置完异常处理器后直接来一个报错的操作——下面两行伪代码在汇编中如下:
asm
xor eax, eax
mov [eax], eax
jmp eax
xor将eax置零后,直接将eax写入0x00000000地址,但这是个无效地址,所以会异常,如果程序是正常打开的,会自动跳转到之前设置好的异常处理器,但是如果程序在调试器中运行,调试器会先捕获异常,异常处理函数不会被调用,程序行为异常
我们看一下TopLevelExceptionFilter(顶级异常筛选器)是怎么写的:
c
LONG __stdcall TopLevelExceptionFilter(struct _EXCEPTION_POINTERS *ExceptionInfo)
{
ExceptionInfo->ContextRecord->Eip += 4;
return -1;
}
ExceptionInfo->ContextRecord相当于发生异常时的快照,其中EIP是异常指令的位置(本质是程序执行到的位置),而:
EIP += 4
相当于在原本发生异常的位置往前4bit,相当于跳过了发生异常的地方了,接着:
return -1
这一步对应EXCEPTION_CONTINUE_EXECUTION,意思是继续执行程序,并且使用我刚才修改过的 ContextRecord,实际上这个异常处理就是在做一个跳转的工作
所以我们不要让我们的调试器接管这个工作,我们让程序自己的异常自己处理,不要由调试器接管,我们看前面展示的报错窗口,过exc.code(异常代码)为:0xC0000005
我们在IDA设置里把这个的接管关掉,改成程序接管:

我这个版本的IDA在Debugger的option中的expection中改就好了,以后可以根据异常代码来找应该关掉哪个
注意:以上改IDA设置的方法都是狗屁通教的,每次开会自动还原配置,但是这个方法一点用没有,最后还是选择直接nop
关于nop也是很讲究的,我搜到一个WP教我直接把:
c
MEMORY[0] = 0;
JUMPOUT(0);
这两行nop,但实际上在IDA中这是这样的:

md,这个jmp和endp是连在一块的,全都是00401295,改了就全都被改,直接一直报错到死,我一开始就是卡在这一步,而正确的操作应该是这样的:

在这个函数还没被call之前,直接把这个call给nop掉,注意计算字节数,这里要补5个nop,直接这样patch就好了:


最后变成这样,中途会报错一次:

我们直接OK,IDA会带我们去出错的地方,把那一行给nop掉就好了:

nop后继续步进,就可以跑到v4创建堆区的地方了,改完步进会提示异常,直接选择不处理Discard就好了

接下来就可以一路正常步进看代码实际是怎么跑的了,这里我们会发现一个关键的数据:

他被一个关键的函数处理:sub_401390

能看出是个经典的zlib压缩数据的开头78 DA
这个时候把这个数据丢赛博厨子,一下子就得到flag了:
78 DA 4B CB 49 4C AF 4E CB 4D 8B CF 2C 8E 4F 2F 4A 4D 2C 51 AC 65 00 55 19 07 9A

但是此时我还发现其他大佬用动态调试的方法过了,我在想,这个数据未必每次我都能够解密,所以还是需要试下动态调试怎么解出来
于是又折腾了两天(
0x03 踩坑
官方自带的题解没有图,对刚做题的我理解起来太难了,所以我先去找有图的题解,后面回来发现官方的题解写的真好
之前异常处理反调试卡了我很久,于是我去翻大佬的博客写的,希望可以找到解决方法,发现我们可以先运行程序,等程序跑到等待输入密码的位置,再用IDA来attach调试,这样就可以绕过前面那些烦人的反调试了,接下来就是要找到flag,而flag有一步v4和v6的对比,我们发现v6是我们的输入,那接着我们只要在程序里找v4就行了
然后我费劲找到了这个缓冲区的位置,然后断点跑完解压函数去看(这个时候是猜测),结果发现缓冲区的数据根本没有被解压,还以为是找错解压函数了
这是一个很大的坑,这个思路把我带歪了,实际上,这个压缩的数据根本就没有被成功解压!,那些说能在EAX直接看到flag的,要么是写的假WP,要么是把修复缓冲区的过程故意省略了!
这些WP都不太靠谱啊
0x04 修复输出缓冲区
折腾到第三天了,本来想放弃了,但是突然翻了一下原本官方的题解,发现自己看得懂了,思路瞬间清晰了!
我到这里才意识到(这个时候我已经读了一遍解压函数的大致内容了),我找的解压函数可能没有错,只是可能解压失败了而已!
c
int __cdecl sub_401390(int a1, int *a2, int a3, int a4)
{
int v4; // eax
int result; // eax
int v6; // esi
int v7; // [esp+8h] [ebp-38h] BYREF
int v8; // [esp+Ch] [ebp-34h]
int v9; // [esp+14h] [ebp-2Ch]
int v10; // [esp+18h] [ebp-28h]
int v11; // [esp+1Ch] [ebp-24h]
int v12; // [esp+28h] [ebp-18h]
int v13; // [esp+2Ch] [ebp-14h]
v8 = a4;
v7 = a3;
v4 = *a2;
v9 = a1;
v10 = v4;
v12 = 0;
v13 = 0;
result = sub_401580(&v7, a123, 56);
if ( !result )
{
v6 = sub_4015A0(&v7, 4);
if ( v6 == 1 )
{
*a2 = v11;
return sub_402DF0(&v7);
}
else
{
sub_402DF0(&v7);
if ( v6 == 2 || v6 == -5 && !v8 )
return -3;
else
return v6;
}
}
return result;
}
我试过用断点调试猜测的解压函数,v6的返回值是FFFFFFFB,也就是-5,当时觉得奇怪,但是改成1也没啥效果,看完题解觉得自己唐爆了,这个应该是返回的状态吗,没有1说明失败了了啊,改这个有啥用
这个时候思路转变成:查找失败原因

继续在这个位置下断点,之前就是在这个位置发现EAX的值为-5,这个时候我要看传入sub_4015A0的到底是什么数据!

这里不难看出来,edx中的数据和4被推入栈给sub_4015A0作为参数使用,我们再步进一步,看看edx里面有什么

大概是这样的一些东西,由于是引用的方式,这里这一大片内存都可能是传入的数据,我们进函数看看怎么用的:

我们可以看到这里面基本就是一个DWORD为一个数据,具体是什么先别管,反正差不多是这样划分的,再回到edx指向的地址,我们看看Hex view:

从F4开头的一段Hex开始,应该都是数据,DWORD为size,以小端序排布,之前我们看反汇编的时候知道,这样连续的东西不是数组就是结构体,但是数组不太可能,我们直接搜下结构体:

好搜,既然z_stream是核心,那就让我们看一下
c
typedef struct z_stream_s {
Bytef *next_in; // 指向下一个输入字节
uInt avail_in; // 输入缓冲区中可用字节数
uLong total_in; // 已处理的输入字节总数
Bytef *next_out; // 指向下一个输出字节位置
uInt avail_out; // 输出缓冲区剩余空间
uLong total_out; // 已生成的输出字节总数
char *msg; // 错误信息(仅供调试)
struct internal_state *state; // 内部状态(用户不可直接访问)
alloc_func zalloc; // 内存分配函数指针
free_func zfree; // 内存释放函数指针
voidpf opaque; // 传递给 zalloc/zfree 的参数
int data_type; // 数据类型(binary/text)
uLong adler; // Adler-32 校验值
uLong reserved; // 保留字段
} z_stream;
长这样一个个算下来,总共应该是38h个字节,首先对齐前两个,确实一个是输入的地址(压缩的flag),下一个是可用字节数1Ch,算下来是28字节,跟前面有一次填的长度一样,应该没错,我们就当就是这样的,一个个对着看,第四个也确实是输出位置的地址(这是我之前踩坑的时候发现的,当时这个位置的数据一直没变化,所以卡住了),第五个是0,这不对啊,输出空间怎么可以是零呢,难道问题出在这里?
让我们修复一下

这个时候再继续往后跑,会发现EAX的值变成1了,说明解压成功了,这个时候再跳转到输出的地址,就可以看到flag了!

找到了,好开心!
评论