十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用x32dbg/x64dbg从反汇编逆向还原C语言代码实战

用x32dbg/x64dbg从反汇编逆向还原C语言代码实战 前面几篇已经讲过了 x32dbg/x64dbg 的载入、断点、单步和内存查看这次要解决一个更实际的问题拿到一段反汇编代码之后怎么把它还原成看得懂的 C 语言代码。这个系列走到第 11 篇核心已经不再是怎么操作调试器而是怎么用调试器逆向分析程序的逻辑再从汇编反推 C 语言的变量、数组、循环、分支和函数调用。这适合人群很明确正在学 C 语言、想理解编译器产物的人刚接触逆向、对着反汇编窗口不知道怎么下手的人做 CTF 逆向题或者需要分析某个函数行为的安全方向初学者。最值得关注的点是还原 C 代码不是逐行翻译而是先建立“编译器生成模式”的概念再顺着栈布局、寄存器传递、跳转结构把逻辑重新拼出来。这篇文章我就按实际分析顺序拆一遍最后还会讲怎么用 x64dbg 的补丁和条件断点验证还原结果。1. 先搞清楚一件事还原 C 代码到底在还原什么1.1 目标不是复制原始源码而是得到行为等价的可读逻辑很多新手拿到反汇编第一反应是想“逐行翻译回源码”这个方向从一开始就错了。C 代码经过编译之后变量名、函数名、行号、注释全部丢失编译器还会做常量折叠、寄存器分配、指令重排。就算同一个函数用两个优化级别编译反汇编结果都会差很多。所以逆向还原的真实目标是恢复出“和原程序行为等价但结构清晰、可读性高”的伪 C 代码。变量名可以自己起用 a、b、c 甚至 var_4、var_8 都可以只要最终能说明白程序在干什么。我通常的标准是写出一个函数输入输出一致关键分支和循环的位置正确就算还原成功。1.2 编译器生成的机器码有固定规律C 语言代码在 x86/x64 汇编里有很强的模式感因为它们都是同一套调用约定、栈帧规则和控制流翻译方式。看 32 位程序时最常见的是这种栈帧开头push ebp mov ebp, esp sub esp, 0x40这两行压入 ebp 再让 ebp 等于 esp就是为了建立栈基址。之后的局部变量全部通过ebp - 偏移访问参数则通过ebp 偏移访问。这就是为什么逆向 C 代码比逆向乱序后的二进制更“有迹可循”。64 位程序用的是 x64dbg参数传递主要靠 rcx、rdx、r8、r9局部变量访问相对 rbp 做偏移结构上依然能看出栈帧的影子。所以先判断是针对 32 位程序还是 64 位程序选对调试器后面还原才顺。1.3 x32dbg 和 x64dbg 在还原过程中的分工x32dbg 和 x64dbg 是同系列调试器x32dbg 负责 32 位程序x64dbg 负责 64 位程序。它们的功能基本一致都能看反汇编、寄存器、栈、内存、设置断点、修改指令、打补丁。在还原 C 代码时调试器不只是用来“看指令”的更重要的是提供动态验证。比如静态看反汇编时你猜某个jz跳转是if (x 0)但不敢确定 jz 前面的条件是什么就可以在跳转处下断点运行到那里直接看寄存器标志位或者看当前比较的两个值到底是多少。这个步骤能把“猜”变成“确定”。1.4 一个完整的还原流程应该长什么样子我一般按这个顺序走载入程序定位到要分析的函数入口。一眼扫过 prologue判断栈帧大小和局部变量空间。按地址顺序记录所有对ebp/rbp - 偏移的读写整理出变量表。圈定循环结构找到计数器增减、条件比较、跳转回跳点。圈定分支结构找到 test/cmp 和对应的 jcc 指令。识别函数调用看调用前参数怎么传入调用后返回值怎么接收。用调试器下断点逐段确认比较值、跳转方向、数组下标和结果输出。把整理结果写成 C 语言代码再编译对比行为。这个流程不是一次性完成的通常会在第 7 步反复调整第 3 到第 6 步的猜测。后面我用一个完整样例展开。2. 准备一个能练手的样例从入口开始跟2.1 我用的样例程序为了演示我用了一个很简单的教学程序然后把它编译成 32 位程序再用 x32dbg 打开分析。你也可以自己编译一个同类程序或者找一个 CTF 入门题来练。原 C 代码大致是这样#include stdio.h int main() { int arr[8] {3, 1, 4, 1, 5, 9, 2, 6}; int count 0; for (int i 0; i 8; i) { if (arr[i] % 2 0) { count; } } printf(count %d\n, count); return 0; }这个程序包含了很典型的几种结构局部变量、数组、for 循环、if 条件判断、取模运算、函数调用。非常适合用来演示还原过程。需要说明的是下面给的汇编片段是教学用途的精简表达实际编译环境不同、优化选项不同指令顺序和地址都会变化。关键不是背指令而是掌握分辨变量的方法。2.2 先找 main 入口再判断参数和栈布局在 x32dbg 里打开编译好的程序后先不要急着按 F8。先看看模块入口然后通过“符号”窗口或者交叉引用找到 main。大多数教学程序和 CTF 题都能直接看到 main 函数名被剥掉符号的现实程序需要另找入口这个放到最后一段说。找到 main 入口后会看到类似这样的开头00401020 push ebp 00401021 mov ebp, esp 00401023 sub esp, 0x40sub esp, 0x40表示当前函数在栈上预留了 0x40 字节空间用来放局部变量和临时值。0x40 是 64 字节足够放 8 个 int 数组元素和几个 int 变量。到这里可以先记下“该函数有至少 64 字节局部变量区域”。2.3 用 x32dbg 单步跟一遍记录关键位置我先在下面这些位置下断点断点位置观察内容预期信息main 入口栈帧建立看局部变量空间大小循环条件跳转计数器和边界值看 for 循环边界arr[i] % 2的判断处寄存器中的数组元素值看当前元素是否偶数count 位置栈上 count 变化看计数逻辑printf 调用前参数栈看最终输出下断点的目的是把静态反汇编里的“疑似循环”“疑似分支”变成动态确定的跳转路径。比如只看汇编时你可能会混淆jge是大于等于还是小于等于但只要在断点处看一下实际跳转是否发生再结合 CMP 的两个操作数很快就能确定。调试时我推荐先用 F8 步过进入库函数时不要傻傻跟进去。如果误入了 printf 或 scanf 内部直接运行到返回或者重新下断点绕开。2.4 一条重要经验不要一上来就开“自动分析全部函数”x32dbg 有反汇编自动分析功能但对新手来说全程序分析出来的函数边界经常不准反而容易把人带偏。我更建议直接定位到目标函数从入口往下一条一条看。分析范围越小变量和跳转关系越清楚。3. 从汇编片段还原出 C 逻辑一个完整例子3.1 第一部分识别局部变量继续看样例的汇编先处理数组和 count 变量初始化。在 x32dbg 里你会在 prologue 之后看到一串连续的写内存指令00401026 mov dword ptr [ebp-0x4], 0 ; count 0 0040102D mov dword ptr [ebp-0x24], 3 ; arr[0] 3 00401034 mov dword ptr [ebp-0x20], 1 ; arr[1] 1 0040103B mov dword ptr [ebp-0x1C], 4 ; arr[2] 4 00401042 mov dword ptr [ebp-0x18], 1 ; arr[3] 1 00401049 mov dword ptr [ebp-0x14], 5 ; arr[4] 5 00401050 mov dword ptr [ebp-0x10], 9 ; arr[5] 9 00401057 mov dword ptr [ebp-0x0C], 2 ; arr[6] 2 0040105E mov dword ptr [ebp-0x8], 6 ; arr[7] 6这时不要急着记地址先把偏移整理出来ebp-0x4写入 0之后在循环里可能被count累加先记为count。ebp-0x24到ebp-0x8连续 8 个 dword地址递增正好是 8 个 int 元素的数组。首地址是ebp-0x24。第 7 个元素是ebp-0xC第 8 个元素是ebp-0x8。注意数组存储在栈上从低地址向高地址排列这里的递减偏移代表从数组首元素到末元素。看到“连续偏移 间隔固定”就要立刻想到数组。3.2 第二部分循环结构接着往下找计数器赋值和跳转00401065 mov dword ptr [ebp-0x28], 0 ; i 0 0040106C jmp short 0040107F ; 先跳去条件判断 0040106E mov eax, dword ptr [ebp-0x28] 00401071 mov ecx, dword ptr [ebpeax*4-0x24] ; arr[i] 00401075 and ecx, 1 ; arr[i] 1 00401078 jnz short 0040107E ; 结果非0说明是奇数跳过 count 0040107A inc dword ptr [ebp-0x4] ; count 0040107E inc dword ptr [ebp-0x28] ; i 0040107F cmp dword ptr [ebp-0x28], 8 ; i 8 ? 00401083 jl short 0040106E ; 小于则继续循环这个模式非常明显。mov [ebp-0x28], 0是初始化i 0jmp short跳到条件判断是 for 循环先判断后执行的典型形态inc [ebp-0x28]是icmp [ebp-0x28], 8; jl是i 8的边界判断跳回地址0040106E就是循环体开始地址。这时的 C 语言还原就是for (int i 0; i 8; i) { // 循环体内容 }不需要知道原程序用了for还是while只要循环等价就是正确的还原。3.3 第三部分分支和取模第 3.2 小节里mov ecx, [ebpeax*4-0x24]是取数组元素。eax在这里是ieax*4是 int 宽度 4-0x24是数组首地址相对于 ebp 的偏移。这个寻址方式一旦出现就说明有数组访问。and ecx, 1是判断奇偶的常见手段。因为偶数二进制最低位是 0 1结果为 0奇数结果为 1。后面的jnz跳到0040107E直接 i说明奇数不进入累加不跳转则执行inc [ebp-0x4]也就是count。所以这一段还原成 Cif ((arr[i] 1) 0) { count; }注意原来的程序写的是arr[i] % 2 0编译器在优化时会等价替换成 1。所以还原代码不用死板地写 1也可以写成arr[i] % 2 0两者等价。3.4 完整还原结果把前面几个部分拼起来得到#include stdio.h int main() { int arr[8]; int count 0; int i; arr[0] 3; arr[1] 1; arr[2] 4; arr[3] 1; arr[4] 5; arr[5] 9; arr[6] 2; arr[7] 6; for (i 0; i 8; i) { if ((arr[i] 1) 0) { count; } } printf(count %d\n, count); return 0; }这份还原代码和原始程序的行为一致。变量名arr、count、i是我起的原始程序里叫什么无所谓重点是逻辑还原出来了。之后你还可以顺手看一眼printf(count %d\n, count)的调用位置调用前栈上或者寄存器里放的就是 count输出结果应该显示 3因为 4、2、6 三个偶数。到这里x32dbg 静态分析加动态验证的第一步就完成了。每次还原都不要急着跳到下一段先确认眼前的循环、分支和函数调用都解释得通。4. 逆向还原 C 代码时的规律对照表4.1 局部变量、数组、指针的识别方法当反汇编里出现mov dword ptr [ebp-4], 0这样的指令本质是往当前函数的栈空间写数据。在 C 层面这就是一个局部变量。如果是mov dword ptr [ebp-0x24], 3后面跟着一连串间隔相同的写操作大概率是数组初始化。数组访问的典型模式是“基地址寄存器 偏移寄存器 * 元素宽度”。比如mov eax, [ebpecx*4-0x24]ecx 是下标乘以 4 说明每个元素占 4 字节也就是 int 或 unsigned int。看到*8时就要考虑 long long、double 或者 64 位宽度的类型。看到*1通常是 char 数组。指针变量在这中间比较隐蔽。它本身也是一个局部变量存的是一个地址。还原时碰到“一次取地址放入变量再一次从变量取地址去取值”才能判断是指针解引用。4.2 for、while、do-while 在汇编里的差异先说好恢复出的循环不一定要和原始写法完全一致编译器完全可以把 while 优化成 for 形态。但熟悉三种循环的汇编特征可以加快识别for 循环先初始化计数器然后无条件跳到条件判断条件满足才进入循环体循环体末尾累加计数器再跳回条件判断。while 循环没有明确的计数器初始化直接比较条件然后按条件跳转。do-while 循环无条件先执行一次循环体最后比较条件满足则跳回。判断循环时最重要是找到三个节点初始化位置、条件判断位置、循环体末尾跳回位置。把这三个点标记出来画一条跳转线整个循环结构就清晰了。4.3 switch 和 if-else 的识别短 switch 和一连串 if-else 在汇编里很像。如果看到多次cmp后接多个je或jz并且每个分支都通向不同的处理代码就是 switch 或 if-else 链。区别在于switch 的多个比较通常针对同一个寄存器if-else 链也可能如此但 switch 更容易出现跳转表。跳转表识别方法是看到mov eax, [ebp-xx]然后jmp dword ptr [表地址 eax*4]。这表示程序把 switch 的分支地址放到一张表中用表达式值做索引。遇到跳转表不要硬把表展开成无数个 if可以直接还原成 switch。4.4 字符串和库函数调用的识别printf、scanf、strlen、memcpy 这些库函数在反汇编窗口里通常能直接看到名字前提是程序没有剥掉导入符号。如果导入表被清理了就要通过调用模式判断。比如 printf 的调用在 x86 32 位下是参数压栈模式push 格式串地址 push 变量 call printf add esp, 8格式串地址可以通过在数据窗口查看右键切换到 ASCII就能看到count %d\n。看到能读懂的字符串是还原程序逻辑的重要抓手因为字符串往往直接暴露了输出含义。下面是一张我在还原过程中常用的对照表C 语言元素常见汇编特征还原思路局部变量[ebp-偏移]或[rbp-偏移]写读先记录偏移再按赋值关系命名数组连续偏移 固定步长访问算元素宽度和数组边界指针变量存地址再通过该地址取值区分取地址和解引用两级for 循环初始化计数器先跳到 cmp/jcc再回跳记录初始值、边界、步长while 循环cmp/jcc 在循环体前后画跳转图判断退出条件if-elsecmp/test 一组 jcc沿着跳转目标分出真假分支switch同一寄存器多次 cmp或跳转表结合跳转表地址还原 case库函数调用push 参数后 call 函数名查看参数数量和返回值5. x64dbg 动态调试验证还原结果的步骤5.1 用条件断点确认循环次数和特殊分支静态还原到“for 循环会执行 8 次”之后可以在条件跳转处设一个条件断点比如当[ebp-0x28] 7时断下来确认最后一次循环进入时的下标。这个动作可以验证你对循环边界的判断。x64dbg 的断点表达式支持地址和寄存器也可写内存引用。设置方式是右键目标行选择断点 - 条件断点填入表达式。如果表达式写复杂了不生效先用普通断点看单次值再逐步调整条件不要一上来就想一次式写出完美表达式。5.2 用“修改跳转条件”验证分支理解这里要说明一下修改跳转条件是为了学习调试器、验证你对分支逻辑的理解而不是用来做越权操作。在分析自己写的教学程序或 CTF 赛题时这个验证方法很有用。做法是找到jnz或jz指令右键选择“汇编”把它改成jmp或nop然后运行程序观察输出结果变化。拿样例来说jnz short 0040107E的意思是“如果 arr[i] 1 非 0就跳过 count”。如果你把它改成nop那么无论什么情况都不会跳过count 最终会变成 8。运行后如果看到输出 8说明你对这个分支的理解完全正确。这个操作的前提是程序在调试器里运行改的是内存中的指令不落盘就不会修改原文件。真要用补丁功能保存文件记得先备份而且只在自己的学习样本上做。5.3 直接查看栈和内存把抽象值变成可视值有时候寄存器里的值看不出含义但只要打开栈窗口或者内存窗口把二进制显示切换成十进制或 ASCII立刻就能明白。我在还原时会把 eip 停在可疑位置然后看栈窗口跟随 ebp对照前面记录的变量偏移逐个验证栈上的值。内存窗口的操作也很简单在数据区选中一段字节右键可以切换显示方式整数、长整型、ASCII、Unicode 都可以。字符串还原靠这个非常直观。5.4 验证还原是否正确的一组标准我觉得满足这些条件还原结果才算可靠每个关键跳转方向都解释清楚不再有“这个 jmp 不知道为什么跳”。所有局部变量偏移都有归属不会被某个指令突然使用而未记录。函数调用前的参数值能一一对应到还原代码里的函数实参。运行修改后的跳转条件输出变化符合预期。把还原的 C 代码重新编译在相同输入下行为一致。如果只是静态“猜”出一版代码没有经过动态验证那只能叫推测。真正做过断点确认和跳转修改测试的还原才有底气说“我理解了这段程序”。6. 我在还原过程中会优先排查的问题6.1 符号被剥离之后怎么找函数边界实际分析的程序不一定自带 main 符号。遇到这种情况不要到处乱翻先按“程序入口 - 启动代码 - 用户函数”的路径找。另一个办法是搜索字符串比如找到count %d\n然后查看哪些代码访问了这个字符串地址顺着交叉引用往上找通常能找到调用它的用户函数。函数边界识别一般看两点开头是不是push ebp; mov ebp, esp结尾是不是leave; ret或add esp, xx; ret。找到这个模式即使函数名丢失也可以把它当成一个函数来分析。6.2 优化后的代码难还原怎么办/O2优化后的代码会大量使用寄存器栈变量可能被优化掉循环也可能会被展开。这不是不能还原而是要调整策略先看调用关系和整体控制流把“这是一个算法还是数据处理函数”搞清楚再细化每一个分支。如果只是为了学习汇编和 C 的对应关系建议先用不优化或低优化级别编译的样本练习因为栈变量明显、分支直观。等熟悉之后再挑战优化版本。6.3 还原结果和实际行为对不上时从哪里查我会按这个顺序排查地址是否对得上。确认断点和反汇编窗口里看到的地址确实属于目标函数而不是跳到了库函数。参数传递方式。32 位程序看栈64 位程序先看 rcx、rdx、r8、r9再看栈。数据宽度。看到dword ptr是 4 字节word ptr是 2 字节byte ptr是 1 字节别混。结构体填充。如果一个结构体中有对齐字段偏移可能不是连续递增的。有没有浮点寄存器。涉及 float、double 的代码会使用 xmm0/xmm1和整数逻辑完全不同。循环边界是开区间还是闭区间。jl和jle就差一个i 8和i 7的区别但逻辑完全不同。大部分对不上都出在前三个问题上。我见过很多次“看起来像是读数组实际读的是结构体成员”的情况原因就是只看偏移没看数据宽度和全局布局。6.4 适合自己练习的清单找一个十行左右的 C 小函数编译成 32 位程序用 x32dbg 分析。先不看源码只靠反汇编还原出循环和分支。用条件断点验证数组下标和循环次数。修改一个跳转条件观察程序输出是否符合预期。对比还原代码与原代码的差异看编译器做了什么等价变换。这里特别建议第一次练习不要碰复杂程序。先把一个三层以内的小函数从汇编还原成 C再用调试器验证每个跳转比一次啃一个大程序有用得多。逆向还原 C 代码是一件靠经验积累的事模式见多了看到and ecx, 1马上想到奇偶判断看到ebpeax*4-0x24马上想到数组访问速度自然就上来了。
返回列表