跳至内容

Jixun's Blog 填坑还是开坑,这是个好问题。

dasctf - luoing.exe 逆向题

在网上搞到了一个 CTF 逆向题,故分析一番。

该题用到了下述技术进行对抗:

  • 花指令(静态分析对抗)
  • 调试器检测(不绕过调试器检测会得到错误的答案)

※ 该题可以在文末的附件区获得,解压密码为 jixun.uk

初步试探 #

$ .\"luoing.exe"
欢迎来到DASCTF!请输入flag
123
长度错误

看起来就是一个很简单的命令行程序,读入一个字符串(通常为 CTF 提交题的答案),然后通过某个算法来检查该值是否正确。

使用调试器 x64dbg 扫一下字符串引用,快速定位到真实的 main 函数位于 004169D7 附近。

静态分析 #

简单整理下反编译工具提供的伪码,大意如下:

puts("欢迎来到DASCTF!请输入flag:");
scanf("%s", flag_buffer);
int key_length = strlen(flag_buffer);
if (key_length != 38) {
    puts("长度错误!");
    ExitProcess(0); // noreturn
}

※ 看起来是通过循环来输出每一个字符,因此能看到很多膨胀的代码。和逆向目标没有太大关联,跳过即可。

在此下面则是紧接了一段“花指令”,即不影响程序运行的无意义代码,用来增加逆向分析难度:

LAB_00416d23:
   PUSH      EBP            ; 备份 EBP 变量
   CALL      LAB_00416d29   ; 入栈,等价于 PUSH 0x00416d29

LAB_00416d29:
   POP       EBP            ; 出栈。 EBP ← 0x00416d29
   DEC       EAX            
   ADD       EBP,0x8        ; EBP ← 0x00416d31 [ 0x00416d29 + 0x08  ]
   PUSH      EBP            ; / 这两条指令等价于
   RETN                     ; \ 执行 "JMP EBP" 

   db        E8h            ; 花指令,反汇编工具可能会尝试将该指令
                            ; 解析为 5 字节长度的 "CALL ..." 指令
                            ; 而影响后续代码的解析

LAB_00416d31:
   POP       EBP            ; 还原一开始的 EBP 的值(恢复备份)

这么一大段代码,看着好像做了很多事,实则什么都没有干;有效的代码部分只有 DEC EAX,但 EAX 在之后还没有用到就被覆盖了。

继续分析后面:

for (int i = 0; i < 38; i++) {
    uint32_t value = next_rand() & 0xFF; // 只取最低 8 位
    if (signed(flag_buffer[i] ^ value) != secret[i]) {
        puts("这个错误!");
        ExitProcess(0); // noreturn
    }
}
puts("你得到了!");

secret 则是在这个函数开始时定义的一个数组,其等价 C 代码表示:

uint8_t secret[38] = {0xC2, 0x6C, 0xFC, 0xB3, 0xA7, 0x8E, 0xF5, 0x83,
                      0x81, 0xCB, 0xBF, 0xD5, 0xC2, 0xCE, 0x83, 0xBD,
                      0xE6, 0x6A, 0xA5, 0xA8, 0x44, 0xF5, 0x45, 0x59,
                      0x50, 0xD8, 0x03, 0x98, 0xF7, 0x74, 0x87, 0xAC,
                      0xC5, 0x3E, 0x38, 0x2F, 0x07, 0x68};

next_rand 是什么 #

通过对该函数内引用的全局变量进行交叉引用查询,可以顺藤摸瓜找到其位于 0x41124E 的初始化函数。当然,这里也藏了花指令。

LAB_004162a7:
    XOR  EAX,EAX        ; EAX = 0; ZF = 1
    JZ   LAB_004162ac   ; 等价于 JMP,因为此刻 ZF 标志位是 1
    db   0xE9           ; 妨碍反编译工具,将后续指令解析为
                        ; 5 字节长度的 JMP 指令。
LAB_004162ac:

db 0xE9 改为 nop 即可继续正常解析。

往下继续查看,发现该函数引入了一个“显眼”的常量 0x6C078965

LAB_004162fe:
    IMUL  EAX, ECX, 0x6c078965 ; EAX = ECX * 0x6c078965

将该值放到搜索引擎查询,并对照算法流程,最终确认为【梅森旋转算法(Mersenne Twister)】的一种实现。

继续对初始化函数进行交叉引用查询,发现它刚好就在定义 secret 值代码的正下方:

_00416968:
    mov eax, dword [0x421000]   ; g_seed(=0x044C)
                                ; mt19937 随机数种子
    push eax
    call mt19937_init

小插曲: 调试器检测 #

对的,这个 CTF 还有一个非常简单的调试机制。若是检测到调试器,会将用于初始化 mt19937 的随机数种子偷偷改为 0x07D1

void TlsCallback() {
    BOOL pDebuggerPresent = 0;
    CheckRemoteDebuggerPresent(GetCurrentProcess(),
                               &pDebuggerPresent);
    if (pDebuggerPresent) {
        g_seed = 0x07D1;
    }
}

夺旗 #

夺旗答案代码

mt19937.h 头文件:

#pragma once
#include <stdint.h>

// Mersenne Twister 19937
// 参考维基百科说明
//   https://en.wikipedia.org/wiki/Mersenne_Twister
struct MT19937Ctx {
  uint32_t index;
  uint32_t state[624];
};

void mt19937_init(struct MT19937Ctx *ctx, int seed) {
  ctx->index = 624;

  uint32_t value = seed;
  ctx->state[0] = seed;
  for (int i = 1; i < 624; ++i) {
    value = 0x6C078965 * (value ^ (value >> 30)) + i;
    ctx->state[i] = value;
  }
}

void mt19937_twist(struct MT19937Ctx *ctx) {
  for (uint32_t i = 0; i < 624; ++i) {
    uint32_t y =
        (ctx->state[i] & 0x80000000) | (ctx->state[(i + 1) % 624] & 0x7FFFFFFF);
    uint32_t xor_value = (y & 1) * 0x9908B0DF;
    ctx->state[i] = ctx->state[(i + 397) % 624] ^ (y >> 1) ^ xor_value;
  }
}

uint32_t mt19937_next(struct MT19937Ctx *ctx) {
  if (ctx->index >= 624) {
    mt19937_twist(ctx);
    ctx->index = 0;
  }
  uint32_t result = ctx->state[ctx->index++];

  result ^= result >> 0x0B;
  result ^= (result << 0x07) & 0x9D2C5680;
  result ^= (result << 0x0F) & 0xEFC60000;
  result ^= result >> 0x12;

  return result;
}

主程序:

#include "mt19937.h"
#include <stdio.h>

int main() {
  struct MT19937Ctx mt19937 = {};
  mt19937_init(&mt19937, 0x044C);

  uint8_t secret[38] = {0xC2, 0x6C, 0xFC, 0xB3, 0xA7, 0x8E, 0xF5, 0x83,
                        0x81, 0xCB, 0xBF, 0xD5, 0xC2, 0xCE, 0x83, 0xBD,
                        0xE6, 0x6A, 0xA5, 0xA8, 0x44, 0xF5, 0x45, 0x59,
                        0x50, 0xD8, 0x03, 0x98, 0xF7, 0x74, 0x87, 0xAC,
                        0xC5, 0x3E, 0x38, 0x2F, 0x07, 0x68};

  for (int i = 0; i < sizeof(secret); i++) {
    secret[i] ^= (uint8_t)(mt19937_next(&mt19937));
  }

  printf("flag: %.38s\n", (char *)&secret[0]);
}

执行解密代码,得到答案 DASCTF{welcome_reverse_it_is_so_cool!}

动态调试 #

本文虽然饶了这么一大圈来静态分析,但实际上动态调试其实是最快也最方便的夺旗方法。

调试器检测可以通过安装 ScyllaHide 来绕过或等程序启动后再附加亦可。

对程序本体进行一点点魔改:

; 使用 Multiline Ultimate Assembler 进行多行汇编,
; 一次性更改多处的多行指令

<$luoing.11E1D>
    jmp $$11E29 ; 顺便把调试器检测也干掉

<$luoing.16D5A>
@for_continue:

<$luoing.16D64>
    mov ecx, dword[ebp-0x288]   ; ecx = i
    xor al, byte[ebp+ecx-0x3c]
    mov byte[ebp+ecx-0x284], al

    inc dword[ebp-0x288]
    cmp dword[ebp-0x288], 0x30 ; 写多点问题不大
    jle short @for_continue

@do_nothing:
    ; 死循环; 方便之后附加
    ; 记得在此放下断点
    jmp short @do_nothing

键入符合长度要求的内容(如 12345678901234567890123456789012345678),等待调试器断下,然后在内存区域跳转到 ebp-284 处就能得到需要的旗帜了:

魔改程序代码使其在内存中暴露 flag 内容

新的挑战 #

根据这个 CTF 的题目,我也重新做了一个 Linux x86-64 平台适用的类似 CTF。

这个挑战没有加入花指令或调试器检测,可以用来练习。

挑战在附件区的 challenge.xz 中。


知识共享许可协议 本作品采用知识共享署名-相同方式共享 4.0 国际许可协议进行许可。

评论区