引言
区块链方向的 CTF 题是近五年增长最快的一类。它和传统的二进制题有一个根本区别:题目不是给你一个可执行文件让你找内存破坏,而是给你一份部署在链上的合约和一组初始条件,让你在有限次交易内把合约里的资金掏空、或者把某个状态变量改成特定值。判题方式也随之改变——不是提交 flag 字符串,而是让链上状态满足某个 isSolved() 断言。
工程上的难点集中在三处。第一是心智模型切换:EVM 是 256 位栈式虚拟机,没有指针、没有堆,所有持久状态存在一棵 32 字节槽位(slot)为单位的 Merkle 树里,理解「一个 mapping 的键值对落在哪个 slot」是读题的第一步。第二是漏洞形态不同:链上几乎不存在缓冲区溢出,真正的杀手是「业务逻辑与状态假设被打破」——重入、权限缺失、预言机操纵、闪电贷组合。第三是经济性与原子性:一笔交易要么全部生效要么全部回滚,这既是攻击者放大杠杆的工具(闪电贷),也是防守方最有效的兜底。
本文按「执行模型 → 存储布局 → 常见漏洞 → 组合攻击 → 反编译 → 本地复现 → 题目类型 → 防御」的顺序组织。第 1、2 节打地基,第 3 到第 6 节讲漏洞与利用原理,第 7、8 节讲工程手段,第 9 节回到出题与防御视角。
所有示例都在本地 Foundry 环境与自建靶场链上完成,代码是教学用的最小复现。真实链上的漏洞研究应走负责任的披露流程,防御侧工程可参考 智能合约安全审计 与 区块链安全总览 。零基础读者建议先读 CTF 竞赛全景与学习路径 。
目录
- EVM 执行模型与账户体系
- 存储布局、ABI 编码与函数选择器
- 重入攻击与状态机顺序
- 整数溢出、精度与舍入问题
- Delegatecall、代理模式与权限缺陷
- 闪电贷与组合攻击
- 合约反编译与字节码分析
- 本地复现环境:Foundry 与 Hardhat
- 链上题目类型与防御要点
1. EVM 执行模型与账户体系
EVM 是一台 256 位字长的栈式虚拟机,操作数最大 256 位(uint256),栈深上限 1024。它有三种存储区域,理解它们的生命周期差异是读合约的前提:
| 区域 | 生命周期 | 读写成本 | 说明 |
|---|---|---|---|
| storage | 永久(上链) | 最高(SSTORE 约 20000 gas) | 每个合约一棵 Merkle 树 |
| memory | 单次调用内 | 低(线性扩展) | 按 32 字节字寻址,可扩展 |
| calldata | 单次调用内 | 只读 | 外部传入的 ABI 编码数据 |
账户分两类:EOA(外部账户,由私钥控制,有 nonce 与余额)与合约账户(由代码控制,有 code 与 storageRoot)。合约账户不能主动发起交易,只能被 EOA 或其他合约调用,这个约束决定了所有攻击都必须「由一次外部交易触发」。
调用的四种方式在 CTF 里高频出现,语义差异必须背熟:
// 教学片段:四种调用的语义差异
address(target).transfer(1 ether); // 2300 gas,失败即回滚,已不推荐
address(target).send(1 ether); // 2300 gas,失败返回 false
(bool ok, ) = target.call{value: 1}(data); // 转发全部 gas,最常用
(bool ok2, ) = target.delegatecall(data); // 在自己的存储上下文执行他人代码
call 与 delegatecall 的差别是整个代理漏洞家族的根:call 切换存储上下文到被调用合约,delegatecall 保留调用者的存储、msg.sender 与 msg.value,只借用对方的代码。staticcall 则禁止任何状态修改,是 view 函数的底层实现。
检测与防御:EVM 的调用语义没有「安全默认值」——call 转发全部 gas 本身就是重入的燃料。防御上应显式限制转发 gas(call{gas: 30000})或使用「检查-生效-交互」模式,两者都建立在理解本节模型的基础上。
2. 存储布局、ABI 编码与函数选择器
合约的 storage 是一张 2^256 个槽位的稀疏表,每个槽 32 字节。Solidity 的布局规则是按声明顺序从 slot 0 开始紧凑打包:小于 32 字节的类型会共享一个槽,但 struct 与数组总是开新槽。
mapping 和动态数组是最需要单独记忆的两类:
mapping(K => V) 声明在 slot p 时,键 k 对应的值在:
keccak256(abi.encode(k, p))
动态数组 T[] 声明在 slot p 时:
slot p 存长度(length)
元素 i 位于 keccak256(abi.encode(p)) + i
固定数组 T[N] 声明在 slot p 时:
元素 i 位于 p + i
这个公式是链上题目的「万能钥匙」:一旦题目允许你写某个 mapping,你就能算出任意槽位的地址,进而读取或改写任意状态。bytes 与 string 短于 31 字节时直接内联在声明槽里(低位存数据,最低位是长度乘 2),长于 31 字节则槽里存 长度 * 2 + 1 与数据起始槽号。
ABI 编码决定了「函数怎么被调用」。函数选择器是签名 name(type1,type2) 的 keccak256 前 4 字节,所以链上只有「选择器」没有「函数名」——这也是重载与代理冲突的根源:
# 计算函数选择器(本地教学)
cast sig "transfer(address,uint256)" # 0xa9059cbb
cast keccak "transfer(address,uint256)"
cast sig-event "Transfer(address,address,uint256)"
参数编码有严格的对齐规则:静态类型按 32 字节依次拼接;动态类型(bytes、string、动态数组)在头部留一个「偏移量」,真正的数据放在尾部,长度前缀再跟内容。理解这个「头尾分离」结构,才能在看到一段裸 calldata 时手工还原出调用了什么。
检测与防御:存储槽可预测意味着「不该上链的秘密不能上链」。任何用 private 修饰的状态变量都只是「其他合约不能直接读」,用 eth_getStorageAt 或本地 vm.load 依然可读——私钥、随机种子、答案哈希若直接落链,等于公开。
3. 重入攻击与状态机顺序
重入(reentrancy)是智能合约历史上最著名的漏洞类别,2016 年的 The DAO 事件让它一举成名。它的成因可以用一句话概括:在更新自身状态之前,先把控制权交给了外部地址。
// 教学片段:存在重入漏洞的提款函数(本地靶场复现)
mapping(address => uint256) public balances;
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "no balance");
(bool ok, ) = msg.sender.call{value: amount}(""); // 先转账,控制权交给对方
require(ok, "transfer failed");
balances[msg.sender] = 0; // 后清零,为时已晚
}
攻击合约只需在 receive() 里再次调用 withdraw(),因为此时 balances[msg.sender] 还没被清零,require 依然通过,于是每一次递归都能取出一份余额,直到合约资金耗尽或 gas 用尽。
修复模式有三种,工程上通常叠加使用:
// 模式一:检查-生效-交互(CEI),把状态更新提到外部调用之前
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "no balance");
balances[msg.sender] = 0; // 先生效
(bool ok, ) = msg.sender.call{value: amount}(""); // 后交互
require(ok, "transfer failed");
}
// 模式二:重入锁
bool private locked;
modifier nonReentrant() {
require(!locked, "reentrant");
locked = true;
_;
locked = false;
}
第三种是「拉取式(pull over push)」:合约不主动转账,而是让用户自己来取,从根本上消除了在转账中交出控制权的机会。重入还有两个变体值得注意:只读重入(在 view 函数里读到不一致的中间状态,常配合价格预言机操纵)与跨函数重入(A 函数未加锁但 B 函数改了共享状态)。
检测与防御:工具侧 Slither 的 reentrancy-eth 检测器、Mythril 的符号执行都能自动发现经典重入;工程上则靠 CEI 模式 + nonReentrant 锁 + 定期审计三层。链上题目的判题合约本身就是最好的老师——它常常就是那个「先交互后生效」的受害者。
4. 整数溢出、精度与舍入问题
Solidity 0.8 之前,uint256 的加减乘是环绕的:0 - 1 得到 2^256 - 1,2^255 * 2 得到 0。这类漏洞在早期代币合约里造成过著名的「凭空铸币」事件。
// 教学片段:0.8 之前的溢出(本地靶场复现)
// 攻击者向两个地址各转 2^255,余额之和环绕成 0,检查被绕过
function batchTransfer(address[] memory to, uint256 amount) public {
uint256 total = to.length * amount; // 若溢出为 0,检查失效
require(balances[msg.sender] >= total, "insufficient");
for (uint256 i = 0; i < to.length; i++) {
balances[to[i]] += amount;
}
balances[msg.sender] -= total;
}
0.8 之后编译器默认插入 checked 运算,溢出直接 revert。要复现老漏洞得显式用 unchecked { } 块包住。但溢出消失不代表数值问题消失,剩下的坑更难发现:
| 问题 | 现象 | 典型场景 |
|---|---|---|
| 精度截断 | 先除后乘,小数被丢弃 | 利息、分成计算 |
| 舍入方向 | 应向下却向上取整 | 抵押率、清算阈值 |
| 除法归零 | 整数除法把小额抹成 0 | 手续费、投票权重 |
| 类型转换 | uint256 转 uint8 静默截断 | 打包存储、压缩参数 |
一个具体的舍入陷阱:计算「存入 x 得到多少份额」时,若用 shares = x * totalSupply / totalAssets,当 totalAssets 被攻击者操纵成极大值时,小额存款会被舍入成 0 份额,资产「捐赠」给了合约。这类「精度操纵」是 DeFi 题目的高频考点,防御手段是放大精度(用 1e18 缩放)与明确舍入方向(对协议永远向下取整,对用户向上)。
// 教学片段:用定点数放大避免精度丢失
uint256 constant WAD = 1e18;
function mulWad(uint256 a, uint256 b) internal pure returns (uint256) {
return (a * b) / WAD; // 先乘后除,且用 1e18 缩放
}
检测与防御:Solidity 0.8+ 的默认检查、OpenZeppelin 的 SafeMath(老项目)、以及属性测试(Foundry 的 invariant)共同覆盖这一类。写新代码时不要用 unchecked 去「省 gas」,除非你已证明该处的数学边界。
5. Delegatecall、代理模式与权限缺陷
delegatecall 是升级代理的实现基础,也是 CTF 里最高频的陷阱。核心风险在于:被调用的代码可以改写调用者的任意存储槽。
// 教学片段:危险的 delegatecall(本地靶场复现)
contract Proxy {
address public implementation; // slot 0
address public owner; // slot 1
function setImplementation(address impl) external {
implementation = impl; // slot 0
}
fallback() external payable {
address impl = implementation;
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}
如果 implementation 指向一个「第一个槽位是 owner」的合约,那么调用它的 setOwner() 时,写入的是 Proxy 的 slot 0——也就是 implementation 本身。更糟的情况:若代理与实现合约的存储布局不一致,实现合约的写操作会踩到代理的其他关键槽,例如把 owner 覆盖成攻击者地址。
代理家族的常见形态与对应风险:
| 模式 | 存储布局 | 主要风险 |
|---|---|---|
| 透明代理 | 代理持有 admin,非 admin 调用一律转发 | 函数选择器冲突导致 admin 函数不可达 |
| UUPS | 升级逻辑在实现合约内 | 实现合约若漏掉 _authorizeUpgrade 即被接管 |
| Diamond (EIP-2535) | 多实现 + 选择器路由表 | 路由表可被替换 |
| Beacon | 多个代理共享一个逻辑地址 | 逻辑地址单点 |
权限缺陷比重入更「朴素」但也更致命:initialize() 未加 initializer 修饰符可被重复调用;构造函数里的 owner 赋值在代理模式下不执行(因为逻辑合约的构造函数不通过代理运行);tx.origin 被当作鉴权依据可被钓鱼绕过。
// 教学片段:tx.origin 鉴权的钓鱼陷阱
contract Wallet {
address owner;
function transfer(address to, uint256 amount) external {
require(tx.origin == owner, "not owner"); // 错误:应使用 msg.sender
}
}
// 攻击者诱导 owner 调用恶意合约,恶意合约再调用 Wallet.transfer,
// 此时 tx.origin 仍是 owner,但 msg.sender 是恶意合约
检测与防御:tx.origin 一律禁用于鉴权;代理模式必须用 OpenZeppelin 的 Initializable + UUPSUpgradeable 并保证存储布局「只追加不重排」;initialize 要加 initializer 并在部署脚本里原子完成初始化。审计时优先核对「谁是 admin、升级函数谁能调」。
6. 闪电贷与组合攻击
闪电贷(flash loan)允许在同一笔交易内借出巨额资金、用完即还,无需抵押,代价只是一笔手续费。它本身不是漏洞,而是把「需要巨额资金的攻击」变成了零成本,让原本因资金门槛而不成立的攻击变得可行。
// 教学片段:闪电贷的原子性骨架(本地靶场复现)
interface IFlashLender {
function flashLoan(uint256 amount, bytes calldata data) external;
}
contract Attacker is IERC20Receiver {
function attack(address lender, address pool) external {
IFlashLender(lender).flashLoan(1_000_000e18, "");
}
function onFlashLoan(uint256 amount, bytes calldata) external {
// 1. 用借来的资金操纵某个现货价格 / 抵押品余额
// 2. 在被操纵的价格下,对目标协议发起借贷、清算或套利
// 3. 归还本金 + 手续费,剩余即利润
// 若第 3 步失败,整笔交易回滚,攻击者零损失
}
}
典型的组合攻击链有三类。价格操纵:目标协议用某个 AMM 的现货价(getReserves 直接相除)当预言机,攻击者用闪电贷砸盘压低价格,再以虚高抵押率借出资产。治理攻击:闪电贷借到大量治理代币,在同一个区块内提出并通过恶意提案。清算套利:人为触发清算条件,再以折扣价吃下抵押品。
防守方的三条主线,对应三类攻击:
- 用抗操纵的预言机:Chainlink 的聚合价、TWAP(时间加权平均价,窗口越长越难操纵),绝不直接读现货储备。相关原理见 预言机与价格喂价 。
- 治理加时间锁:提案与执行之间强制
timelock,让「同一区块内借贷-投票-执行」的闪电贷治理攻击失效。 - 限制单区块行为:检查「同一区块内是否有过状态快照」,或对关键操作加冷却期。
检测与防御:链上监控的核心指标是「同一笔交易内的大额借贷 + 关键状态变更 + 归还」这一模式。Foundry 的 invariant 测试可以表达「任意调用序列下,池中资产不低于负债」这类不变量,把组合攻击在本地就暴露出来。这类「用不变量约束协议」的思路与 Foundry 测试与 Mock
中的模糊测试方法直接相关。
7. 合约反编译与字节码分析
很多链上题目不给源码,只给你一个部署地址或一段 runtime bytecode。这时需要从字节码还原逻辑。
第一步是确认「有没有源码」。Etherscan 类浏览器上的 Verify & Publish 会把源码与字节码绑定,多数题目会直接给出源码,但 CTF 里故意不给的情况越来越多。拿到裸字节码后的标准动作:
# 本地教学:从 runtime bytecode 还原结构
# 1. 去掉构造函数部分,只保留 runtime(部署时被 CODECOPY 的那段)
# 2. 反汇编成操作码
cast disassemble 0x6080604052... > disasm.txt
# 3. 用 Panoramix 或 EtherVM 反编译成伪 Solidity
读操作码的几个高价值模式:函数分发器通常是开头一串 PUSH4 <selector> + EQ + PUSH2 <dest> + JUMPI,把所有选择器列出来就等于拿到了「接口清单」:
PUSH1 0x00 ; calldata 偏移 0
CALLDATALOAD ; 载入前 32 字节
PUSH1 0xe0 ; 右移 224 位 -> 取高 4 字节
SHR
DUP1
PUSH4 0xa9059cbb ; transfer(address,uint256)
EQ
PUSH2 0x0100
JUMPI
...
其余常见模式:SSTORE/SLOAD 出现的位置揭示哪些是状态变量;DELEGATECALL 的存在意味着这是代理;SELFDESTRUCT 与 CREATE2 常出现在「自杀式强制转账」或「地址预计算」题目里;assembly 块在源码里会编译成裸操作码,是隐藏逻辑的重灾区。
对比「源码 vs 字节码」还有一个实用技巧:把源码编译一遍,逐字节 diff。如果某个函数在源码里存在但在字节码里消失(或反之),说明出题人做了手脚——这是「源码与部署不一致」类题目的唯一入口。
检测与防御:项目方应开启源码验证并公布构建可复现的配置(solc 版本、优化轮数、EVM 版本),否则「链上的代码不是你以为的代码」。字节码层面的完整性校验是供应链安全在链上的延伸。
8. 本地复现环境:Foundry 与 Hardhat
链上题目不能真的去打主网,必须在本地区块链里复现。Foundry 是当前 CTF 首选,因为它用 Solidity 写测试、速度快、内置 cheatcode。
# 本地教学:Foundry 环境搭建
forge init ctf-solution && cd ctf-solution
forge install foundry-rs/forge-std
# 把题目合约放进 src/,攻击合约与测试放进 test/
forge test -vvvv # 逐条打印调用栈与状态变更
forge test --fork-url $RPC --fork-block-number 18000000 # 分叉主网状态
三个 cheatcode 覆盖了链上题目的绝大多数需求:
// 教学片段:CTF 中最常用的三个 cheatcode
vm.createSelectFork("mainnet", 18_000_000); // 分叉到指定区块的主网状态
vm.deal(attacker, 100 ether); // 给某地址凭空充值
vm.load(addr, bytes32(uint256(0))) // 直接读某个槽位,绕过 private
;
vm.prank(attacker); // 下一条调用以 attacker 身份发出
判题通常由题目提供的 Setup 合约完成:先部署 Setup,再用 EOA 调用 solve(),最后断言 setup.isSolved() == true。写解答时第一件事是读 Setup 的构造函数,它往往已经给了你初始资金、已部署的合约地址、以及判题条件。
Hardhat 的优势在 JavaScript/TypeScript 生态与调试体验,适合题目给出复杂部署脚本的场景。两者可以混用:用 Hardhat 复现部署流程,用 Foundry 写攻击与不变量测试。
检测与防御:能本地复现意味着「漏洞可被任何人在上线前发现」。工程上应把主网分叉测试纳入 CI,用真实的历史状态跑一遍自己的不变量断言,这比任何形式化声明都更能暴露组合攻击。
9. 链上题目类型与防御要点
CTF 链上题按考点可以分成几大类,识别题型就能立刻想到对应的攻击面:
| 题型 | 典型特征 | 首选切入点 |
|---|---|---|
| 重入提款 | 有 withdraw + 外部调用 | CEI 顺序、receive() 递归 |
| 权限绕过 | initialize、onlyOwner 缺失 | 重复初始化、tx.origin |
| 代理升级 | 有 delegatecall 与实现槽 | 存储布局冲突、_authorizeUpgrade |
| 代币经济 | 有 mint/burn/swap 逻辑 | 精度截断、闪电贷操纵 |
| 预言机操纵 | 读 AMM 现货价 | TWAP vs 现货、闪电贷砸盘 |
| 签名与重放 | ecrecover、permit | 缺 nonce、缺 chainId、ecrecover 返回 0 |
| 随机数预测 | 用 block.timestamp 当随机源 | 同区块可预测、prevrandao 语义 |
| 反编译 | 只给字节码 | 分发器还原接口、assembly 隐藏逻辑 |
从防守视角看,这八类题型对应八条可落地的工程要求,把它们合并成一张检查表:
- 所有外部调用前先完成状态更新(CEI),并对跨函数共享状态加锁。
- 鉴权一律用
msg.sender,禁用tx.origin;初始化函数加initializer。 - 代理与实现的存储布局严格对齐,升级函数有权限且加时间锁。
- 金额计算用定点数放大,明确舍入方向,避免先除后乘。
- 价格来源必须是抗操纵预言机(聚合价或长窗口 TWAP)。
- 签名验证绑定
chainId+nonce+ 过期时间,ecrecover返回 0 时必须拒绝。 - 随机性来源禁止使用区块变量,改用承诺-揭示或 VRF。
- 上线前做形式化不变量测试 + 第三方审计,并公开可复现的构建配置。
链上最贵的教训是「不可回滚」:一笔成功的攻击交易就是终局,没有补丁窗口。这与传统软件「先上线再打补丁」的节奏完全不同,也解释了为什么这个领域的工程规范比多数后端系统更保守。关于升级与代理的工程细节,可延伸阅读 合约升级与代理模式 。
权衡取舍
| 场景 | 优先手段 | 理由 | 代价 |
|---|---|---|---|
| 有源码、逻辑简单 | 直接读源码找 CEI 违规 | 最快,无需工具 | 漏掉 assembly 与继承链 |
| 有源码、逻辑复杂 | Slither + 人工审计 | 自动覆盖经典模式 | 误报需人工过滤 |
| 只给字节码 | 反汇编 + 反编译 | 唯一路径 | 伪代码可读性差 |
| 需要复现组合攻击 | Foundry 分叉测试 | 可注入任意状态 | 需要 RPC 与历史区块 |
| 验证「无漏洞」 | 不变量模糊测试 | 覆盖未知调用序列 | 状态空间爆炸,只能证伪 |
| 正式上线前 | 第三方审计 + 形式化 | 成本换确定性 | 昂贵且非绝对保证 |
选型的核心判断是「你手上有什么」。有源码就先读源码并用静态工具扫一遍,只给字节码才上反编译;能分叉就一定要分叉,因为真实的历史状态里藏着本地手工构造不出来的边界条件。切忌跳过「读 Setup 合约」这一步直接开始写攻击——判题条件才是题目真正的题面。
常见坑清单
- 把
private当加密:private只限制其他合约直接访问,eth_getStorageAt与vm.load都能读;敏感值不能落链。 - 算错
mapping槽位:键值对地址是keccak256(abi.encode(key, slot)),不是keccak256(key) ^ slot;参数顺序与类型编码都不能错。 - 重入利用时忘记
receive():攻击合约收 ETH 必须实现receive()或fallback(),否则转账直接失败,递归无从触发。 require与assert混用:assert消耗全部 gas 且 0.8 前不回滚状态,链上题目里两者语义差异会影响 gas 预算。- 忽略 gas 上限:主网单笔交易 gas 有上限,递归重入次数过多会因 gas 耗尽而失败;本地测试要设同样的
gas_limit。 delegatecall返回值不检查:delegatecall失败只返回false而不revert,忘了require会让攻击「静默失败」。- 代理初始化被抢跑:
initialize若未在部署交易内原子调用,任何人都能抢先成为 owner。 - 签名不绑
chainId:跨链重放、测试网签名在主网复用,都是漏掉chainId与nonce的后果。 - 用
block.timestamp当随机源:同一区块内可被矿工/验证者影响,blockhash也只在最近 256 个区块内有效。 - 本地测试不设
block.number:分叉测试若不指定区块,vm.createSelectFork会拉到最新块,导致与题目预期的历史状态不一致。
小结
链上 CTF 的知识结构和二进制题完全不同:它的核心不是「内存模型」而是「状态模型与经济模型」。读题时要问三个问题——合约信任了谁(外部调用、预言机、tx.origin)、状态更新的顺序对不对(CEI)、有没有人能零成本放大杠杆(闪电贷)。这三个问题一旦问清楚,绝大多数题目的攻击面就浮现了。
工程上,Foundry 的 cheatcode 与分叉测试把「不可能在本地复现的链上场景」变成了可重复的实验,这是这个方向近年进步最快的地方。建议的学习路径是先用手写的 unchecked 合约把溢出与重入的原理跑通,再用 Foundry 分叉真实协议做不变量测试,最后读几份公开的审计报告,看真实漏洞与 CTF 题目的差距在哪。
最后是边界意识:CTF 靶场与自建链上的实验是学习,对主网上未授权的合约发起攻击在多数司法辖区属于违法行为,且会被链上取证永久记录。防御侧的系统性阅读可继续看 区块链安全总览 ,把攻击手法翻译成检查项才是这条路真正的价值。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。