做逆向时间长了,你会发现真正的日常不是手持十六进制编辑器盯着字节发呆,而是要频繁做三件事:把机器码反汇编成可读的汇编、把修改后的指令重新编码成字节、再把某段代码放进一个可控环境里跑起来看行为。这三件事各有专业工具,但能在一套 Python 环境里完美闭环的,就是圈子里常说的“逆向三剑客”——Keystone汇编引擎、Capstone反汇编引擎、UnicornCPU 模拟引擎。
这三个库不是我吹,凡是做过二进制分析、漏洞研究、恶意代码行为分析或者 CTF 的人,大概率都在项目里和它们打过交道。它们的共同点是都支持非常广泛的 CPU 架构(x86、ARM、Mips、PowerPC、RISC-V 等),都有干净的 C 核心和 Python 绑定,不仅适合做自动化分析脚本,更重要的是,把它们拆开用各管一段,合起来用就能组成一条完整的“反汇编 → 修补 → 模拟执行”流水线。这篇文章我就按我自己实际使用时的思路,把每个库怎么用、三个怎么串联、以及踩过的坑一次性讲清楚。
1. 三剑客各管哪一段:从机器码到可观测行为
1.1 一条完整的二进制分析流水线
如果你拿到一个只给了二进制文件的函数,想知道它是干嘛的、怎么改行为、改了之后能不能跑,那要过的环节其实很固定。
第一步是“读”。机器码看不懂,需要把它翻译成汇编指令,看寄存器变化、内存访问、控制流跳转。这一步对应的是 Capstone。第二步是“写”。看完之后常常要动手改,比如把某个立即数从 10 改成 20,把某个条件跳转改成无条件跳转。你当然可以手算机器码,但效率太低,更自然的是直接写一行汇编,让工具把它编码成字节,替换进原文件。这一步对应 Keystone。第三步是“验”。改完后的代码不能直接拿整个进程去跑,太重也不安全,最好在一个完全受控的内存环境里单独执行这段函数,观察寄存器最终状态、内存写入情况。这一步对应 Unicorn。
所以你看,这三个工具不是各自的“平替”,而是同一条产线的前中后段。单独用任何一个都有价值,但组合起来才是它们被称为“三剑客”的真正原因。我在给新人介绍的时候,一般直接说:Capstone 是翻译官,Keystone 是复写员,Unicorn 是沙箱。
1.2 三个项目的身世与血缘
这三个项目不是偶然长得像,它们真的是一家人。Capstone 和 Keystone 的设计框架都源自 LLVM 的 MC 底层,只不过 Capstone 把指令解码方向抽了出来,Keystone 把编码方向抽了出来。Unicorn 则是基于 QEMU 的 CPU 模拟核心,很多 API 习惯和 QEMU 一脉相承。也因此,三者的架构常量、模式常量(比如 x86 的 16/32/64 位模式)都非常接近,用起来不太需要额外记忆。
有个比较直观的对比表,我整理了一下我常用的信息:
| 工具 | 核心作用 | 方向 | 典型架构 | 一句话记忆 |
|---|---|---|---|---|
| Capstone | 反汇编 | 二进制 → 汇编 | x86/x64、ARM、Mips、PowerPC、RISC-V | 翻译官 |
| Keystone | 汇编 | 汇编 → 二进制 | x86/x64、ARM、Mips、PowerPC、SPARC | 复写员 |
| Unicorn | 模拟执行 | 二进制 → 行为 | x86/x64、ARM、Mips、RISC-V 等 | 沙箱 |
血缘近的好处是,出错的特征也类似,排错经验往往能互相复用。坏处是,如果你分不清它们各自该负责哪一步,很容易出现“用 Capstone 反汇编出来的指令,直接喂给 Keystone 却编不回去”的困惑——这两者的语法语义并不完全对等。后面我会专门讲这个差别。
2. 环境准备:安装这一步就有三个暗坑
2.1 三个库的安装方式
先说最简单的。全部用 pip 装,这是 Python 绑定最舒服的一点:
pip install capstone pip install keystone-engine pip install unicorn注意了,Keystone 的 PyPI 包名是keystone-engine,不是keystone。我第一次装的时候直接pip install keystone,装了个完全不相干的东西,import 的时候才发现不对。这个小坑我在不同场合提醒过很多人,每次都有人中招。
三者的 import 名倒是很统一:
import capstone import keystone import unicorn如果你的环境里同时需要 C 库做二次开发,也可以直接拉源码编译。但日常做分析脚本,pip 预编译的 wheel 基本够用,没必要从源码折腾。唯一要注意的是 Python 版本,装的时候留意一下 wheel 是否匹配你当前的解释器,老版本 Python 有时候需要自己编译。
2.2 各自的最小调用验证
装完之后,先别急着写大段逻辑,跑通三个最小调用比什么都重要。我通常用下面这几行代码验证环境。
Capstone 最小示例:
from capstone import * code = b"\x55\x48\x8b\x05\xb8\x13\x00\x00" md = Cs(CS_ARCH_X86, CS_MODE_64) for insn in md.disasm(code, 0x1000): print("0x%x:\t%s\t%s" % (insn.address, insn.mnemonic, insn.op_str))Keystone 最小示例:
from keystone import * ks = Ks(KS_ARCH_X86, KS_MODE_64) encoding, count = ks.asm("lea rax, [rip + 0x13b8]") print(bytes(encoding).hex())Unicorn 最小示例:
from unicorn import * from unicorn.x86_const import * CODE = b"\xb8\x2a\x00\x00\x00\xc3" # mov eax, 0x2a; ret BASE = 0x10000 mu = Uc(UC_ARCH_X86, UC_MODE_64) mu.mem_map(BASE, 0x1000) mu.mem_write(BASE, CODE) mu.emu_start(BASE, BASE + len(CODE)) print(hex(mu.reg_read(UC_X86_REG_RAX)))这三段代码都能跑通,说明你的三件套安装没问题。注意 Unicorn 例子里 emu_start 的第二个参数是until地址,也就是停止地址,我写成了BASE + len(CODE)。如果这段代码有ret,可能会因为返回地址不可用而出错,后面我会专门讲怎么处理栈的问题。
3. Capstone:把十六进制翻回人话
3.1 核心对象与 disasm 的返回逻辑
Capstone 的使用思路很直接:用Cs创建对象时指定架构和模式,然后调用disasm方法传入字节串和起始地址。注意这个起始地址不是“从哪里读”,而是“这段字节想假装自己在内存里的哪个位置”。它会直接反映到每条指令的address字段上,也会影响类似 RIP 相对寻址的直观理解。
disasm返回的是一个生成器,每个元素是一条指令对象。最常用的字段是:
address:这条指令在所在段中的地址mnemonic:助记符op_str:操作数size:这条指令占多少字节bytes:这条指令的原始字节
如果你只关心“这个二进制里调用了哪些函数”,用 Capstone 扫一遍,按mnemonic过滤出call就行。如果你要把反汇编结果用于更深的优化分析,可以给Cs传detail=True,这样指令对象会额外带上操作数详细信息、寄存器读写信息、指令分组等。代价是分析速度稍慢,日常脚本无所谓。
3.2 一个指令级分析例子
假设你从一个样本里抠出了一段待分析的代码:
from capstone import * code = bytes.fromhex( "55 48 89 e5 48 8b 45 f8 48 83 c0 10 5d c3" ) md = Cs(CS_ARCH_X86, CS_MODE_64) md.detail = True for insn in md.disasm(code, 0x4000): print(f"0x{insn.address:04x}: {insn.mnemonic} {insn.op_str} ({insn.size}B)")输出大致是:
0x4000: push rbp (1B) 0x4001: mov rbp, rsp (3B) 0x4004: mov rax, qword ptr [rbp - 8] (4B) 0x4008: add rax, 0x10 (4B) 0x400c: pop rbp (1B) 0x400d: ret (1B)从这个函数头就能看出来,它读了一个局部变量(很可能是外部传入的参数),加了 0x10 作为返回值。这一步的价值在于:你不必用肉眼去数48 8b 45 f8是干什么的,Capstone 直接告诉你结果。对初学者来说,反汇编输出还能帮助理解指令编码长度差异,比如为什么push rbp只有 1 字节,而mov rax, qword ptr [rbp - 8]要 4 字节。
3.3 多架构下的模式切换
Capstone 对多架构的支持是它成为“三剑客”之首的重要原因。切架构只需要改Cs的第一个参数和对应的模式常量:
- x86 32 位:
Cs(CS_ARCH_X86, CS_MODE_32) - x86 64 位:
Cs(CS_ARCH_X86, CS_MODE_64) - ARM 32 位(默认 ARM 模式):
Cs(CS_ARCH_ARM, CS_MODE_ARM) - ARM Thumb 模式:
Cs(CS_ARCH_ARM, CS_MODE_THUMB) - AArch64:
Cs(CS_ARCH_ARM64, CS_MODE_ARM) - Mips 32/64:
Cs(CS_ARCH_MIPS, CS_MODE_MIPS32 + CS_MODE_BIG_ENDIAN)等
实际做固件分析时经常遇到“同一段字节,用 ARM 模式解不开,换 Thumb 模式就通了”的情况。我建议写脚本的时候把架构和模式做成命令行参数,批量分析不同设备的固件时能省下大量重复劳动。
有个小细节:Capstone 对某些变长指令集(比如 x86)的指令边界判断并不总和你预期一致,尤其是数据夹在指令中间时,反汇编会“错位”。这是所有线性反汇编方法的通病,遇到这种情况再接上 Unicorn 和更高级的符号执行,也是后话。
4. Keystone:把汇编语义再变回字节
4.1 基本编码流程
Keystone 的用法和 Capstone 正好反过来。你用Ks创建对象,指定架构和模式,然后直接给asm方法传汇编代码。它返回的二元组里,第一个值是编码后的字节数组。
from keystone import * ks = Ks(KS_ARCH_X86, KS_MODE_64) code = "mov rax, 0x7b; ret" encoding, count = ks.asm(code) print(bytes(encoding).hex()) # 48 c7 c0 7b 00 00 00 c3 之类注意,Keystone 的汇编语法不是随意写的,它基本遵循主流汇编器语法。x86 系列默认是 Intel 语法,如果你更习惯 AT&T 语法,可以用ks.syntax = KS_OPT_SYNTAX_ATT切换。这个设置是运行时的,同一个Ks对象可以反复使用,不影响性能。
Keystone 对我日常帮助最大的一点,是不用再去查指令编码表。比如我想把原来的mov eax, 0x2a改成mov eax, 0x7b,不需要手动改机器码里的立即数字节,直接用汇编器重编一遍就行。尤其在批量 patch 场景下,这个能力几乎是刚需。
4.2 地址相关的编码问题
Keystone 暗坑最多的地方就在这里。x86 的相对跳转、RIP 相对寻址,编码时依赖“当前指令地址”。如果你不给地址就汇编,Keystone 默认地址是 0,那么jmp 0x1005这类指令算出来的偏移就是基于地址 0 的,结果很可能不是你想要的。
正确的做法是,asm方法可以传入第二个参数作为起始地址:
encoding, count = ks.asm("jmp 0x1005", 0x1000)这样 Keystone 在编码相对跳转时就知道当前指令位于 0x1000,目标 0x1005,两者之间的偏移是多少,从而生成正确的机器码。
再举一个更常见的例子,RIP 相对寻址:
ks.asm("lea rax, [rip + 0x1234]", 0x4000)我刚开始用 Keystone 的时候经常忘传地址,结果生成的字节放到 Unicorn 里一跑,PC 全部错乱。这个教训现在已经成为我的固定习惯:只要汇编代码里出现任何相对寻址,第一件事就是在asm里传地址。
4.3 典型用途:补丁脚本与模糊测试种子
Keystone 的实战价值不只是“把指令改一下”,它能让你的补丁工作从手工改字节升级成脚本化、可复现的流程。举个例子,你想把某个函数开头的mov eax, 0x2a; ret整体替换成mov eax, 0x7b; ret,手改的话要算好字节长度,甚至可能要补齐 NOP;而用 Keystone,你只要写:
patch_code = "mov eax, 0x7b; ret" encoding, _ = ks.asm(patch_code) new_bytes = bytes(encoding).ljust(6, b"\x90") # 用 NOP 补齐长度另外在模糊测试场景里,Keystone 也很顺手。比如你想生成一系列不同立即数的测试用例,直接循环调用ks.asm,每次改一个立即数,就能快速得到几十个不同的机器码片段。这比准备一堆十六进制样例要直观得多,也更不容易出错。
5. Unicorn:没有 CPU 也能跑代码
5.1 内存映射、写入与执行的基本流程
Unicorn 的本质是一个 CPU 模拟器,它不依赖你的物理架构。你在 x86 的电脑上可以模拟 ARM 代码,也可以在 64 位系统里模拟 32 位程序。上手第一件事是理解它的内存模型:你必须在虚拟地址空间里主动映射一块内存,再把代码写进去,然后才能执行。
基本流程永远是这套四步走:
from unicorn import * from unicorn.x86_const import * CODE = b"\xb8\x2a\x00\x00\x00" # mov eax, 0x2a BASE = 0x10000 mu = Uc(UC_ARCH_X86, UC_MODE_64) mu.mem_map(BASE, 0x1000) # 1. 映射内存 mu.mem_write(BASE, CODE) # 2. 写入代码 mu.emu_start(BASE, BASE + len(CODE)) # 3. 执行 rax = mu.reg_read(UC_X86_REG_RAX) # 4. 读取结果 print(hex(rax))emu_start的第二个参数是停止地址,表示“当 PC 到达这个地址时,模拟停止”。这里我故意没有放ret,而是直接把停止地址放在指令末尾,避免了返回地址的问题。很多新手一上来就模拟带ret的函数,结果各种报错,原因就是栈还没准备好。
5.2 函数级模拟:传参、执行、读返回值
单独执行一段指令只是开胃菜,Unicorn 真正强大的是可以在内存里拉出一个完整的“微型进程”,加载一个函数,设置寄存器参数,执行后读返回值。
以 x64 调用约定为例,前几个参数放在 RDI、RSI、RDX,返回值在 RAX。假设有一段汇编是add rdi, rsi; mov rax, rdi; ret,我们可以模拟一个函数调用:
from unicorn import * from unicorn.x86_const import * CODE = b"\x48\x01\xf7\x48\x89\xf8\xc3" # 对应: add rdi, rsi; mov rax, rdi; ret BASE = 0x10000 STACK = 0x20000 mu = Uc(UC_ARCH_X86, UC_MODE_64) mu.mem_map(BASE, 0x1000) mu.mem_write(BASE, CODE) # 设置参数 mu.reg_write(UC_X86_REG_RDI, 10) mu.reg_write(UC_X86_REG_RSI, 32) # 准备栈内存 mu.mem_map(STACK, 0x1000) mu.reg_write(UC_X86_REG_RSP, STACK + 0x800) mu.mem_write(STACK + 0x800, (BASE + 0x100).to_bytes(8, "little")) # 执行到 ret 后再往后走一点,等于停在返回地址处 mu.emu_start(BASE, BASE + 0x100) print(mu.reg_read(UC_X86_REG_RAX)) # 42这里的关键是栈顶写了返回地址BASE + 0x100,所以执行到ret后,PC 跳到 0x100,而我们把until设成 0x100,模拟随之停止。只要返回地址指向的是一块已映射且可执行的内存,Unicorn 就不会报错。
5.3 挂钩子观察指令执行流
有时候你不只是想拿结果,还想看看这段代码到底是怎么一步步跑的、有没有偷偷跳转。Unicorn 的 hook 机制在这里特别有用。
def hook_code(uc, address, size, user_data): print(f">>> Executing at 0x{address:x} (size={size})") mu.hook_add(UC_HOOK_CODE, hook_code)钩子回调会在每条指令执行前被调用,你可以打印地址、修改寄存器、甚至直接改内存。我在分析壳的变形循环或者自解密代码时,最常用的就是记录 PC 轨迹,看它有没有在几个地址之间反复横跳,看完基本就能猜出循环边界。
6. 三剑客组合拳:一次函数修补的从读到验证
6.1 场景设定与目标
把三个工具串起来的价值,我在一次处理函数修补时感受特别深。目标手上的二进制里有一个函数,返回值是硬编码的 0x2a,现在要让它返回 0x7b。不能碰整个程序,只能改这一个函数。最稳妥的流程是:先反汇编确认原始字节,再用汇编器生成替换字节,最后放进模拟器里验证效果。
这个流程听起来简单,但每一步都有细节。如果只靠十六进制编辑器,你得反复对比指令长度、手动计算立即数偏移,验证时还得把整个程序跑起来。而用三剑客,整个过程可以浓缩成一个 Python 脚本。
6.2 第一步:Capstone 读取原始函数的真实意图
先把目标函数原始 6 个字节拿过来,交给 Capstone:
from capstone import * old = bytes.fromhex("b8 2a 00 00 00 c3") md = Cs(CS_ARCH_X86, CS_MODE_64) for insn in md.disasm(old, 0x1000): print(f"0x{insn.address:x}:\t{insn.mnemonic}\t{insn.op_str}")输出非常明确:
0x1000: mov eax, 0x2a 0x1005: ret这一步的作用是“确认语义”。不要小看它,很多新手拿到b8 2a 00 00 00只会背“这是 mov eax, 42”,但一旦遇到多字节指令、指令边界不清晰的情况,没有自动反汇编几乎是睁眼瞎。
6.3 第二步:Keystone 生成修补后的新字节
现在我知道要改的只是立即数。用 Keystone 汇编:
from keystone import * ks = Ks(KS_ARCH_X86, KS_MODE_64) new_code = "mov eax, 0x7b; ret" encoding, _ = ks.asm(new_code, 0x1000) new = bytes(encoding) print(new.hex()) # b8 7b 00 00 00 c3这里有个细节,我传了地址 0x1000 给asm。虽然该指令本身不涉及相对寻址,但我仍然保持这个习惯,一旦以后换成带跳转指令的代码,就不会因为漏传地址而产出错误字节。新字节的长度如果比旧字节短,还需要考虑补齐;如果更长,就得看周围是否有足够的空间放跳板,这个属于插桩工程的范畴了。
6.4 第三步:Unicorn 验证新代码真的干了这个事
字节生成之后,不能直接说“成功”,要放进 Unicorn 跑一下:
from unicorn import * from unicorn.x86_const import * BASE = 0x10000 mu = Uc(UC_ARCH_X86, UC_MODE_64) mu.mem_map(BASE, 0x1000) mu.mem_write(BASE, new) # 由于函数里有 ret,需要准备返回地址 STACK = 0x30000 mu.mem_map(STACK, 0x1000) mu.reg_write(UC_X86_REG_RSP, STACK + 0x800) mu.mem_write(STACK + 0x800, (BASE + 0x50).to_bytes(8, "little")) mu.emu_start(BASE, BASE + 0x50) result = mu.reg_read(UC_X86_REG_RAX) print(hex(result)) # 0x7b这一步非常像“单元测试”,只不过测试对象是机器码。在真实项目中,我会把这个流程封装成函数,输入旧字节和期望的新语义,输出验证后的新字节。
7. 实战排坑:我在真实项目里被卡住的几个点
7.1 Capstone 指令对象的只读属性
Capstone 的指令对象你只能读,不能改。很多人刚上手,想直接insn.mnemonic = "nop"来 patch,结果立刻报错。正确思路是“反汇编用来理解,修改交给 Keystone”,两者不混用。另外,disasm是即时生成器,你拿到insn.bytes后想保留原始字节做分析,最好复制一份,不要觉得“反正 bytes 不可变不会有问题”。
还有个容易忽略的点:Cs的detail模式一旦开启,反汇编速度会明显下降。如果你只是快速扫几条指令的助记符,没必要开;但如果你要做指令级的数据流分析,比如看每条指令读了哪些寄存器,那必须开。自己写脚本时最好做个开关。
7.2 Keystone 的默认语法与地址隐式偏移
Keystone 的默认语法和你的直觉可能不一样。x86 下它默认是 Intel 语法,但如果你从网上抄了一段 AT&T 的汇编代码,直接喂进去就会报错。先确认你当前模式是什么,再贴代码,这个排查顺序能帮你省下半小时。
另外,asm返回的count到底是字节数还是指令数,不同版本之间我也见过歧义。为了避免踩坑,我几乎从来不依赖第二个返回值,只用第一个字节数组。如果你真的需要统计指令条数,用 Capstone 反汇编新字节再数一遍更可靠。
相对寻址的“隐式偏移”问题在前面提过,这里再强调一次:ks.asm("jmp 0x1005", 0x1000)里,0x1000 是当前指令的地址,不是“目标地址加上一个偏移”。如果你传成目标地址,编码出来的跳转全都是错的。这个错误非常隐蔽,因为从汇编文本看完全正常,只有在模拟器拿到错误偏移时才会暴露。
7.3 Unicorn 初始化最容易忘的三件事
第一件是栈。模拟带ret的函数时,不映射栈十有八九直接报内存错误。我的做法是固定一个栈基地址,在emu_start之前设置 RSP,并且在栈顶写入一个合法的返回地址。
第二件是停止地址。emu_start的until参数给得太靠后,代码可能会一路执行到未映射区域;给得太靠前,又可能提前停止,连函数后半段都没执行完。我一般会把until设成一个“哨兵地址”,也就是一段已映射但故意不放任何代码的区域,这样ret跳到哨兵后就能自然停止。
第三件是权限。mem_map默认权限是可读可写可执行,日常分析够用。但如果你想模拟某个严格开了 NX 的环境,就要用mem_map(addr, size, UC_PROT_READ | UC_PROT_WRITE)显式设置。我见过有人模拟代码时把数据区也映射成可执行,导致分析结果失真。
7.4 组合使用时的版本与常量匹配
三剑客虽然亲缘关系很近,但它们是三个独立项目,版本节奏不同。有些坑是你代码本身没问题,只是装到的版本太老或太新,常量定义变了。我的习惯是在脚本开头打印版本号:
import capstone, keystone, unicorn print("capstone", capstone.__version__) print("keystone", keystone.__version__) print("unicorn", unicorn.__version__)遇到诡异行为时,先检查这三个版本是不是和你搜到的教程一致。尤其是 Keystone,多年没有大的版本更新,一些老示例里的 API 和现代版本已经有细微差别,比如某些架构常量的命名。保持版本固定,比什么都重要。
最后再分享一个我个人的使用习惯:日常我会把三剑客的基础调用封装成一个小库,Capstone 的解码、Keystone 的编码、Unicorn 的内存初始化都做成带参数的工具函数。这样每次拿到新样本,真正要写的代码只有业务逻辑,而不是反复重复环境搭建。工具的价值就在于此——不是让你研究工具本身,而是让逆向分析这件事变快、变稳。