Reverse 方向静态分析与反编译

以 CTF 逆向题的授权分析场景为背景,讲清静态分析与反编译方法论:从 ELF 与 PE 格式、readelf 与 objdump 信息收集,到 IDA Pro 与 Ghidra 的交叉引用与类型恢复,再到伪代码失真的根因、System V 调用约定与栈帧阅读、base64 与 TEA 算法指纹识别、控制流平坦化与不透明谓词、z3 约束求解建模,以及 Python 字节码与 .NET 逆向要点。

引言

逆向工程(Reverse Engineering)在 CTF 里是「读程序」的学问:给定一个剥离了符号表的二进制,要求还原出它校验 flag 的算法,最终把输入解出来。它和 Pwn 的分工很清楚,Pwn 关心的是内存破坏如何被利用,Reverse 关心的是「这段机器码到底在算什么」。很多题目是两者的混合体:先用 Reverse 定位漏洞点与偏移,再用 Pwn 构造利用链,所以这两条线在实战里经常来回切换。

静态分析指不运行程序、只看文件字节的方法:读段表、看反汇编、跑反编译器、做交叉引用。它的优势是离线、全局、可自动化,劣势是面对加壳、自修改代码、间接跳转时会「断链」。动态调试则是让程序真的跑起来,用断点观察寄存器与内存。成熟的做法是两者交替:静态给出假设,动态做验证。

真正的难点不在工具按钮,而在证据链。编译器优化会摧毁源码结构,反编译器的伪代码是「概率性猜测」而不是事实,出题人还会主动施加混淆与反调试。因此逆向的过程是不断提出假设、用汇编、内存快照、约束求解等多种证据交叉验证,直到所有证据自洽。

本文按「格式基础 → 信息收集 → 工具链 → 反编译失真 → 汇编阅读 → 算法识别 → 混淆对抗 → 约束求解与字节码 → 防御视角」推进。适合刚入门的 CTF 选手,也适合需要做二进制审计与供应链核验的工程师。若你是零基础,建议先看 CTF 竞赛全景与学习路径 建立方向感。

目录

  1. 可执行格式基础:ELF 与 PE 的结构对照
  2. 信息收集:file、readelf、objdump、strings、checksec
  3. 反汇编与反编译工具链:IDA Pro 与 Ghidra
  4. 反编译伪代码为什么不「准」
  5. x86-64 汇编基本功:System V 调用约定与栈帧
  6. 常见算法识别:base64、TEA、RC4 与异或
  7. 混淆对抗:控制流平坦化与不透明谓词
  8. 约束求解与字节码逆向:z3、pyc 与 .NET
  9. 检测与防御视角下的静态分析

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 时最常见的错点。

概念ELFPE
魔数7f 45 4c 464d 5a(MZ)
加载单位段(Program Header)节(Section Table)
入口e_entryAddressOfEntryPoint
重定位.rela.dyn / .rela.plt.reloc 目录
导入表.dynsym + GOTImport 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,它在带循环与系统调用的题上经常跑不完。

常见坑清单

  1. 把 PIE 二进制的静态地址当成运行时地址,断点永远打不中——先算基址偏移再下断。
  2. 只信伪代码就下结论说「没有校验」——伪代码在间接跳转处会静默截断,必须回汇编确认。
  3. 用 Int 而不是 BitVec 建模 z3 约束,模运算丢失导致解错或求不出解。
  4. 忽略 -O2 内联,按伪代码的函数边界去找「校验函数」,结果它在 main 里被展开了。
  5. 忘了 .init_array 里的构造函数,反调试逻辑在 main 之前就跑完并改了行为。
  6. 用错反编译工具版本读高版本 .pyc,magic 不匹配导致静默产出错误代码。
  7. strings 只看长度默认值(4),漏掉短的关键字符串,应加 -n 6 或更小。
  8. 手工 patch 时混淆了 RVA 与文件偏移,写坏 PE 节表导致程序无法加载。
  9. 把 lea rax, [rdi+rdi*4] 误读为「取地址」,实际是 rdi*5 的乘法。
  10. 对未授权的商业软件使用本文方法,越过合规边界,这是法律问题而非技术问题。

小结

静态分析的核心不是工具,而是「证据链」思维:文件格式告诉你信息在哪,反编译器给出全局轮廓,汇编给出精确语义,交叉引用串起调用关系,约束求解把语义转成可计算的方程。任何单一证据都可能有偏差,只有多条证据自洽时结论才可靠。

工程上,先花两分钟做信息收集(file、checksec、strings、readelf)永远值得,它决定了后面走哪条路。遇到读不懂的伪代码,退回汇编;遇到算不出的校验,建模给 z3;遇到反调试与加壳,就该切到动态调试,这部分内容在 Reverse 方向动态调试与脱壳 里展开。

最后提醒合规:这些技术适用于 CTF 靶场、自有软件与授权测试。能力越强,越要清楚边界在哪里。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「网络安全攻防」更多文章

  1. 流量分析与协议逆向
  2. 椭圆曲线与格攻击
  3. Windows 提权与 AD 内网渗透