引言
逆向工程(Reverse Engineering)在 CTF 里是「读程序」的学问:给定一个剥离了符号表的二进制,要求还原出它校验 flag 的算法,最终把输入解出来。它和 Pwn 的分工很清楚,Pwn 关心的是内存破坏如何被利用,Reverse 关心的是「这段机器码到底在算什么」。很多题目是两者的混合体:先用 Reverse 定位漏洞点与偏移,再用 Pwn 构造利用链,所以这两条线在实战里经常来回切换。
静态分析指不运行程序、只看文件字节的方法:读段表、看反汇编、跑反编译器、做交叉引用。它的优势是离线、全局、可自动化,劣势是面对加壳、自修改代码、间接跳转时会「断链」。动态调试则是让程序真的跑起来,用断点观察寄存器与内存。成熟的做法是两者交替:静态给出假设,动态做验证。
真正的难点不在工具按钮,而在证据链。编译器优化会摧毁源码结构,反编译器的伪代码是「概率性猜测」而不是事实,出题人还会主动施加混淆与反调试。因此逆向的过程是不断提出假设、用汇编、内存快照、约束求解等多种证据交叉验证,直到所有证据自洽。
本文按「格式基础 → 信息收集 → 工具链 → 反编译失真 → 汇编阅读 → 算法识别 → 混淆对抗 → 约束求解与字节码 → 防御视角」推进。适合刚入门的 CTF 选手,也适合需要做二进制审计与供应链核验的工程师。若你是零基础,建议先看 CTF 竞赛全景与学习路径 建立方向感。
目录
- 可执行格式基础:ELF 与 PE 的结构对照
- 信息收集:file、readelf、objdump、strings、checksec
- 反汇编与反编译工具链:IDA Pro 与 Ghidra
- 反编译伪代码为什么不「准」
- x86-64 汇编基本功:System V 调用约定与栈帧
- 常见算法识别:base64、TEA、RC4 与异或
- 混淆对抗:控制流平坦化与不透明谓词
- 约束求解与字节码逆向:z3、pyc 与 .NET
- 检测与防御视角下的静态分析
1. 可执行格式基础:ELF 与 PE 的结构对照
Linux 下 CTF 逆向题绝大多数是 ELF,Windows 题是 PE。理解格式的价值在于:知道哪些信息在文件里、哪些只在加载后存在,能让你一眼判断「这个地址是文件偏移还是虚拟地址」。
ELF 有三个关键视角:文件头(e_ident 魔数 \x7fELF、e_type 区分 ET_EXEC 与 ET_DYN、e_entry 入口)、程序头表(Program Header Table,描述段 segment,加载器只看这个)、节头表(Section Header Table,描述节 section,链接器与调试工具看这个)。PIE(Position Independent Executable)编译出的文件 e_type 是 ET_DYN,基址随机,静态看到的地址需要加上运行时基址才是真实地址。
PE 的对应概念是 DOS 头 → e_lfanew → NT 头 → 节表(.text/.data/.rdata/.rsrc)。RVA(相对虚拟地址)与文件偏移的换算要查节表的 VirtualAddress 与 PointerToRawData,这是手工 patch 时最常见的错点。
| 概念 | ELF | PE |
|---|---|---|
| 魔数 | 7f 45 4c 46 | 4d 5a(MZ) |
| 加载单位 | 段(Program Header) | 节(Section Table) |
| 入口 | e_entry | AddressOfEntryPoint |
| 重定位 | .rela.dyn / .rela.plt | .reloc 目录 |
| 导入表 | .dynsym + GOT | Import Directory |
一个具体的 ELF 节表片段,能帮你建立「节名 → 用途」的直觉:
[Nr] Name Type Address Offset Size
[ 1] .interp PROGBITS 0000000000000318 00000318 00001c
[12] .init PROGBITS 0000000000001000 00001000 00001b
[13] .plt PROGBITS 0000000000001020 00001020 0000a0
[14] .text PROGBITS 00000000000010c0 000010c0 0004a5
[15] .fini PROGBITS 0000000000001570 00001570 00000d
[23] .init_array INIT_ARRAY 0000000000003db8 00002db8 000008
[24] .fini_array FINI_ARRAY 0000000000003dc0 00002dc0 000008
[25] .dynamic DYNAMIC 0000000000003dc8 00002dc8 0001f0
[27] .got PROGBITS 0000000000003fc0 00002fc0 000040
[28] .data PROGBITS 0000000000004000 00003000 000010
[29] .bss NOBITS 0000000000004010 00003010 000008
注意 .bss 是 NOBITS,它在文件里不占空间,运行时才分配——所以「字符串藏在 bss」这种说法是错的,bss 里只可能是运行时写入的值。.plt 与 .got 是延迟绑定机制的两半:第一次调用外部函数时 plt 跳进动态链接器解析地址并回填 got,之后直接走 got。逆向时看到 call printf@plt 就知道这是外部调用。
PIE 的基址在 Linux 上通常页对齐(4KB),所以「静态地址的低 12 位」在运行时保持不变,这是个非常有用的调试技巧:断点地址写 base + 0x1136,而 0x136 这三位十六进制可以直接从静态反汇编抄。
工程启示:加固产品常用「段级加密 + 运行时解密」把关键代码从文件里挪走,静态分析看不到明文。这意味着「符号剥离」只是最低成本的混淆,真正抬高成本的是把语义藏到运行时。
2. 信息收集:file、readelf、objdump、strings、checksec
拿到文件的第一分钟决定后面两小时的效率。标准动作是先确认类型、架构、链接方式、保护,再决定走静态还是动态。
file ./chall # ELF 64-bit LSB pie executable, x86-64, dynamically linked
readelf -h ./chall # 文件头:Type: DYN / Entry point / Machine
readelf -l ./chall # 程序头:LOAD 段权限 R E / RW,看有无可写可执行段
readelf -S ./chall # 节头:.init_array/.fini_array 常被用作反调试点
readelf -s ./chall | head -40 # 动态符号,即便 strip 过 .dynsym 往往还在
objdump -d -M intel ./chall # 反汇编,intel 语法比 AT&T 好读
strings -a -n 6 ./chall | less # 长度>=6 的可打印串,找提示与格式串
checksec --file=./chall # pwntools 自带,输出 NX/PIE/Canary/RELRO
几个高价值细节:readelf -d 看 DT_NEEDED 能确认用了哪些库;.init_array 里放构造函数,混淆器常把反调试逻辑塞在这里,比 main 更早执行;strings 之后紧跟 objdump -s -j .rodata 可以确认字符串所在的虚拟地址,方便在反汇编里按地址回跳。
checksec 的结果对 Reverse 也有用:Canary 存在说明有栈保护、FORTIFY 说明部分函数被替换成 __printf_chk 之类,反汇编里看到的陌生符号多半来自这里。若后续要转向漏洞利用,可以接着读 Pwn 方向栈溢出与 ROP 链
。
推荐的固定动作顺序,可以写进笔记模板:
1 file + sha256sum 确认类型与指纹,方便赛后复盘
2 checksec 确认 PIE / NX / Canary / RELRO
3 strings -a -n 6 捞提示串、格式串、可疑字母表
4 readelf -hSl 确认架构、入口、段权限、节布局
5 nm -D / readelf -s 确认还残留哪些动态符号
6 objdump -d -M intel 对 main / 入口附近做粗读
7 载入 IDA 或 Ghidra 建立交叉引用与函数命名
第 3 步有个技巧:把 strings 的输出按长度排序,异常长的串往往是 base64 字母表或被硬编码的密文;把输出和 objdump -s -j .rodata 对照,就能拿到每个串的虚拟地址。
工程启示:发布版本应剥离符号并关闭调试信息,但不要把「剥离」当成安全边界。符号剥离只提高阅读成本,不改变算法可还原性;真正有效的是把关键判定放到服务端。
3. 反汇编与反编译工具链:IDA Pro 与 Ghidra
反汇编器把机器码翻译成汇编(一一对应,无损),反编译器在此基础上重建伪 C 代码(有损,是猜测)。两者不是替代关系:伪代码给你全局轮廓,汇编给你精确语义。
IDA Pro 的使用要点:加载时选对处理器与基址;Shift+F12 打开字符串窗口并按 X 查交叉引用;函数内按 N 重命名、Y 改类型,命名会级联影响反编译质量;Tab 在反汇编与伪代码间切换;F5 生成伪代码。对 malloc 返回的指针手动设类型(Alt+Q 或右键 Set Type),是提升伪代码可读性最有效的一步。
Ghidra 是免费替代,优势在反编译质量与脚本化:analyzeHeadless 可批量处理;Decompiler 的 Highlight 与 Data Type Manager 能导入自定义结构体。缺点是大项目分析慢、对某些加壳样本不如 IDA 稳。
IDA 常用快捷键速查
G 跳转地址 / 符号
X 查看交叉引用(谁调用了这里)
N 重命名
Y 设置类型
H 转成十进制显示
; 加注释
Space 切换图形视图 / 文本视图
Ghidra 的脚本化能力值得单独投入时间。下面这段 PyGhidra / Jython 风格的片段遍历所有函数,把「调用了 strcmp 的函数」列出来,快速缩小校验点范围:
from ghidra.program.model.symbol import RefType # 在 Ghidra Script Manager 里运行
fm = currentProgram.getFunctionManager()
for f in fm.getFunctions(True):
for callee in f.getCalledFunctions(monitor):
if callee.getName() in ("strcmp", "memcmp", "strncmp"):
print(f.getEntryPoint(), f.getName(), "->", callee.getName())
命令行侧的 analyzeHeadless 让批量分析成为可能,适合在赛前把一批附件一次性跑完建立索引:
analyzeHeadless /tmp/proj rev -import ./chall \
-postScript DumpStrings.java -deleteProject
IDA 与 Ghidra 的取舍很实际:IDA 的调试器集成、类型库、反编译质量更好,但授权成本高;Ghidra 免费、可脚本化、反编译在多数样本上够用。日常练习建议先用 Ghidra 打底,遇到啃不动的再上 IDA。
工程启示:逆向工具链同样适用于安全审计与合规核验,比如确认第三方 SDK 是否偷偷上传设备标识。建立「二进制成分分析 + 反编译复核」流程,比只依赖 SBOM 更可靠,相关治理思路见 CTF 工具链与攻击视角下的防御一文。
4. 反编译伪代码为什么不「准」
理解失真来源,才知道什么时候必须回到汇编。
第一类是优化:-O2 会把短函数内联(inline)、把循环展开、把常量折叠、把 x*4 变成 lea、把 if/else 变成 cmov 或条件跳转的等价形式。反编译器还原出的控制流可能与源码结构完全不同。
第二类是间接跳转:switch 编译成跳转表(.rodata 里的地址数组)或两级表,虚函数调用走虚表(vtable)取指针再调用。反编译器看到 jmp rax 时无法静态确定目标集合,只能保守截断。
第三类是类型丢失:剥离符号后,参数个数与类型全靠调用约定和用法推断,反编译器会给出 int、undefined4、__int64 这类占位类型,甚至把指针当整数。
| 现象 | 根因 | 应对 |
|---|---|---|
伪代码里出现 undefined 变量 | 类型推断失败 | 手工设类型,交叉验证用法 |
| 循环体消失 / 被展开 | 编译器 unroll | 回汇编按跳转边界重画 |
jmp rax 无法跟进 | 间接跳转 | 动态断点记录实际目标 |
| 参数个数不对 | 调用约定误判 | 看 rdi/rsi/rdx 赋值序列 |
一个典型的「源码与伪代码对不上」的例子。源码是:
int check(int a, int b) {
if (a > b) return a - b;
return b - a;
}
-O2 编译后可能变成无分支形式,反编译出来是:
check:
mov eax, edi
sub eax, esi ; eax = a - b
mov edx, esi
sub edx, edi ; edx = b - a
cmovl eax, edx ; 若 a-b < 0 则取 edx
ret
cmov 抹掉了显式分支,反编译器只能给出 return (a-b) < 0 ? b-a : a-b; 这种等价但结构不同的结果。语义没错,但你按这个去搜「哪两个数在比较」会走偏。
反过来,switch 被编译成跳转表时,反编译器常常给出一个巨大的 switch 加一个 default: goto 的混乱结构。这时正确的做法是直接读 .rodata 里那张地址表(objdump -s -j .rodata 能看到一串 8 字节地址),按表项顺序还原 case 编号。
工程启示:不要盲信反编译结果去做安全结论,尤其是「这段代码没有校验」这类判断。伪代码看不到的分支,可能恰恰是校验逻辑。
5. x86-64 汇编基本功:System V 调用约定与栈帧
读汇编的核心是「参数从哪来、结果到哪去」。Linux x86-64 用 System V AMD64 ABI:整数与指针参数依次放 rdi, rsi, rdx, rcx, r8, r9,浮点走 xmm0–xmm7,返回值放 rax(浮点 xmm0)。超过六个整数参数的部分压栈,且从右往左压。
寄存器用途速记:rax 累加器与返回值,rbx 被调用者保存(callee-saved),rcx 计数与第四参数,rdx 第三参数与除法高位,rsi/rdi 源/目的与第二/第一参数,rbp 帧指针,rsp 栈指针,r8–r15 中 r8–r11 调用者保存、r12–r15 被调用者保存。
函数序言与尾声是识别函数边界最可靠的标志:
push rbp ; 保存调用者帧指针
mov rbp, rsp ; 建立新帧
sub rsp, 0x40 ; 分配 64 字节局部变量
mov DWORD PTR [rbp-0x4], edi ; 第一个 int 参数落到局部变量
lea rax, [rip+0x2f11] ; RIP 相对寻址,取 .rodata 字符串地址
call 0x401136 ; 调用,可能是 puts / strlen
leave ; mov rsp,rbp + pop rbp
ret
lea 要特别熟练:lea rax, [rdi+rdi*4] 等于 rax = rdi*5,是编译器做常数乘法与地址运算的通用手段,看到它别急着认为在取地址。[rbp-0x20] 是局部数组,[rbp+0x10] 通常是第七个及以后的栈参数。
常见寻址模式对照表,读汇编时按这个查:
| 写法 | 含义 | 常见来源 |
|---|---|---|
[rbp-0x8] | 局部变量 | 栈上标量 |
[rbp-0x40] | 局部数组基址 | 缓冲区 |
[rbp+0x10] | 第七个栈参数 | 参数多于六个 |
[rip+0x2f11] | 全局 / 常量池 | 字符串、跳转表 |
[rax+rdx*8] | 数组索引 | 指针数组、跳转表 |
[rax+rax*2] | rax*3 | 常数乘法 |
字符串比较循环也值得记住模板:编译器常把 strcmp 内联成逐字节循环,形如 movzx eax, byte ptr [rdi+rcx] / cmp al, byte ptr [rsi+rcx] / jne 的结构。看到这种模式就知道这里在比字符串,紧接着的比较常量往往就是 flag 的一部分。
工程启示:读汇编的能力直接迁移到崩溃分析、性能调优与内核调试。若你关注内核态,可对照 内核调试与 kgdb 里的寄存器与栈回溯方法,两者共用同一套心智模型。
6. 常见算法识别:base64、TEA、RC4 与异或
CTF 逆向题里 80% 的算法是「教科书算法 + 一点点魔改」。建立指纹库能让你在三十秒内判断出题人用了什么。
base64 的指纹是字母表字符串 ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/,以及 >> 6、>> 4、>> 2 配合 & 0x3f 的位运算序列。自定义字母表只是换了表,恢复方法是从 .rodata 里把那串 64 字节抠出来。
TEA/XTEA 的指纹是魔数 0x9E3779B9(黄金比例倒数)与 sum += delta 的迭代结构;XTEA 额外多了 (sum>>5)+key[1] 这类移位异或。RC4 的指纹是 256 字节的 KSA 初始化循环(s[i] = i 后 j = (j + s[i] + key[i % len]) & 0xff 交换)加 PRGA 异或。
import base64 # 教学用最小复现:识别自定义字母表 base64 的还原思路
std = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
custom = "QwErTyUiOpAsDfGhJkLzXcVbNm1234567890+/aB" # 从 .rodata 抠出
trans = str.maketrans(custom, std)
ct = "从程序输出里抄下来的密文=="
print(base64.b64decode(ct.translate(trans)))
TEA 的参考实现(32 轮、delta = 0x9E3779B9),把它记熟就能一眼认出:
void tea_decrypt(uint32_t *v, const uint32_t *k) {
uint32_t sum = 0xC6EF3720; // delta * 32
for (int i = 0; i < 32; i++) {
v[1] -= ((v[0] << 4) + k[1]) ^ (v[0] + sum) ^ ((v[0] >> 5) + k[2]);
v[0] -= ((v[1] << 4) + k[0]) ^ (v[1] + sum) ^ ((v[1] >> 5) + k[3]);
sum -= 0x9E3779B9;
}
}
RC4 的 KSA/PRGA 骨架同样要熟。识别点在于「256 字节数组 + 交换 + 取模 256」:
def rc4(key: bytes, data: bytes) -> bytes: # 教学用最小复现:RC4 PRGA 的识别特征
s = list(range(256))
j = 0
for i in range(256): # KSA
j = (j + s[i] + key[i % len(key)]) & 0xff
s[i], s[j] = s[j], s[i]
out, i, j = bytearray(), 0, 0
for b in data: # PRGA
i = (i + 1) & 0xff
j = (j + s[i]) & 0xff
s[i], s[j] = s[j], s[i]
out.append(b ^ s[(s[i] + s[j]) & 0xff])
return bytes(out)
单字节异或与滚动异或(x ^= prev)常见于「入门题」。识别方法是看密文熵:单字节异或多半是高频重复字节,可以用 xortool 猜长度与密钥。矩阵变换类题目(M * flag = C)在 GF(2) 或模素数下求解,属于线性代数而非密码学。
工程启示:这类「弱算法识别」同样适用于合规审计——发现产品用自研异或混淆代替标准加密时,应判定为设计缺陷,因为它不提供机密性保证。
7. 混淆对抗:控制流平坦化与不透明谓词
当题目难度上到「中等」以上,出题人(或商业加固产品)会用 OLLVM 系列混淆。认识两种主力手法就够应付大部分题。
控制流平坦化(Control Flow Flattening):把所有基本块塞进一个 switch 里,用状态变量 state 串起来,主循环形如 while(1){ switch(state){ case 0xA3: ...; state = 0x17; break; ... } }。还原思路是识别状态变量的赋值序列,重建真实的基本块前驱后继关系,再按 state 转移图重排代码。手工做法是在每个 case 末尾记录 state = 常量,画出有向图。
不透明谓词(Opaque Predicate):插入恒真或恒假但静态难以判断的分支,例如 if ((x*x + x) % 2 == 0) 恒真(因为 x(x+1) 必为偶数)。识别方法是找那些「看起来复杂但两边代码不对称」的条件,真分支往往是正常逻辑,假分支是垃圾代码。
平坦化还原的手工步骤
1 定位分发器:找循环体内的多路跳转(jmp 表或 switch)
2 提取状态变量:找被反复读写的同一局部变量
3 记录转移:每个基本块末尾的 state 赋值即一条边
4 重排:按状态机拓扑顺序展开成线性/树形代码
5 校验:还原后重新编译跑一遍,与原始输出比对
不透明谓词在汇编层的典型长相:
mov eax, edi ; eax = x
imul eax, edi ; eax = x * x
add eax, edi ; eax = x*x + x
and eax, 1 ; 取最低位
test eax, eax
jne real_logic ; 恒不跳(x(x+1) 必为偶数),后面是垃圾块
识别要点:条件里的表达式只用到已确定取值的变量,且两分支的「代码体量」严重不对称。另一个变体是「永不执行的死分支里塞入真逻辑」,反编译器的启发式会优先走「看起来正常」的那一支,反而误导你。对抗方法是手工判断条件的可满足性——对 x(x+1) 这类多项式,奇数/偶数性质直接决定结果。
如果混淆强度很高,可以试试去平坦化插件(如 ollvm-breaker、D-810),它们用符号执行或模式匹配还原状态机。但要注意,插件对魔改过的分发器(比如状态变量被拆成两个、或用 state ^ 0x55 做编码)常常失效,最终还是得手工画转移图。
工程启示:混淆只提升人工成本,对符号执行与污点分析的效果有限。若你是加固方,应把预算放在「服务端校验 + 完整性度量」上,而不是无限加厚客户端混淆。
8. 约束求解与字节码逆向:z3、pyc 与 .NET
当校验逻辑是「对输入做一系列位运算后与常量比较」,把程序语义翻译成约束、交给 SMT 求解器,比手工逆推快一个数量级。z3 是主力工具。
from z3 import Solver, BitVec, BitVecVal, Or, sat # 把逐字节校验翻译成 z3 约束
flag = [BitVec(f"f{i}", 8) for i in range(8)]
s = Solver()
for c in flag: # 限定可打印字符,缩小搜索空间
s.add(Or(And(c >= 0x20, c <= 0x7e)))
s.add(flag[0] ^ 0x13 == 0x5d) # 逐条抄写程序里的校验式
s.add((flag[1] + 0x2a) & 0xff == 0x61)
s.add(flag[2] * 3 & 0xff == 0xd2)
print(s.check(), s.model() if s.check() == sat else "")
要点:用 BitVec(8) 而不是 Int,因为程序里的运算是模 256 的,用整数会漏解或错解;先加可打印字符约束能极大加速;约束太多时按「独立字节组」切分成多个小求解任务。
建模还有几个实用技巧:把「大数组常量比较」改写成循环约束而不是逐条抄写,能显著减少手误;用 s.check() 之前先 s.push() / s.pop() 试探局部约束,定位是哪个条件导致 unsat;求解慢时给 Solver() 设 timeout 并把任务切片并行。
字节码逆向是另一条支线。Python 的 .pyc 用 dis 模块就能反汇编,配合 marshal 解析 code object:
python3 -c "import dis,marshal,sys; \
f=open('chall.pyc','rb'); f.read(16); \
dis.dis(marshal.load(f))"
Python 3.8+ 的 pyc 头部是 16 字节(magic 4 + flags 4 + 时间戳/哈希 8),版本不匹配会导致反编译失败,用 uncompyle6 或 decompyle3 时要对齐版本;更高版本可考虑 pycdc。Java 用 javap -c -p 或 CFR/Procyon,.class 的常量池是还原字符串的关键。.NET 用 dnSpy 或 ILSpy,IL 比汇编好读得多,很多时候直接改 IL 就能过校验。若题目还叠了密码学,可参考 Crypto 方向 RSA 常见攻击手法里的数学建模思路。
工程启示:约束求解能自动化还原「纯计算型」校验,这意味着任何纯客户端的 flag/许可证判定都不具备抗破解性。许可证校验应包含服务端签发与签名验证。
9. 检测与防御视角下的静态分析
站在防守方,静态分析能力要反过来用:既要知道自己的二进制会被怎么读,也要用它做安全核验。
软件保护的成本曲线是陡峭的:符号剥离几乎零成本但只挡住新手;OLLVM 混淆成本中等,能挡住自动化工具但挡不住有经验的选手;虚拟机保护(VMProtect 类)成本最高,能把逆向时间从小时拉到周,但会显著影响性能与稳定性,且仍有「还原字节码解释器」的系统性破解路径。
合规边界必须清楚:本文所有方法面向 CTF 靶场与自己拥有/被授权测试的软件。对真实商业软件做绕过授权、破解许可、提取商业机密,在多数司法辖区属于违法行为;竞赛中拿到的二进制也仅限比赛用途。
完整性校验是低成本高收益的防线:发布产物附签名与哈希,客户端启动时校验自身关键段,服务端对关键请求做二次判定。供应链侧则要防范「构建产物被替换」,这与攻击视角下的防御工程实践一致。
权衡取舍
| 方案 | 成本 | 适用场景 | 主要局限 |
|---|---|---|---|
| 纯静态反编译 | 低 | 无壳、无混淆的入门题 | 遇间接跳转即断链 |
| 静态 + 汇编交叉验证 | 中 | 中等难度、有少量混淆 | 耗时长,依赖经验 |
| 静态 + 动态调试 | 中高 | 有反调试、自修改代码 | 需要可运行环境 |
| 符号执行(angr) | 高 | 路径明确的校验题 | 路径爆炸、状态数失控 |
| 约束求解(z3) | 中 | 纯计算型校验 | 语义翻译易出错 |
| 手工重写算法 | 高 | 算法魔改严重的题 | 完全依赖理解正确性 |
选择原则:先用静态拿到全局结构与字符串线索;反编译读不懂就回汇编;遇到加密常量或间接调用就上动态;确认是纯计算校验再上 z3。不要一开始就打开 angr,它在带循环与系统调用的题上经常跑不完。
常见坑清单
- 把 PIE 二进制的静态地址当成运行时地址,断点永远打不中——先算基址偏移再下断。
- 只信伪代码就下结论说「没有校验」——伪代码在间接跳转处会静默截断,必须回汇编确认。
- 用
Int而不是BitVec建模 z3 约束,模运算丢失导致解错或求不出解。 - 忽略
-O2内联,按伪代码的函数边界去找「校验函数」,结果它在main里被展开了。 - 忘了
.init_array里的构造函数,反调试逻辑在main之前就跑完并改了行为。 - 用错反编译工具版本读高版本
.pyc,magic 不匹配导致静默产出错误代码。 strings只看长度默认值(4),漏掉短的关键字符串,应加-n 6或更小。- 手工 patch 时混淆了 RVA 与文件偏移,写坏 PE 节表导致程序无法加载。
- 把
lea rax, [rdi+rdi*4]误读为「取地址」,实际是rdi*5的乘法。 - 对未授权的商业软件使用本文方法,越过合规边界,这是法律问题而非技术问题。
小结
静态分析的核心不是工具,而是「证据链」思维:文件格式告诉你信息在哪,反编译器给出全局轮廓,汇编给出精确语义,交叉引用串起调用关系,约束求解把语义转成可计算的方程。任何单一证据都可能有偏差,只有多条证据自洽时结论才可靠。
工程上,先花两分钟做信息收集(file、checksec、strings、readelf)永远值得,它决定了后面走哪条路。遇到读不懂的伪代码,退回汇编;遇到算不出的校验,建模给 z3;遇到反调试与加壳,就该切到动态调试,这部分内容在 Reverse 方向动态调试与脱壳
里展开。
最后提醒合规:这些技术适用于 CTF 靶场、自有软件与授权测试。能力越强,越要清楚边界在哪里。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。