尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

x64dbg逆向实战:将汇编循环分支还原为C伪代码

x64dbg逆向实战:将汇编循环分支还原为C伪代码 做 x32dbg/x64dbg 逆向分析时最常被问的一句话是能不能把 exe 里的代码反向分析并还原成 C 语言先说结论能但不是把源码逐字变回来而是把汇编逻辑翻译成一份可读的伪 C 代码再对照调用约定和内存布局把结构补齐。这篇继续记录第 12 轮实操重点是用 x32dbg/x64dbg 还原带循环、分支、数组和指针的 C 函数并给出我自己的排查顺序。如果你刚学完 C 语言基础、想理解底层或者已经在做 CTF 逆向和程序行为分析这篇文章会比单纯背寄存器和指令更有用。1. 先想清楚反向分析还原C语言代码到底还什么1.1 还原目标不是“源码复刻”很多人第一次接触 x64dbg 反汇编时会期待屏幕上直接冒出一份和原来一模一样的 C 源码。真实情况不是这样。编译器把 C 代码翻译成机器指令后变量名、类型名、行号等信息通常都丢了只剩寄存器名、内存地址和指令。反向分析要做的是把这些指令重新组织成能理解逻辑的“伪 C 代码”比如for、if、while、memcpy、int、char*这些概念重新浮现出来。我一般会先和一起学的朋友对齐一个观点还原代码不是拍照而是翻译。比如下面的 C 循环for (i 0; i count; i) { sum arr[i]; }经过编译器优化后完全可能变成倒着循环mov ebx, count xor eax, eax loop_start: add eax, dword ptr [esi ebx*4 - 4] dec ebx jnz loop_start这两种形态做的事一样但直接对比汇编和上面的 for 写法你会觉得“完全对不上”。如果把目标定成“必须还原出 for 写法”很容易卡住如果定成“还原成语义等价的循环结构”就自然多了。判断语义等价的标准很简单给定相同输入程序执行到关键位置时变量值和分支走向一致。1.2 还原到哪一层就够用还原粒度要看场景。做算法分析时我通常只需要还原三层。第一层是控制流也就是函数由哪些分支、循环、调用组成。这一层能让你知道程序“做了什么步骤”。第二层是数据流也就是关键变量的来源和去向哪个参数被拷贝成局部变量哪个返回值经过计算哪块缓冲区被写到屏幕或磁盘。第三层是常量与调用关系比如字符串格式、比较阈值 10、缓冲区大小 64、调用了 printf 和 strncpy。这一层能直接对应到 C 代码里的%d、if (x 10)、char buf[64]。对第一次上手的人来说最值得先练的是“控制流 局部变量”的还原。不要一开始就挑战大型程序先从一两个函数开始。我自己的建议是把还原结果写成带注释的汇编片段再整理成伪代码最后再翻译成一份能编译的 C 代码。如果跳过了“带注释汇编”这一步很容易把内存当寄存器、把地址当值后面越改越乱。另外要提醒一个常见误区不要以为用了某款反编译插件、脱壳工具或静态分析器就能一键还原所有 C 代码。x32dbg/x64dbg 的价值不在于“自动反编译”而在于它允许你动态观察寄存器、栈、内存和调用关系。很多逻辑错误必须靠动态调试才能验证静态看汇编很容易猜偏。所以学习反向分析动态调试能力是绕不开的基本功。2. 环境和测试程序准备先搭好能复现的调试环境2.1 用自己编译的C程序练手最稳妥不要一上来就打开一个陌生文件开始分析。我建议先写一个非常小的 C 程序自己编译成可执行文件再加载到 x32dbg/x64dbg 里观察。原因很简单你知道源码分析结果对不对可以马上验证出错时也知道是自己分析错了还是工具问题。下面是我用来练还原的示例程序包含循环、分支、字符串、数组、指针和库函数调用比较适合入门#include stdio.h #include string.h int process(char *name, int count) { int i; int sum 0; char buffer[64]; strncpy(buffer, name, 63); buffer[63] \0; for (i 0; i count; i) { if (buffer[i] a buffer[i] z) { sum buffer[i] - a 1; } else { sum 1; } } return sum; } int main(int argc, char **argv) { char input[128]; printf(input: ); scanf(%127s, input); printf(sum%d\n, process(input, 127)); return 0; }这个程序逻辑不复杂读入一个字符串统计小写字母的“字母序号之和”其他字符每次加 1。比如输入abca贡献 1b贡献 2c贡献 3求和是 6。编译时建议先用默认优化甚至用-O0。如果一上来就-O2很多局部变量会被塞进寄存器循环会被优化成奇怪形态初学者很容易劝退。等基础模式熟悉了再开优化编译器继续练就会明显感觉到难度上升。2.2 32位和64位的选择会影响后续所有判断x32dbg 和 x64dbg 不是简单的“新版本和旧版本”关系它们分别调试 32 位和 64 位程序。加载文件前先在模块信息里确认目标架构选择错误的调试器打开轻则符号不完整重则断点地址解析出错。32 位程序在 Windows 上通常使用“调用者把参数压栈”的调用约定函数开头常见push ebp; mov ebp, esp; sub esp, xx。64 位程序参数传递优先用 rcx、rdx、r8、r9函数开头常见sub rsp, xx局部变量多时会分配比较大的一段栈空间。这些差异直接决定还原时看哪里。如果你手头只有一个 64 位测试程序就选 x64dbg如果找到的老教程是 32 位例子用 x32dbg。两条路线的指令名和窗口布局也几乎一样容易混但写注释时我习惯在地址前标一下位数比如64-bit: 0x140001000。这样后续整理数据库文件时不容易把两个架构的笔记搅在一起。2.3 关闭ASLR和编译优化减少地址漂移真实样本可能有随机基址每次启动模块基址都变手动记录的绝对地址隔一次重启就失效。学习阶段尽量给自己编译的测试程序关闭 ASLR或者在编译器链接选项里设置固定基址。不同编译器的参数不同这里不写死可以参考系统的“/DYNAMICBASE:NO”或“-no-pie”之类选项。如果做不了也没关系x64dbg 里可以看“基址偏移”的相对地址再把注释保存成数据库文件下次加载同一个 exe 时再导入。我把这一步排在前面是因为后面所有“记住这个地址”“在这里下断点”都建立在稳定地址上。如果每次启动地址都变又没保存数据库单步几轮下来自己先乱了。还有一个容易被忽略的点程序退出后调试器里的模块列表可能上下浮动复制地址时要看是相对偏移还是绝对地址否则下次重开时对不上。3. 定位main函数从系统入口到业务逻辑3.1 别从系统入口一路单步到底程序入口不是 C 的main。Windows 下的可执行文件通常会先经过 CRT 启动代码做环境初始化再调用main。如果你在入口处开始 F7 一路跟进会陷入大量不关心的系统初始化代码费时且容易迷路。更实用的方法是“跳着找”。一个有符号的测试程序可以直接在 x64dbg 的符号窗口里找main。如果符号被去掉就先找特征。我常用的特征有两个一是代码里引用的字符串二是调用的库函数。只要找到一个属于业务逻辑的地址就能从那里反向找到main。这里不要急着怀疑“我没符号怎么办”。还原 C 代码的常用路径是先确认可执行文件里有哪些可见业务逻辑再往上游找函数。没有符号反而能逼着你把调用关系和栈布局看仔细对初学者反而是好事。3.2 用字符串引用反推业务函数示例程序里有input: 和sum%d\n两个字符串。在 x64dbg 里打开内存窗口搜索 ASCII 字符串找到input: 所在地址。右键选“查找引用”或“交叉引用”调试器会列出哪些指令把该地址压栈或加载到寄存器。那条指令通常就在main函数内因为printf(input: )要把字符串地址作为参数传入。沿着那条指令往上滚动能看到函数开头的栈帧设置代码。再往下看通常能找到scanf、process和printf的调用位置。这样定位main比从头单步快得多。没有“查找引用”功能时也可以在scanf或printf的导入函数上下断点运行程序停住后打开调用栈窗口。调用栈会显示当前 API 是从哪个返回地址进来的逐层返回就能找到main。这套思路在去符号的真实现场更常见而且不依赖具体调试器版本。3.3 用调用栈和API断点确认调用关系当你已经找到某个关键函数但不确定它是不是main可以对比参数。32位下main通常有argc和argv两个参数调试时参数会出现在栈顶或者在调用前的push序列中。64位下main会从rcx、rdx读取参数但 CRT 启动代码可能先把它转存到栈上。所以不要只看一个点把调用关系反复确认一遍先在scanf下断点确认输入字符串保存在哪里再在process下断点确认第一个参数是刚才输入的缓冲区地址第二个参数是 127。如果都吻合你对main和process的边界判断就基本可靠了。如果遇到函数被内联调用栈里可能看不到某些中间层。这时候不要硬找先看“实际调用关系”而不是“源码函数关系”。很多优化代码里函数边界已经消失继续纠结哪一行属于哪个函数没有意义。4. 手把手还原带循环和分支的C函数4.1 先看函数开头和结尾把“壳”定下来定位到process后第一件事不是逐行读指令而是看函数的轮廓。可以先看三样东西入口处的栈分配、参数是从哪里读取的、返回前用什么寄存器带出结果。在 32 位场景里process开头可能像这样push ebp mov ebp, esp sub esp, 64hsub esp, 64h意味着栈上分配了 0x64 字节约 100 字节。示例代码里有 64 字节的buffer加上局部变量这个范围是合理的。函数结尾通常能看到leave或mov esp, ebp; pop ebp然后在ret前往eax写入一个值。那个值就是返回值。如果是在 64 位场景开头可能是sub rsp, 80h参数不再从[ebp8]读而是从rcx、rdx读。很多函数会在开头立即把参数保存到局部变量位可能是为了避免调用其他函数时寄存器被覆盖。还原时第一步就把这些转存位置标成arg1、arg2后面读起来会清晰很多。4.2 循环结构识别正向比较和倒计数两种模式示例中的循环for (i 0; i count; i)在未优化的编译结果里经常表现为“初始化-跳转判断-循环体-自增-再判断”的模式mov dword ptr [ebp-4], 0 ; i 0 jmp loc_check loc_loop: ; 循环体 inc dword ptr [ebp-4] ; i loc_check: mov eax, dword ptr [ebp-4] cmp eax, dword ptr [ebp8] ; i count jl loc_loop我识别循环时不是只找jmp而是找“计数器 比较 条件跳回”这三件套。如果编译器优化可能会改成倒计数模式用dec后判断jnz循环次数同样等于 count但初值和结束条件不同。这时不要硬说它是for还是while先描成“循环体执行 count 次”就正确了。还有一个细节循环体可能很短编译器会直接把计数器放在寄存器里比如esi、ecx。这时栈上不再有i变量从源码角度看仍然等价。还原时可以在注释里写“i 存在 ecx 中”不一定要强行构造一个int i。4.3 分支组合和||的汇编形态C 里的if (buffer[i] a buffer[i] z)翻译成汇编后不会是单条比较。常见写法是先判断第一个条件不成立就直接跳到else分支成立再判断第二个条件第二个不成立也跳到else两个都成立才走then分支。我在注释时习惯写成cmp byte ptr [eax], 61h ; buffer[i] a ? jl loc_else cmp byte
返回列表