多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

EVM 字节码逆向工程实战:从十六进制指令流到还原业务控制流图(CFG)

EVM 字节码逆向工程实战:从十六进制指令流到还原业务控制流图(CFG) 在智能合约安全应急响应Incident Response与链上黑客追踪的第一线安全分析师最常面对的严酷现实是攻击者部署的中间人洗钱路由、闪电贷操纵金库、以及伪造的代币合约99% 以上是绝不会在 Etherscan 上开源其 Solidity 源代码的。摆在审计员面前的只有一串冷酷无情、没有任何注释与变量名的纯十六进制十六进制字节码Runtime Bytecode0x608060405234801561001057600080fd5b50600436106100365760003560e01c8063a9059cbb1461003b57...很多缺乏底层经验的开发者面对这串十六进制数会感到无从下手仿佛在阅读外星密码。然而在资深 EVM 逆向极客眼中每一段字节码都是极其严密、精确对齐的机器指令交响乐。EVM 字节码没有真正意义上的“不可读性”。只要掌握了 EVM 函数分发器Dispatcher的工作模式与基本块Basic Blocks切分法则任何人都可以像剥洋葱一样在几分钟内将其完整还原为逻辑清晰的控制流图Control Flow Graph, CFG与高阶伪代码。本文将带你实战走读一段真实的恶意闭回合字节码彻底掌握 EVM 底层逆向工程的核心心法。一、Runtime Bytecode 的解构地图一个标准的 EVM 运行时字节码在宏观上被组织为以下四大功能区块┌─────────────────────────────────────────────────────────────┐ │ EVM 运行时字节码功能解构 │ │ │ │ [0x00 ~ 0x10] : 自由内存指针初始化 (0x40 写入 0x80) │ │ [0x10 ~ 0x20] : 非 Payable 检查 (CALLVALUE 校验) │ │ [0x20 ~ 0x80] : 函数分发器 (Function Dispatcher 路由树) │ │ [0x80 ~ 0x300] : 业务函数主体代码段 (Basic Blocks 跳转执行) │ │ [末尾数十字节] : CBOR 编码的编译器元数据 (IPFS Hash 指纹) │ └─────────────────────────────────────────────────────────────┘二、第一道工序逆向函数分发器Function Dispatcher所有外部调用进入合约的第一站必须经过函数分发器。它的作用类似于高级语言中的switch-case语句负责从交易 Calldata 的前 4 个字节中提取函数选择器并跳转到对应的代码块。2.1 汇编特征指令流走读让我们来看一段经典的函数分发器反汇编指令[00] PUSH1 0x04 ; 压入数值 4 [02] CALLDATASIZE ; 读取当前交易 Calldata 总字节数 [03] LT ; 判断 calldatasize 4 是否成立 [04] PUSH2 0x0028 ; 压入跳转目标 PC: 0x0028 [07] JUMPI ; 若 Calldata 小于 4 字节跳转到 fallback/receive 函数 [08] PUSH1 0x00 ; 压入 0 [0a] CALLDATALOAD ; 从 Calldata 偏移 0 读取 32 字节数据 [0b] PUSH1 0xe0 ; 压入 224 (0xe0) [0d] SHR ; 逻辑右移 224 位(仅保留高位 32 位 4 字节函数选择器) [0e] DUP1 ; 复制选择器以备比对 [0f] PUSH4 0xa9059cbb ; 压入待比对哈希 0xa9059cbb (即 transfer(address,uint256)) [14] EQ ; 比对当前选择器 0xa9059cbb [15] PUSH2 0x003d ; 压入目标执行块入口地址 [18] JUMPI ; 若匹配成功立即跳转到 0x003d 处执行转账逻辑 [19] DUP1 ; 复制选择器进行下一轮比对 [1a] PUSH4 0x70a08231 ; 压入 0x70a08231 (即 balanceOf(address)) [1f] EQ ; 比对 [20] PUSH2 0x006a ; 压入目标入口 [23] JUMPI ; 匹配成功则跳转通过这一层走读分析师在数秒内即可提取出该未知合约包含的所有对外暴露的函数接口选择器0xa9059cbb- 对应 ERC20transfer(address,uint256)0x70a08231- 对应 ERC20balanceOf(address)只需将未知的 4 字节哈希丢入openchain.xyz或4byte.directory公共特征库检索就能瞬间还原函数签名。三、构建控制流图CFG基本块切分算法有了入口之后如何厘清函数内部复杂的分支判断if-else与循环核心算法是控制流图CFG的基本块划分。3.1 基本块Basic Block的三大绝对法则一个基本块是单进单出的连续指令序列块的第一条指令必须是入口整个程序的起始指令PC 0或者任意JUMPDEST0x5b操作码所在的指令块的最后一条指令必须是出口终止指令无条件跳转JUMP条件跳转JUMPI停机/退出指令RETURN、REVERT、STOP、INVALID、SELFDESTRUCT块的中间绝对不能包含任何跳转或跳转目标。┌─────────────────────────┐ │ Block A (PC 0x003d) │ │ MLOAD ... SLOAD ... LT │ │ 0x0055 JUMPI │ └───────────┬─────────────┘ │ ┌──────────────────┴──────────────────┐ ▼ (条件成立: True) ▼ (条件不成立: False) ┌─────────────────────────┐ ┌─────────────────────────┐ │ Block B (PC 0x0055) │ │ Block C (PC 0x004a) │ │ JUMPDEST │ │ PUSH4 0xf4d78842 │ │ SSTORE ... RETURN │ │ REVERT (回滚报错) │ └─────────────────────────┘ └─────────────────────────┘四、Python 自动化静态 CFG 提取器实战以下是基于 Python 实现的轻量级 EVM 字节码反汇编与基本块切分脚本from typing import List, Dict, Tuple # EVM 核心操作码映射字典 OPCODES { 0x00: STOP, 0x01: ADD, 0x02: MUL, 0x03: SUB, 0x14: EQ, 0x15: ISZERO, 0x35: CALLDATALOAD, 0x54: SLOAD, 0x55: SSTORE, 0x56: JUMP, 0x57: JUMPI, 0x5b: JUMPDEST, 0xf3: RETURN, 0xfd: REVERT } class Instruction: def __init__(self, pc: int, op: int, name: str, arg: bytes b): self.pc pc self.op op self.name name self.arg arg def __repr__(self): arg_hex f 0x{self.arg.hex()} if self.arg else return f[{hex(self.pc)}] {self.name}{arg_hex} def disassemble(bytecode_hex: str) - List[Instruction]: raw bytes.fromhex(bytecode_hex.replace(0x, )) instructions [] pc 0 while pc len(raw): op raw[pc] # 处理 PUSH1 ~ PUSH32 (0x60 ~ 0x7f) if 0x60 op 0x7f: push_len op - 0x60 1 arg raw[pc 1 : pc 1 push_len] instructions.append(Instruction(pc, op, fPUSH{push_len}, arg)) pc 1 push_len else: name OPCODES.get(op, fUNKNOWN_0x{op:02x}) instructions.append(Instruction(pc, op, name)) pc 1 return instructions def split_basic_blocks(instructions: List[Instruction]) - List[List[Instruction]]: blocks [] current_block [] terminators {JUMP, JUMPI, RETURN, REVERT, STOP} for instr in instructions: # 遇到新的 JUMPDEST 且当前块非空必须切分新块 if instr.name JUMPDEST and current_block: blocks.append(current_block) current_block [] current_block.append(instr) # 遇到出口指令终结当前块 if instr.name in terminators: blocks.append(current_block) current_block [] if current_block: blocks.append(current_block) return blocks运行该脚本一段几百字节的生硬十六进制数据会在毫秒级内被切分为十几个相互拓扑连接的基本块每个分支条件的逻辑清晰可见。五、极客逆向反思与防御建议警惕控制流平坦化Opaque Predicates / Control Flow Flattening部分高阶黑客会在汇编中插入恒真条件例如PUSH1 0x01 PUSH1 0x01 EQ引导的无意义虚假分支或者利用动态计算的JUMP跳转目标由运算得出而非硬编码 PUSH故意打碎静态 CFG 拓扑。分析此类代码需要结合符号执行Symbolic Execution如 Mythril动态求解约束定位关键持久化写入SSTORE在分析未开源攻击合约时最快的捷径是直接全局搜索SSTORE0x55操作码。找到每一处修改全局状态的位置反向向上推导其入栈数据的计算来源即可瞬间锁定核心转账与提款逻辑源码保护的伪命题许多项目方试图通过不开源代码来达到“安全隐匿Security through Obscurity”。然而在专业的安全分析师和黑客面前字节码的确定性暴露无遗。真正的安全只能来自于数学约束的严密与代码架构的健全隐匿源码永远救不了千疮百孔的协议。
返回列表