3

dasctf - luoing.exe 逆向题

 9 months ago
source link: https://jixun.uk/posts/2023/dasctf-luoing/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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 处就能得到需要的旗帜了:

dasctf-luoing

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

新的挑战 #

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

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

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



About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK