☰
Win32逆向实战:查壳静态分析提取Flag全流程
2026/10/20 17:01:21 网站建设 项目流程

前阵子刷题碰到一道名叫 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这类随机命名,可能是 VMProtect;Themida 则会生成一堆乱码区段名。

入口点也是个重要线索。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 对话框程序好处是,用户点击按钮后消息会直接路由到DialogFunc,F5 之后基本就是一层简单的 if 分支,非常适合新手。

2.2 字符串窗口与交叉引用定位DialogFunc

进入 IDA 后,按 Shift+F12 打开 Strings 窗口。这里会列出程序里所有字符串,包括提示文本、格式串、错误信息等等。JustRE 的字符串列表里有类似“请输入”“成功”“失败”这样的中文提示,也可能有英文的“Right”“Wrong”“Success”之类。找到这些提示字符串后,选中任意一个按 X 查看交叉引用,就能跳转到引用它的函数。

这一步通常就能直达DialogFunc,因为在标准对话框程序里,提示字符串基本都是在MessageBoxA调用处出现的,而MessageBoxA又只在按钮点击分支里被调用。

还有一个更隐蔽但更高效的定位方式:直接对GetDlgItemTextA按 X 查看交叉引用。因为这个 API 的作用是从编辑框读取用户输入,所有做输入校验的程序都必须调用它。JustRE 里这个 API 只有一处调用点,双击过去,你就站在了整个校验逻辑的门口。

字符串窗口和 API 交叉引用搭配使用,基本都能找到目标函数。我个人的习惯是:先搜字符串,看到可疑提示语就按 X;如果没有线索,就对输入相关 API 下手。两种方式在 JustRE 上都会指到同一个地方。

2.3 反编译对话框过程:按钮事件里的校验入口

双击DialogFunc后,在函数体内按下 F5,IDA 会给出 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 是按钮的控件 ID;GetDlgItemTextA(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这样的符号名,但在数据窗口里它只是一个个数字。

接下来的操作是:把光标放在第一个字节处,按 Shift+E,选择导出为 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 中按 Ctrl+G 输入地址,回车跳到那里,按 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 的一位。更简单的做法是在数据窗口里按 Ctrl+G,输入 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,意思是函数在某个位置栈指针不匹配。解决办法是把光标停在报错位置,按 Alt+K 打开栈指针调整窗口,修改当前偏移值,直到函数尾部的栈指针和入口一致。还有一个更省事的办法是右键函数名,选择“Set Item Type”,把调用约定在__stdcall、__cdecl、__fastcall之间切换,有时候 F5 就能恢复正常。

如果调了半天还是不行,就退回汇编层面,手动找到循环和比较指令,效果一样。只是费点时间。

5.2 字符串搜不到与Unicode编码

用 Shift+F12 打开 Strings 窗口搜关键词却没结果,这个问题很常见。第一个原因,程序用的是 Unicode 字符串,IDA 的 Strings 窗口默认只显示部分类型。你可以在 Strings 窗口里右键,打开字符串类型过滤,勾选 Unicode 系列。如果是宽字符程序,搜GetDlgItemTextW之类的 API 会看到W后缀,这也能提醒你。

第二个原因,目标字符串被拆开存放,比如把flag{和后面的内容分开放在两个地址,或者每个字符单独存到一个数组里,字符串窗口当然搜不到完整字符串。这种情况下,动态调试时看内存比静态搜索更快。

第三个原因,字符串本身被动态生成。这个在加壳或者加密的题目中更常见,程序运行时加密模块才解密出明文。这时直接搜索内存:x64dbg 里按 Ctrl+B 可以搜索字节序列,或者等程序运行后,在“内存布局”窗口把程序主模块的那几段设成可读,再用字符串搜索插件扫一遍。JustRE 没有这个问题,但不代表以后不会遇到。

5.3 控件ID、字节序与反调试的速查表

最后给你一个我在实际分析中碰到的疑难点的速查表,覆盖不少情况。

问题现象可能原因处理方式
伪代码里看不出完整 flag字符串被分散到多个变量或字节数组用交叉引用找所有引用点,逐个提取合并
拿到 DWORD 整数却解不出字符串忽略了 Little-Endian 字节序按内存地址低到高重新排列字节再解码
程序启动后直接退出或无限弹窗存在 IsDebuggerPresent 类反调试在 API 处下断,修改返回值或使用隐藏插件
程序没有导入 GetDlgItemTextA可能是 GetDlgItemTextW 或非标准读取搜索 MessageBoxA 交叉引用,看输出逻辑
IDA 能打开但 F5 不出伪代码栈指针不平衡Alt+K 修正 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 的 Shift+E 导出功能不只有 C 数组格式,也支持 Python 字典、二进制文件等多种格式。如果导出为 Python 数组,你可以直接在 IDA 内置的 Python 控制台里完成解码,省去复制粘贴的步骤。把导出的字节数组交给脚本转 ASCII,是我用过最快的方式,比手动抄十六进制快得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询