ctfshow:武穆遗书

reverseyulabu2026-09-28 18 次阅读

REVERSE 武穆遗书

这题前前后后花了快三天时间才琢磨出来的,翻了好多个WP,不认真写个WP放松一下都对不起自己

0x00 整体思路

  • 识别UPX,脱壳
  • 找到主函数,大致阅读主函数逻辑
  • 找到关键压缩数据,解压后得flag

附加:动态调试发现压缩数据并没有被成功解压,通过修正输出缓冲区长度,最后在缓冲区查看到解压后得flag

0x01 UPX脱壳

打开一看,要我输入一个密码,那还说啥了,直接进IDA看看吧……完了无从下手,咋回事

看一眼WP,呜呜🥹,要脱壳,我是飞舞不会,完蛋了(

学完回来,看一眼文件头:

image-20260923092700241

鉴定为UPX加壳,如果魔改了那再说,先用UPX工具脱壳:

cmd 复制代码
upx.exe -d your_program

0x02 阅读主函数逻辑

现在再进我的IDA就能正常找到main了,也能分析出函数(之前只有一个start,根本看不懂)

image-20260923093322520

直接通过字符串定位核心判断代码,继续开心的逆向~

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给绕过就行了,其他内容我就不管了,但是最后还是出现一个神人报错:

image-20260924094934400

这里程序试图向一个不能被写的内存写入,但是没调试的时候正常能跑,又是反调试,气死我了,继续往后看:

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设置里把这个的接管关掉,改成程序接管:

image-20260924195156585

我这个版本的IDA在Debugger的option中的expection中改就好了,以后可以根据异常代码来找应该关掉哪个

注意:以上改IDA设置的方法都是狗屁通教的,每次开会自动还原配置,但是这个方法一点用没有,最后还是选择直接nop

关于nop也是很讲究的,我搜到一个WP教我直接把:

c 复制代码
MEMORY[0] = 0;
JUMPOUT(0);

这两行nop,但实际上在IDA中这是这样的:

image-20260926153424962

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

image-20260926153044347

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

image-20260926153342674
image-20260926153736067

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

image-20260926153829252

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

image-20260926153902158

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

image-20260926154123841

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

image-20260925194543047

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

image-20260925194659814

能看出是个经典的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
image-20260926143907881

但是此时我还发现其他大佬用动态调试的方法过了,我在想,这个数据未必每次我都能够解密,所以还是需要试下动态调试怎么解出来

于是又折腾了两天(

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说明失败了了啊,改这个有啥用

这个时候思路转变成:查找失败原因

image-20260926160810768

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

image-20260926161521886

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

image-20260926162155764

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

image-20260926163633338

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

image-20260926163858220

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

image-20260926170920370

好搜,既然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,这不对啊,输出空间怎么可以是零呢,难道问题出在这里?

让我们修复一下

image-20260926172310202

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

image-20260926150425579

找到了,好开心!

评论

正在加载评论区…