区块链与智能合约 CTF

面向 CTF 与自建链上靶场的智能合约安全入门:从 EVM 执行模型与存储槽布局讲起,拆解重入、整数溢出、Delegatecall 与权限缺陷、闪电贷组合攻击的成因,梳理合约反编译与字节码分析方法,并给出 Foundry 本地复现环境搭建与链上题目的解题套路与防御要点。

引言

区块链方向的 CTF 题是近五年增长最快的一类。它和传统的二进制题有一个根本区别:题目不是给你一个可执行文件让你找内存破坏,而是给你一份部署在链上的合约和一组初始条件,让你在有限次交易内把合约里的资金掏空、或者把某个状态变量改成特定值。判题方式也随之改变——不是提交 flag 字符串,而是让链上状态满足某个 isSolved() 断言。

工程上的难点集中在三处。第一是心智模型切换:EVM 是 256 位栈式虚拟机,没有指针、没有堆,所有持久状态存在一棵 32 字节槽位(slot)为单位的 Merkle 树里,理解「一个 mapping 的键值对落在哪个 slot」是读题的第一步。第二是漏洞形态不同:链上几乎不存在缓冲区溢出,真正的杀手是「业务逻辑与状态假设被打破」——重入、权限缺失、预言机操纵、闪电贷组合。第三是经济性与原子性:一笔交易要么全部生效要么全部回滚,这既是攻击者放大杠杆的工具(闪电贷),也是防守方最有效的兜底。

本文按「执行模型 → 存储布局 → 常见漏洞 → 组合攻击 → 反编译 → 本地复现 → 题目类型 → 防御」的顺序组织。第 1、2 节打地基,第 3 到第 6 节讲漏洞与利用原理,第 7、8 节讲工程手段,第 9 节回到出题与防御视角。

所有示例都在本地 Foundry 环境与自建靶场链上完成,代码是教学用的最小复现。真实链上的漏洞研究应走负责任的披露流程,防御侧工程可参考 智能合约安全审计 与 区块链安全总览 。零基础读者建议先读 CTF 竞赛全景与学习路径 。

目录

  1. EVM 执行模型与账户体系
  2. 存储布局、ABI 编码与函数选择器
  3. 重入攻击与状态机顺序
  4. 整数溢出、精度与舍入问题
  5. Delegatecall、代理模式与权限缺陷
  6. 闪电贷与组合攻击
  7. 合约反编译与字节码分析
  8. 本地复现环境:Foundry 与 Hardhat
  9. 链上题目类型与防御要点

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 合约」这一步直接开始写攻击——判题条件才是题目真正的题面。

常见坑清单

  1. 把 private 当加密:private 只限制其他合约直接访问,eth_getStorageAt 与 vm.load 都能读;敏感值不能落链。
  2. 算错 mapping 槽位:键值对地址是 keccak256(abi.encode(key, slot)),不是 keccak256(key) ^ slot;参数顺序与类型编码都不能错。
  3. 重入利用时忘记 receive():攻击合约收 ETH 必须实现 receive() 或 fallback(),否则转账直接失败,递归无从触发。
  4. require 与 assert 混用:assert 消耗全部 gas 且 0.8 前不回滚状态,链上题目里两者语义差异会影响 gas 预算。
  5. 忽略 gas 上限:主网单笔交易 gas 有上限,递归重入次数过多会因 gas 耗尽而失败;本地测试要设同样的 gas_limit。
  6. delegatecall 返回值不检查:delegatecall 失败只返回 false 而不 revert,忘了 require 会让攻击「静默失败」。
  7. 代理初始化被抢跑:initialize 若未在部署交易内原子调用,任何人都能抢先成为 owner。
  8. 签名不绑 chainId:跨链重放、测试网签名在主网复用,都是漏掉 chainId 与 nonce 的后果。
  9. 用 block.timestamp 当随机源:同一区块内可被矿工/验证者影响,blockhash 也只在最近 256 个区块内有效。
  10. 本地测试不设 block.number:分叉测试若不指定区块,vm.createSelectFork 会拉到最新块,导致与题目预期的历史状态不一致。

小结

链上 CTF 的知识结构和二进制题完全不同:它的核心不是「内存模型」而是「状态模型与经济模型」。读题时要问三个问题——合约信任了谁(外部调用、预言机、tx.origin)、状态更新的顺序对不对(CEI)、有没有人能零成本放大杠杆(闪电贷)。这三个问题一旦问清楚,绝大多数题目的攻击面就浮现了。

工程上,Foundry 的 cheatcode 与分叉测试把「不可能在本地复现的链上场景」变成了可重复的实验,这是这个方向近年进步最快的地方。建议的学习路径是先用手写的 unchecked 合约把溢出与重入的原理跑通,再用 Foundry 分叉真实协议做不变量测试,最后读几份公开的审计报告,看真实漏洞与 CTF 题目的差距在哪。

最后是边界意识:CTF 靶场与自建链上的实验是学习,对主网上未授权的合约发起攻击在多数司法辖区属于违法行为,且会被链上取证永久记录。防御侧的系统性阅读可继续看 区块链安全总览 ,把攻击手法翻译成检查项才是这条路真正的价值。

继续阅读

探索更多技术文章

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

全部文章 返回首页

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

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