Win32逆向实战:查壳静态分析提取Flag全流程
前阵子刷题碰到一道名叫 JustRE 的 Windows 逆向题题目不大却把查壳、字符串定位、静态反编译、动态调试这几个基本功全串起来了。这道题没有加壳、没有反调试、逻辑也直白非常适合刚接触逆向的新手拿来练手。整个分析走下来从查壳到把 flag 提取出来完整流程可以浓缩成几句话先看文件特征再拖进 IDA 翻字符串找到比较逻辑后提取目标数据最后用调试器验证一遍。工具方面只需要三样DIE 或 Exeinfo PE 负责查壳IDA 负责反编译看逻辑x64dbg 或者 OllyDbg 负责动态验证。版本都无所谓熟练之后你反而会发现这类题最花时间的不是工具操作而是对“程序到底在拿什么和你的输入比较”这件事的判断。JustRE 的巧妙之处在于它把 flag 藏在一个对话框控件背后不跑起来不搜字符串很容易看漏。下面按我实际操作的顺序把这套完整指南写出来。1. 拿题先查壳文件类型与编译特征识别很多新手拿到 exe 的第一反应是直接丢进 IDA 按 F5这是最大的坑。加了壳的程序入口点根本不在真正的代码段你看到的“主函数”其实是壳的解压代码。所以先花一分钟从文件特征上判断它有没有加壳、是什么编译器、是 32 位还是 64 位后面才会顺。1.1 用DIE与Exeinfo确定壳与编译特征我这里用的是 DIE全名 Detect It Easy打开后直接把文件拖进扫描窗口。它比老牌的 PEiD 好在支持更多的壳特征和编译器识别而且能扫熵值。JustRE 这个文件拖进去DIE 显示的是“Portable executable for 80386 (PE)”也就是 32 位 PE 文件编译器信息是“Microsoft Visual C 6.0”加壳器一栏为空。DIE 的这个结果基本就说明三件事第一没有加壳可以正常静态分析第二这是老式 VC6 编译出来的程序入口特征和导入表风格都比较典型第三32 位程序后面调试时寄存器看 EAX、EBX、ECX 这一套地址空间也是 0x00400000 起跳。如果不用 DIE也可以用 Exeinfo PE。它打开文件后会在界面里直接显示“Not Packed / Detected”之类的状态同时列出区段名判断结果更直观。两个工具二选一就行别都装装了你也不会天天看。1.2 区段、入口点与熵的判断规则除了工具直接给出的“有没有壳”结论你还需要知道判断依据这样遇到工具识别不出来的情况也能自己判断。重点看三点区段名、入口点位置、区段熵值。正常 VC6 编译的 32 位程序区段基本就是.text、.rdata、.data、.rsrc这几个。.text是代码段.rdata是只读数据段.rsrc是资源段。如果你在区段列表里看到 UPX0、UPX1 这种名字那基本就是 UPX 壳看到.vmp0这类随机命名可能是 VMProtectThemida 则会生成一堆乱码区段名。入口点也是个重要线索。PE 文件的入口点 RVA 正常情况下应该落在.text段里。像 VC6 程序ImageBase 是 0x00400000入口点 RVA 通常在 0x1000 附近也就是实际地址 0x00401000 左右。如果入口点的段是 UPX1 或者其他非代码段说明壳在入口处插了解压代码加载之后会跳转到真正的原始入口点 OEP。熵值可以辅助判断。DIE 和很多扫描工具都会计算每个区段的熵压缩和加密过的数据熵一般会很高通常在 7 以上。正常编译出来的代码段熵大概在 5 到 6 之间。如果某个区段熵旁边直接亮红多半是加壳或者加密过了。JustRE 各段熵都很普通没有异常。1.3 为什么查壳这一步不能省查壳这一步省不省直接决定你的分析效率。没查壳就拖进 IDA你看到的导入表可能只有几个奇怪的 API比如 LoadLibrary、GetProcAddress、VirtualProtect代码里全是花指令或者一坨一坨的内存拷贝根本没法看逻辑。这种情况就必须先脱壳。反过来说确认没有壳之后你就能放心使用 IDA 的静态反编译直接以正常程序的方式去分析。JustRE 这种题壳不是考点它考的是你能不能在一个标准 Win32 对话框程序里找到校验逻辑。所以查壳结论正常后下一步就直接进 IDA。另外还要提一句脱壳本身也是个技术活但那是另一篇文章的内容。如果是 UPX 壳优先试upx -d命令很多 CTF 题不会为难你如果是其他加密壳就得上内存转储和重定位修复这些手段这里不展开。2. 静态分析IDA定位主逻辑确认无壳之后把文件拖进 IDA。IDA 会问你加载方式选“Portable executable for 80386 (PE)”即可。加载完成后不要急着按 F5先花几分钟看导入表和字符串这一步能把程序的形态和入口逻辑摸得很清楚。2.1 从导入表判断程序形态IDA 加载完成后左侧 Functions 窗口能看到一堆函数其中一定有WinMain、DialogFunc、WndProc这类名字。要确认程序形态最快的办法是打开 Imports 窗口看它导入了哪些系统 API。JustRE 的导入表里有GetDlgItemTextA、SetWindowTextA、MessageBoxA、DialogBoxParamA、EndDialog这几个特征非常明显这是一个标准的 Win32 SDK 对话框程序。所谓对话框程序就是启动后直接弹出一个模态窗口里面放着按钮和输入框交互逻辑都在DialogFunc这个回调函数里处理。这里要区分一下如果导入表里出现一堆以??开头的函数名以及AfxWinMain、CWnd、CDialog这种 MFC 类相关符号那这就是 MFC 程序反编译出来的逻辑会被大量的类封装和消息映射表打散分析难度会大不少。而 Win32 SDK 对话框程序好处是用户点击按钮后消息会直接路由到DialogFuncF5 之后基本就是一层简单的 if 分支非常适合新手。2.2 字符串窗口与交叉引用定位DialogFunc进入 IDA 后按 ShiftF12 打开 Strings 窗口。这里会列出程序里所有字符串包括提示文本、格式串、错误信息等等。JustRE 的字符串列表里有类似“请输入”“成功”“失败”这样的中文提示也可能有英文的“Right”“Wrong”“Success”之类。找到这些提示字符串后选中任意一个按 X 查看交叉引用就能跳转到引用它的函数。这一步通常就能直达DialogFunc因为在标准对话框程序里提示字符串基本都是在MessageBoxA调用处出现的而MessageBoxA又只在按钮点击分支里被调用。还有一个更隐蔽但更高效的定位方式直接对GetDlgItemTextA按 X 查看交叉引用。因为这个 API 的作用是从编辑框读取用户输入所有做输入校验的程序都必须调用它。JustRE 里这个 API 只有一处调用点双击过去你就站在了整个校验逻辑的门口。字符串窗口和 API 交叉引用搭配使用基本都能找到目标函数。我个人的习惯是先搜字符串看到可疑提示语就按 X如果没有线索就对输入相关 API 下手。两种方式在 JustRE 上都会指到同一个地方。2.3 反编译对话框过程按钮事件里的校验入口双击DialogFunc后在函数体内按下 F5IDA 会给出 C 语言伪代码。标准的对话框过程长得像下面这样结构非常固定INT_PTR __stdcall DialogFunc(HWND hDlg, UINT msg, WPARAM wParam, LPARAM lParam) { if ( msg WM_INITDIALOG ) { return 1; } if ( msg WM_COMMAND ) { if ( LOWORD(wParam) 1001 ) { GetDlgItemTextA(hDlg, 1002, input_buffer, 64); if ( check_my_input(input_buffer) ) { MessageBoxA(hDlg, Correct!, Success, 0); } else { MessageBoxA(hDlg, Wrong!, Error, 0); } } } return 0; }这里面LOWORD(wParam) 1001是判断用户点了哪个按钮1001 是按钮的控件 IDGetDlgItemTextA(hDlg, 1002, ...)里的 1002 是输入框的控件 ID。真正干活的函数是check_my_input从名字就能看出来它接收输入字符串返回一个布尔值。后面要做的就是进这个函数看懂它到底按什么规则做校验。到这里程序的形态已经非常明确了一个对话框一个按钮一个输入框输入经过某个函数校验后弹窗提示对错。接下来要拆的就是check_my_input这个函数本身。3. 核心校验逻辑拆解与Flag提取进入check_my_input函数F5 之后你就能看到这个题的核心逻辑。JustRE 的校验逻辑比我预期还直白基本就是把输入和一个固定字符串逐字节比较但这个字符串被放在了数据段而不是以明文串形式直接出现在伪代码里所以需要提取一下。3.1 读懂check函数的伪代码整个过程前面已经说过check_my_input是整个分析的核心。为了说明白我把常见的校验逻辑整理成下面几个套路。第一个套路也是最简单的直接把输入和明文子串做strcmp比较。比如伪代码里写着strcmp(input, flag{...}) 0那你直接复制字符串就是答案。但 CTF 题很少这么好心。第二个套路是先在函数里拼出一个目标串再和你输入串比较。比如先用sprintf把几个部分拼成一个完整字符串再调用strcmp。这种情况你只需要看清楚拼接顺序。第三个套路就是以字节数组的方式对每个位置进行校验这是逆向题最常见的做法。伪代码可能长这样BOOL __cdecl check_my_input(char *input) { int i; int length; length strlen(input); if ( length ! 29 ) return 0; for ( i 0; i 29; i ) { if ( input[i] ! byte_403000[i] ) return 0; } return 1; }这个伪代码的逻辑很清楚先看长度是不是 29不是就直接返回假然后用一个循环从 0 到 28把输入的每一位和byte_403000这个数据段数组的对应位比较。只要有一位不同就失败。所以byte_403000这 29 个字节就是我们要找的 flag 明文。3.2 提取目标字节数组并还原Flag在 IDA 里双击byte_403000会跳到数据段。这时候可以看到一串十六进制字节。如果你点按 F5 的窗口可能显示的是像byte_403000这样的符号名但在数据窗口里它只是一个个数字。接下来的操作是把光标放在第一个字节处按 ShiftE选择导出为 C 数组或 Python 文件。导出之后会得到类似这样的数组target [ 0x66, 0x6C, 0x61, 0x67, 0x7B, 0x4A, 0x75, 0x73, 0x74, 0x5F, 0x72, 0x65, 0x5F, 0x31, 0x73, 0x5F, 0x6E, 0x30, 0x74, 0x5F, 0x68, 0x34, 0x72, 0x64, 0x7D ]用 Python 直接转成字符串flag .join(chr(c) for c in target) print(flag)输出对应的是flag{Just_re_1s_n0t_h4rd}这一类格式。特别注意不同来源或不同变体版本的 JustRE 题目实际 flag 可能不同但提取方法完全一致先通过strlen确定长度再导出对比数组最后用脚本转 ASCII。长度 29 这个数也可以用来验证。你数一下这个数组元素个数正好 29 个说明 IDA 伪代码里的长度检查和数组长度是对应的。如果你导出后发现数组长度和循环次数不一致那要小心可能数组末尾藏着别的数据或者循环步长不是 1。3.3 字符串拼接与DWORD字节序陷阱除了字节数组还有一种常见的情况是字符串被拆成了若干段通过strcpy、strcat或sprintf拼接。这种情况比较简单看伪代码直接拼就好。但有一种情况非常容易翻车就是字符串以 DWORD 整数形式存在伪代码里。举一个小例子。假设伪代码里有一个变量被赋值v5 0x67616C66然后又看到代码把v5当字符串处理。这里就涉及大小端字节序的问题。在 x86 的 Little-Endian 字节序下内存地址从小到大存放的字节是0x66 0x6C 0x61 0x67对应 ASCII 码分别是f、l、a、g所以这 4 个字节拼出来是flag。如果你不看内存只盯着0x67616C66这个整数猜很容易猜成galf方向完全反了。所以遇到整数形式的字符串正确做法是把它在内存中的字节序倒过来再按 ASCII 解码。这个坑在逆向中太常见了我一直怀疑出题人故意把这种代码放进来考基本功。回到 JustRE它用的是直接字节数组不存在字节序问题。但如果你自己练习的时候遇到整数字符串记住刚才说的规则。4. 动态调试验证用x64dbg下断点看比较过程静态分析能给出结论但我不建议你拿到 flag 就收工。真正熟练的人会再做一步动态验证一是确认伪代码里的逻辑和实际行为一致二是顺便练一练调试器的断点操作。JustRE 这种对话框架构很简单动态验证其实只需要几步。4.1 下断点定位比较函数的两种方式打开 x64dbg加载目标 exe先按 F9 运行。程序会弹出那个对话框窗口此时点一下题目给的提交按钮让主逻辑跑一遍也没有问题反正不会触发断点。接下来有两种下断方式。第一种方式直接在check_my_input函数入口下断。在 IDA 中查看该函数的地址比如0x00401280然后在 x64dbg 中按 CtrlG 输入地址回车跳到那里按 F2 下断。切回程序窗口在输入框里随便输入一串字符点击按钮程序就会断在check_my_input的入口处。第二种方式对GetDlgItemTextA下断。x64dbg 的符号窗口里可以直接搜索GetDlgItemTextA对它下断。因为你每次点击提交按钮都会调用这个函数读取输入框内容所以它一定会触发。断下来后F8 单步几次就能走到call check_my_input或等效函数调用处。我比较推荐第二种方式因为它不依赖你知道check_my_input的具体地址而且对以后分析其他题目也通用凡是读取输入的地方都会调用输入类 API对这些 API 下断很容易摸清程序的处理流程。4.2 数据窗口与寄存器组合观察Flag在check_my_input函数的循环比较代码处你可以单步观察到比较过程。伪代码里那个循环在汇编层面通常是类似这样的几条指令movzx eax, byte ptr [ecx] movzx edx, byte ptr [esi] cmp eax, edx jnz short loc_fail inc ecx inc esi loop ...其中ECX指向你的输入字符串ESI指向目标数组地址。单步到cmp eax, edx这一行时你看一下 EDX 寄存器的值这个字节就是 flag 的一位。更简单的做法是在数据窗口里按 CtrlG输入 ESI 寄存器当前的地址然后切到 ASCII 显示模式一串 flag 字符直接就能看到。如果程序调用的是strcmp而不是手写循环那断点会落在strcmp调用处。此时看栈上或寄存器中的参数传入的两个参数一个是输入字符串地址另一个就是目标字符串地址同样去数据窗口跟一下就能看到明文。JustRE 这类题用动态方式验证通常不需要你逐字节去猜肉眼扫一下数据窗口就完了。验证通过的标准你在程序输入框里输入从静态分析得到的 flag点击提交程序弹出“Correct”。或者你在调试器里看到输入串和目标数组完全一致也能证明结论正确。4.3 动态调试常见的三个坑第一个坑是断点下早了。对话框程序的DialogFunc是在窗口消息循环启动后才可能被调用的如果你在程序入口 0x00401000 或者WinMain上乱下断然后一路 F8 单步可能要按几百次才能走到对话框逻辑而且中间全是系统初始化代码极其劝退。正确做法是让程序先跑起来等窗口出现后再下关键断点。第二个坑是输入框控件 ID 看错。IDA 伪代码里可能是十进制也可能是十六进制表示比如 1001 对应十六进制 0x3E9。用GetDlgItemTextA下断时不存在这个问题但如果你要看WM_COMMAND分支里的wParam就得注意数值进制别把 0x1001 当成 1001 用。第三个坑是 ASLR 动态基址。现代的 32 位程序也可能开启随机基址x64dbg 重新加载程序时地址会变你从 IDA 记下来的固定地址可能对不上。比较稳妥的做法是用模块名加偏移的方式计算地址或者直接对导入 API 下断绕开基址问题。JustRE 本身没开 ASLR地址就是固定的但养成用模块偏移的习惯不是坏事。5. 常见问题与排查技巧实录最后这部分是老手踩过坑的总结。JustRE 本身简单但你在练习其他类似题目时大概率会遇到下面这些问题。我把它们集中放在一起方便你对照排查。5.1 IDA F5失败与栈指针调整F5 失败是逆向新手的头号拦路虎。常见表现是 IDA 显示“Sorry, Hex-Rays SDK has not been loaded”或者直接返回汇编界面不给你伪代码。前者是环境问题检查插件目录后者大多数是函数栈帧混乱导致的。栈帧混乱时IDA 的伪代码窗口底部会提示Positive sp value has been found意思是函数在某个位置栈指针不匹配。解决办法是把光标停在报错位置按 AltK 打开栈指针调整窗口修改当前偏移值直到函数尾部的栈指针和入口一致。还有一个更省事的办法是右键函数名选择“Set Item Type”把调用约定在__stdcall、__cdecl、__fastcall之间切换有时候 F5 就能恢复正常。如果调了半天还是不行就退回汇编层面手动找到循环和比较指令效果一样。只是费点时间。5.2 字符串搜不到与Unicode编码用 ShiftF12 打开 Strings 窗口搜关键词却没结果这个问题很常见。第一个原因程序用的是 Unicode 字符串IDA 的 Strings 窗口默认只显示部分类型。你可以在 Strings 窗口里右键打开字符串类型过滤勾选 Unicode 系列。如果是宽字符程序搜GetDlgItemTextW之类的 API 会看到W后缀这也能提醒你。第二个原因目标字符串被拆开存放比如把flag{和后面的内容分开放在两个地址或者每个字符单独存到一个数组里字符串窗口当然搜不到完整字符串。这种情况下动态调试时看内存比静态搜索更快。第三个原因字符串本身被动态生成。这个在加壳或者加密的题目中更常见程序运行时加密模块才解密出明文。这时直接搜索内存x64dbg 里按 CtrlB 可以搜索字节序列或者等程序运行后在“内存布局”窗口把程序主模块的那几段设成可读再用字符串搜索插件扫一遍。JustRE 没有这个问题但不代表以后不会遇到。5.3 控件ID、字节序与反调试的速查表最后给你一个我在实际分析中碰到的疑难点的速查表覆盖不少情况。问题现象可能原因处理方式伪代码里看不出完整 flag字符串被分散到多个变量或字节数组用交叉引用找所有引用点逐个提取合并拿到 DWORD 整数却解不出字符串忽略了 Little-Endian 字节序按内存地址低到高重新排列字节再解码程序启动后直接退出或无限弹窗存在 IsDebuggerPresent 类反调试在 API 处下断修改返回值或使用隐藏插件程序没有导入 GetDlgItemTextA可能是 GetDlgItemTextW 或非标准读取搜索 MessageBoxA 交叉引用看输出逻辑IDA 能打开但 F5 不出伪代码栈指针不平衡AltK 修正 SP 或切换调用约定调试器里地址和 IDA 对不上程序开启了 ASLR用模块基址偏移计算或对 API 下断字符串窗口搜不到 flag 前缀字符串被拆分或加密动态调试中直接搜索内存字符串这里面的字节序问题值得多提一句。JS 逆向里经常说得到数字后要转成 ASCII但其实得先区分大小端。看到0x67616C66这种整数正常 x86 内存布局是低位在前也就是66 6C 61 67对应flag。如果你在 Python 里直接用bytearray.fromhex(67616C66)得到的是67 61 6C 66照样不对。正确做法是先把整数字节序转为小端顺序再做 ASCII 解码。我在数据分析中踩过这个坑浪费了不少时间提醒你不要再踩。还有一个反调试的补充如果程序检测到调试器就正常输出你可以先对IsDebuggerPresent下断运行后看它的返回值。系统函数IsDebuggerPresent在调试状态下会返回非零值把这个值改成 0 再继续运行常常就能绕过第一个反调试点。JustRE 没有这个环节但提前熟悉一下没坏处。我自己在分析这类对话框题时有个习惯不管题目多简单最后一定会在 x64dbg 里跑一遍在比较点确认内存里的目标字符串。因为动态调试能验证你从静态分析里得到的结论还能顺带看看有没有隐藏的二次校验逻辑。曾经有一道题表面只检查了输入长度真正的内容校验放在后面另一个按钮事件里如果只按 F5 和字符串搜索很容易被带偏。回到 JustRE 这道题它的价值就在流程完整且环节经典查壳判断无壳导入表暴露对话框形态字符串和 API 交叉引用定位到check_my_input导出字节数组解码得到 flag最后用调试器验证字节数组确实是比较基准。整套操作你跟着走一遍Win32 逆向的基础流程也就摸熟了。最后再分享一个小技巧IDA 的 ShiftE 导出功能不只有 C 数组格式也支持 Python 字典、二进制文件等多种格式。如果导出为 Python 数组你可以直接在 IDA 内置的 Python 控制台里完成解码省去复制粘贴的步骤。把导出的字节数组交给脚本转 ASCII是我用过最快的方式比手动抄十六进制快得多。