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
将该值放到搜索引擎查询,并对照算法流程,最终确认为【梅森旋转算法】的一种实现。
继续对初始化函数进行交叉引用查询,发现它刚好就在定义 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
处就能得到需要的旗帜了:
新的挑战
根据这个 CTF 的题目,我也重新做了一个 Linux x86-64 平台适用的类似 CTF。
这个挑战没有加入花指令或调试器检测,可以用来练习。
挑战在附件区的 challenge.xz
中。