WTF-Solidity 链上安全实战:用 Foundry 从零撰写 DFX Finance 重入攻击 PoC
【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程,供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity
本篇技术指南是 WTF-Solidity 仓库《OnChain Transaction Debugging》链上交易调试系列第五讲的完整展开(英文版由 gbaleeee 撰写、Spark 翻译)。文章以 2022 年 11 月 DFX Finance 的dfx-xidr-v2池被攻击的真实事件为主线,带你从 Etherscan 交易概览、Phalcon 调用轨迹、MetaSleuth 资金流逐层拆解攻击过程,最终使用 Foundry 测试框架手写一份可复现攻击的 PoC 合约。读完本文,你将掌握一条完整的"事件分析 → 漏洞定位 → PoC 复现 → 资金流验证"方法论,并深入理解跨函数重入(Cross-Function Reentrancy)这一高频攻击模式。
前置知识:撰写 PoC 之前需要具备什么
原文档明确提出了两点前置要求:
- 了解常见智能合约漏洞样态,可以通过 DeFiVulnLabs 之类的靶场练习入门;
- 了解 DeFi 基础模型如何运作,以及智能合约与智能合约之间如何交互。
在本仓库中恰好有一套配套的学习路径:本系列前四讲分别讲解了链上分析工具(01_tools)、交易与合约交互热身(02_warmup)、为什么要写 Reproduce PoC 以及价格预言机基础(03_write_your_own_poc)、MEV Bot 攻击的 PoC 撰写(04_write_your_own_poc),可以作为本讲的前置热身材料。
重入攻击:概念与三种类型
重入攻击是区块链世界中最广为流传的一种攻击手法。对攻击模式做一个简单概括:当一个函数对另一个不受信任的合约进行外部调用时,重入攻击就有可能发生。如果被调用的外部合约在回调中再次进入调用方尚未完成状态更新的函数,就可能造成状态错乱、资产被重复支取。
目前重入攻击主要可以分为三种类型:
- 单函数重入(Single Function Reentrancy):在同一个函数内部完成重入。仓库 S01_ReentrancyAttack 中的经典
Bank/Attack例子即是代表——Bank.withdraw()先转账ETH再清零余额,攻击合约在receive()回调里反复调用withdraw(),直到把银行提空,对应源码见 ReentrancyAttack.sol; - 跨函数重入(Cross-Function Reentrancy):从函数 A 切入,重入到同一合约中没有保护的函数 B。DFX Finance 事件正是这一类型,后文详述;
- 跨合约重入(Cross-Contract Reentrancy):重入到另一个合约,甚至利用"只读重入(Read-Only Reentrancy)"攻击依赖受害合约状态做决策的第三方协议。仓库 S17_CrossReentrancy 给出了跨函数、跨合约、跨项目三类进阶案例及其对应的"全局重入锁"防御思路。
值得注意的是一旦函数中存在外部调用,重入的触发点并不局限于ETH转账:ERC721/ERC1155的safeTransfer()/safeTransferFrom()以及ERC777的tokensReceived回调同样会交出执行权,因此重入更多是一个宏观上的设计问题,而不只是转账方式问题。
事件背景:DFX Finance 闪电贷重入事件
事件概览
2022 年 11 月 11 日,安全公司 Peckshield 发布公开告警:DFX Finance 的 DEX 池(名为 Curve,合约代号dfx-xidr-v2)因缺乏合理的重入保护被攻击,损失约 3000 ETH(约合 400 万美元),被盗资金随后被转入 Tornado Cash。
交易概览
在区块链浏览器中查看攻击交易,能获得的信息是有限的:主要包括交易的发起者(sender,即攻击者)、被调用的合约、代币转移过程中发出的事件等。但有两个细节值得注意:
- 这笔交易被打上了MEV Transaction和Flashbots标签,说明攻击者刻意绕开公开 mempool,避免自己的攻击交易被 front-run 机器人抢跑;
- 攻击者的操作并非一笔简单的转账,而是一系列合约调用的组合,需要借助专业工具才能看清全貌。
余额分析(Balance Changes)
使用 BlockSec 的交易分析工具 Phalcon 深入分析后,在Balance Changes一栏可以看到这笔交易所带来的资金变化:
- 标记为 receiver 的攻击合约收获了大量的
USDC、XIDR代币; - 名为
dfx-xidr-v2的合约损失了大量USDC、XIDR代币; - 地址以
0x27e8开头的地址也收获了一些USDC、XIDR代币,经查证该地址是DFX Finance 治理多签钱包。
结合经验可以判断:受害者是 DFX Finance 的dfx-xidr-v2合约,损失资产为USDC与XIDR;多签钱包在攻击过程中收到代币,通常对应合约交互流程中的手续费收取逻辑。
资金流分析(Asset Stream)
进一步使用 BlockSec 的另一款资金流分析工具 MetaSleuth 观察代币转移情况,可以看到完整的资金走向:
- 攻击者(exploiter)先从受害合约中借出大量
USDC、XIDR代币; - 攻击者随后将
USDC、XIDR代币发送回受害合约; - 名为
dfx-xidr-v2的曲线代币从零地址被铸造给攻击者; - DFX Finance 多签钱包收到
USDC、XIDR代币(即手续费); - 攻击者的
dfx-xidr-v2代币被销毁(发送回零地址)。
这五步资金流向,可以在接下来的调用轨迹(Call Trace)分析中得到验证。
调用轨迹分析:还原攻击的函数执行链
将交易在 Phalcon 中展开级别设为 2,可以观察到完整的函数调用流程:
- 攻击者调用攻击合约中函数选择器哈希为
0xb727281f的函数,攻击流程由此开始; - 通过
staticcall调用dfx-xidr-v2合约中的viewDeposit函数; - 通过
call调用dfx-xidr-v2合约中的flash函数。值得注意的是,在这个调用内部完成了一次对攻击合约的回调,对应函数选择器哈希为0xc3924ed6; - 最后通过
call调用dfx-xidr-v2合约中的withdraw函数。
这条调用链清晰地呈现出攻击者的操作顺序:先读取、再闪电贷、最后提现。其中最关键的是第 3 步内嵌的回调——这正是重入发生的位置。
函数级拆解:漏洞是如何被一步步利用的
viewDeposit:为铸造曲线代币做准备
攻击者第一步调用viewDeposit函数的意图,可以从该函数的代码实现与注释中找到:攻击者希望获得铸造 200_000 * 1e18 个dfx-xidr-v2曲线代币所需要的两种底层代币(USDC、XIDR)的数量。
在下一步攻击者调用flash函数的参数中可以看到,攻击者把viewDeposit的返回值作为flash输入参数的近似值(数值不完全一致的原因,在分析手续费机制后就会清楚)。
flash:类似 Uniswap V2 的闪电贷
从flash函数的代码实现可以看出,这是一个与 Uniswap V2 闪电贷非常相似的功能:用户可以通过该函数从合约中借出资产,同时合约会向调用者发起一次回调,对应代码如下:
IFlashCallback(msg.sender).flashCallback(fee0, fee1, data);这处外部调用正好对应此前调用轨迹中对攻击合约的回调。对该回调函数签名做 4 Bytes Hash 验证,结果正是0xc3924ed6。
withdraw:销毁曲线代币、取回配对资产
最后一步调用的withdraw函数,根据代码实现与注释可知,其作用是销毁稳定币(dfx-xidr-v2曲线代币),并取回对应的两种底层代币(USDC、XIDR)。
回调中的 deposit:绕过校验的关键
此时一个关键疑问浮现:攻击者明明只是做了一笔闪电贷,凭什么能调用withdraw把合约资产取走?答案就藏在闪电贷的执行过程中——攻击者唯一能自由操作的地方,就是回调函数。
展开回调内部可以看到,攻击者在回调中调用了受害合约的deposit函数。deposit的职责是:接收池子支持的两种资产(USDC、XIDR),并铸造对应的曲线代币。结合transferFrom调用可知,攻击者正是把USDC、XIDR代币发送给了受害合约。
而flash函数对闪电贷是否完成的判定,是通过检查回调执行前后合约中对应代币的余额关系来实现的,判定代码为:
require(balance0Before.add(fee0) <= balance0After, 'Curve/insufficient-token0-returned'); require(balance1Before.add(fee1) <= balance1After, 'Curve/insufficient-token1-returned');deposit执行流程中向合约转入USDC、XIDR的操作,恰好满足了这一余额校验。这里有一个细节:为了满足flash函数对手续费的要求,攻击者存入的USDC、XIDR数量略高于从flash中借出的数量——多出的部分会在flash函数后续执行中作为手续费发送给 DFX Finance 多签钱包。也就是说:
- 攻击者在发起攻击前预先准备了一些
USDC、XIDR作为手续费资金; - 通过
deposit发送给受害合约的总数量 =闪电贷借出的数量 + 手续费; - 这样既完成了
deposit,又通过了flash函数中的余额校验。
于是,攻击者通过在闪电贷回调中执行deposit,达成了两个效果:其一,满足了闪电贷的还款校验;其二,在受害合约中留下了存款记录(相当于用"借来的钱"完成了存款),使后续withdraw有了合法依据。提现时受害合约按存款记录全额给付,最终池子净损失了借出的本金。
手把手撰写 PoC
PoC 骨架
基于以上分析,可以先把 PoC 的主要框架写出来:
contract EXP { uint256 amount; function testExploit() public{ uint[] memory XIDR_USDC = new uint[](2); XIDR_USDC[0] = 0; XIDR_USDC[1] = 0; ( , XIDR_USDC) = dfx.viewDeposit(200_000 * 1e18); dfx.flash(address(this), XIDR_USDC[0] * 995 / 1000, XIDR_USDC[1] * 995 / 1000, new bytes(1)); // 5% fee dfx.withdraw(amount, block.timestamp + 60); } function flashCallback(uint256 fee0, uint256 fee1, bytes calldata data) external{ /* xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx */ } }其中995 / 1000意味着借出数量约为viewDeposit返回值的 99.5%,差额部分即为覆盖闪电贷手续费的资金(该部分最终流向 DFX 多签钱包)。在回调函数内部完成重入,即完成整个攻击闭环。
完整 PoC
把回调函数补全后,得到完整的 PoC:
contract EXP { uint256 amount; function testExploit() public{ uint[] memory XIDR_USDC = new uint[](2); XIDR_USDC[0] = 0; XIDR_USDC[1] = 0; ( , XIDR_USDC) = dfx.viewDeposit(200_000 * 1e18); dfx.flash(address(this), XIDR_USDC[0] * 995 / 1000, XIDR_USDC[1] * 995 / 1000, new bytes(1)); // 5% fee dfx.withdraw(amount, block.timestamp + 60); } function flashCallback(uint256 fee0, uint256 fee1, bytes calldata data) external{ (amount, ) = dfx.deposit(200_000 * 1e18, block.timestamp + 60); } }更完整、可直接运行的代码库可参考 DeFiHackLabs 仓库中的DFX_exp.sol测试用例。
攻击流程总结
- 提前准备一些
USDC、XIDR代币; - 调用
viewDeposit(),获取后续deposit()操作所需的代币数量; - 根据第 2 步的返回值,调用受害合约的
flash()借出USDC、XIDR; - 在
flash()的回调中调用受害合约的deposit(),将USDC、XIDR发送回受害合约,完成重入; - 由于第 4 步留下了存款记录,直接调用受害合约的
withdraw()取走代币。
在本仓库中运行与验证
本仓库根目录的 foundry.toml 已配置好统一的 Foundry 环境:默认 profile 使用solc = "0.8.34",src = "src"、test = "test",并通过 remappings 将forge-std与@openzeppelin/contracts指向lib/下的依赖。你可以基于这套配置运行forge test执行测试;同时,如同本系列 04 讲所介绍的,也可以使用cast run <txid> --quick --rpc-url <rpc>配合 RPC 节点重放链上交易、打印函数调用轨迹,作为对 PoC 行为的对照验证。
用链上事件验证资金流
写完成 PoC 后,还可以回到攻击交易本身,用代币事件对之前的资金流图做交叉验证:
deposit函数执行过程中最后发出的事件,验证了dfx-xidr-v2曲线代币从零地址铸造给攻击者;flash函数执行过程中的USDC、XIDR转移事件(闪电贷手续费收取),对应 DFX Finance 多签钱包收到少量USDC、XIDR代币;withdraw函数执行过程中最后发出的事件,对应dfx-xidr-v2曲线代币发送到零地址销毁。
三条事件链与 MetaSleuth 资金流分析完全吻合,证明了前述对攻击机制的理解是正确的。
总结:典型的跨函数重入
DFX Finance 重入攻击是一起典型的跨函数重入(Cross-Function Reentrancy)事件:攻击者从flash函数切入,在闪电贷回调中调用了同一合约内没有重入保护的deposit函数完成重入,留下存款记录后顺利提现。它的本质与仓库 S17 跨函数重入案例一致——漏洞根源是状态变量在外部调用完成前未先行更新,同时相关函数缺乏统一的防护。
值得强调的是,这次攻击的手法恰好对应 CTF 靶场 damnvulnerabledefi 的第四题Side Entrance。如果项目开发者此前认真研究过这道题,或许这次攻击就不会发生。而在同年 12 月,Defrost 项目也因类似问题被攻击。
作为防御启示,可以参考仓库 S01_ReentrancyAttack 中的两条基本防线:
- 检查-影响-交互模式(CEI):先更新状态变量,再与外部合约交互,从根本上消除重入窗口;
- 重入锁(nonReentrant):用状态变量标记函数执行状态,重入调用直接 revert。
同时结合 S17 进阶案例 的教训:简单的工具永远不会是完美防御——跨函数重入要求所有改变状态的函数统一加锁(必要时使用全局重入锁),跨合约/只读重入则要求把状态更新的顺序与可见性纳入整体设计,在构造过程中的每一步都布置多道不同的防御机制。
延伸学习:仓库内的配套资源
- 链上分析工具选型与使用:Phalcon、Ethtx、Tenderly、4byte 签名库等,见 01_tools;
- Foundry 环境搭建与基础交易分析:见 02_warmup;
- 撰写 Reproduce PoC 的意义与价格预言机基础:见 03_write_your_own_poc;
- 未开源合约(MEV Bot)的 PoC 撰写与
cast run用法:见 04_write_your_own_poc; - 重入攻击的经典概念与 CEI / 重入锁防御代码:S01_ReentrancyAttack、ReentrancyAttack.sol;
- 跨函数 / 跨合约 / 跨项目重入的进阶案例与全局重入锁:S17_CrossReentrancy。
此外,原文档还推荐了若干外部学习材料,包括 Consensys 智能合约最佳实践中关于 Reentrancy 的章节、Reentrancy Attacks on Smart Contracts Distilled、C.R.E.A.M. Finance AMP 漏洞事后复盘、Cross-Contract Reentrancy Attack 分析、Sherlock Yield Strategy 漏洞赏金事后复盘,以及 QuillAudits 关于只读重入漏洞的解读,可作为深入学习重入攻击的延伸阅读清单。
【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程,供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考