本文解释了Solodit检查清单中的三明治攻击,这是一种利用公共Mempool操纵价格和交易活动,从而攻击去中心化交易所(DEX)用户的恶意策略。文章详细描述了攻击步骤,强调了缺乏滑点保护是主要漏洞,并提供了带有滑点保护的修复方案,通过让用户指定最小可接受的输出量来有效防止攻击。建议开发者在所有价格敏感的用户交互中实施强大的滑点保护,以构建公平且具有弹性的DeFi应用。
当攻击者使用公共 mempool 来操纵价格和交易活动时,就会发生三明治攻击。 学习识别它们以及滑点保护如何保护用户。
欢迎回到“Solodit 清单解释”系列。
今天,我们将剖析三明治攻击,这是一种有意的交易排序过程,目标是去中心化交易所 (DEX) 上的用户。 这种攻击利用区块链 mempool 的公共性质来操纵受害者交易周围的价格,从而导致用户的财务损失和攻击者的利润。 我们将检查 Solodit 清单项目 SOL-AM-SandwichAttack-1
,以了解漏洞并实施强大的防御措施。
这是“Solodit 清单解释”系列的一部分。 你可以在这里找到之前的文章:
为了获得最佳体验,请打开一个包含 Solodit 清单 的选项卡,并在阅读时参考它。 完整的代码示例可在 GitHub 上找到。
三明治攻击是一种恶意策略,攻击者在受害者待处理交易前后放置两个交易,以从价格变化中获利。 这得益于 mempool 的透明性——mempool 是一个公共等待区,交易在被矿工或验证者确认之前会在此处等待。 三明治攻击的结构包括三个步骤:
受害者的交易被“夹在”攻击者的两个交易之间。 启用此功能的核心漏洞是缺乏滑点保护。 滑点是指交易的预期价格与实际执行价格之间的差异。 在动荡的市场中或大额订单中,价格可能会在提交交易时和确认交易时之间发生变化。 允许用户定义其交易可接受的最大价格变化的机制称为滑点保护。 它确保如果价格超出指定的阈值,交易将恢复而不是以不利的价格执行。 如果没有滑点保护,用户将面临直接的经济损失,并且协议的感知公平性会受到损害。 让我们探讨如何识别和修复此漏洞。
描述:攻击者可以监控 mempool 并在用户交易之前和之后放置两个交易。 例如,当攻击者发现大型交易时,他们首先执行自己的交易以操纵价格。 然后,他们通过在用户交易执行后平仓来获利。
补救措施:允许用户指定最小输出金额,如果未满足,则恢复交易。
清单项目 SOL-AM-SandwichAttack-1
针对三明治攻击最常见的推动因素:不允许用户指定其最小可接受输出的 swap 函数。
考虑这个 SimpleSwap
合约,它实现了一个基本的自动做市商 (AMM)。 它允许用户将 tokenA
交换为 tokenB
,但缺乏滑点控制。
// SPDX-License-Identifier: MITpragma solidity ^0.8.0;
import"@openzeppelin/contracts/token/ERC20/IERC20.sol";
// This is the vulnerable contract// 这是一个有漏洞的合约
constructor(IERC20 _tokenA, IERC20 _tokenB, uint256 _reserveA, uint256 _reserveB) {
tokenA = _tokenA;
tokenB = _tokenB;
reserveA = _reserveA;
reserveB = _reserveB;
}
// VULNERABILITY: No minimum output amount specified.// 漏洞:未指定最小输出量
functionswapAforB(uint256 amountA) externalreturns (uint256 amountB)
{
require(amountA > 0, "Amount must be greater than 0");// 金额必须大于 0
// Constant product formula with 0.3% fee// 具有 0.3% 费用的常数积公式
uint256 amountInWithFee = amountA * 997;
amountB = (amountInWithFee * reserveB) / (reserveA * 1000 + amountInWithFee);
require(tokenA.transferFrom(msg.sender, address(this), amountA), "Transfer failed");// 转账失败
require(tokenB.transfer(msg.sender, amountB), "Transfer failed");// 转账失败
reserveA += amountA;
reserveB -= amountB;
return amountB;
}
// ... other functions (swapBforA, getAmountOut)// ... 其他函数 (swapBforA, getAmountOut)
}
swapAforB
函数根据当前储备计算输出量 (amountB
) 并执行交易。 调用此函数的用户无法控制最终价格。 如果攻击者在他们的交易执行之前操纵储备,用户将收到比预期更少的 token,并且交易仍然会成功。
这个 Foundry 测试演示了一个完整的三明治攻击。
// SPDX-License-Identifier: MITpragma solidity ^0.8.0;
import"forge-std/Test.sol";
import"./SimpleSwap.sol"; // Assume vulnerable contract is in this file// 假设有漏洞的合约在这个文件中
// ... ERC20 and TestToken contracts// ... ERC20 和 TestToken 合约
address attacker = address(1);
address victim = address(2);
functionsetUp(public{
// 1. Deploy contracts and set initial state// 1. 部署合约并设置初始状态
tokenA = new TestToken("TokenA", "TKA");
tokenB = new TestToken("TokenB", "TKB");
// Initial liquidity: 1000 TKA and 1000 TKB// 初始流动性:1000 TKA 和 1000 TKB
swap = new SimpleSwap(tokenA, tokenB, 1000 ether, 1000 ether);
// 2. Fund the pool and participants// 2. 为池子和参与者提供资金
tokenA.mint(address(swap), 1000 ether);
tokenB.mint(address(swap), 1000 ether);
tokenA.mint(attacker, 300 ether);
tokenA.mint(victim, 150 ether);
// 3. Grant approvals for the swap contract// 3. 授予 swap 合约批准
functiontestSandwichAttack(public{
uint256 victimAmountA = 100 ether; // Victim wants to swap 100 TKA// 受害者想要交换 100 TKA
uint256 attackerAmountA = 200 ether; // Attacker uses 200 TKA for the attack// 攻击者使用 200 TKA 进行攻击
// Calculate expected output for the victim in a fair market// 在公平的市场中计算受害者的预期输出
uint256 expectedTokenB = swap.getAmountOut(victimAmountA, 1000 ether, 1000 ether);
uint256 attackerInitialA = tokenA.balanceOf(attacker);
// --- ATTACK BEGINS ---// --- 攻击开始 ---
// 1. FRONT-RUN: Attacker swaps TKA for TKB to drive up the price of TKB.// 1. 抢先交易:攻击者将 TKA 交换为 TKB 以抬高 TKB 的价格。
// 2. VICTIM'S TRADE: Victim's transaction executes at the manipulated price.// 2. 受害者的交易:受害者的交易以受操纵的价格执行。
// 3. BACK-RUN: Attacker sells TKB back for TKA to realize their profit.// 3. 后向交易:攻击者将 TKB 卖回 TKA 以实现他们的利润。
// --- VERIFY RESULTS ---// --- 验证结果 ---
console.log("Expected TKB for victim:", expectedTokenB / 1e18);// 受害者的预期 TKB:
console.log("Victim actually received:", victimTokenB / 1e18);// 受害者实际收到:
console.log("Victim's loss:", (expectedTokenB - victimTokenB) / 1e18, "TKB");// 受害者的损失:
console.log("Attacker's profit:", attackerProfit / 1e18, "TKA");// 攻击者的利润:
// Assert that the victim received fewer tokens and the attacker profited// 断言受害者收到的 token 较少,并且攻击者获利
assertLt(victimTokenB, expectedTokenB, "Victim should receive fewer tokens");// 受害者应该收到更少的 token
assertGt(attackerProfit, 0, "Attacker should profit");// 攻击者应该获利
}
}
测试结果如下:
Ran 1 test for test/SOL-AM-SandwichAttack-1.t.sol:SandwichAttackTest
[PASS] testSandwichAttack() (gas: 144101)
Logs:
Attacker front-run complete// 攻击者抢先交易完成
Expected tokens: 90 Victim received: 63 tokenB// 预期 token:90 受害者收到:63 tokenB
Actual tokens received: 63 Tokens lost due to sandwich: 26// 实际收到的 token:63 由于三明治攻击损失的 token:26
Attacker profit: 30 tokenA// 攻击者的利润:30 tokenA
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in10.82ms (2.95ms CPU time)
测试用例精确地模拟了三步攻击序列:
swapAforB(attackerAmountA)
,将 200 个 tokenA
交换为 tokenB
。 此操作会改变池子的储备,使 tokenB
相对于 tokenA
更加昂贵。
swapAforB(victimAmountA)
。 由于攻击者的抢先交易已经改变了池子的储备,AMA 的常数积公式现在为受害者的 100 个 tokenA 产生更少数量的 tokenB
。 他们收到的 victimTokenB
远小于 expectedTokenB
。
swapBforA
,出售他们在抢先交易中获得的所有 tokenB
。 因此,攻击者实现了利润,并收回了多于他们开始使用的 200 个 tokenA
的 tokenA
。 末尾的断言语句证实了这一点:受害者损失了价值,而攻击者获得了价值。
为了防止这种攻击,我们必须修改 swap 函数以包含滑点保护参数。 用户应该能够指定他们愿意接受的最小输出 token 量。
这是更正后的 SimpleSwap
合约:
// SPDX-License-Identifier: MITpragma solidity ^0.8.0;
import"@openzeppelin/contracts/token/ERC20/IERC20.sol";
// This is the secure contract// 这是一个安全的合约
// ... state variables and constructor are the same ...// ... 状态变量和构造函数相同 ...
constructor(IERC20 _tokenA, IERC20 _tokenB, uint256 _reserveA, uint256 _reserveB) {
tokenA = _tokenA;
tokenB = _tokenB;
reserveA = _reserveA;
reserveB = _reserveB;
}
// REMEDIATION: Accept a `minAmountB` parameter to enforce slippage protection.// 补救措施:接受一个 `minAmountB` 参数来实施滑点保护。
functionswapAforB(uint256 amountA, uint256 minAmountB) externalreturns (uint256 amountB) {
require(amountA > 0, "Amount must be greater than 0");// 金额必须大于 0
uint256 amountInWithFee = amountA * 997;
amountB = (amountInWithFee * reserveB) / (reserveA * 1000 + amountInWithFee);
// The crucial check: revert if the output is less than the user's minimum.// 关键检查:如果输出小于用户的最小值,则恢复。
require(amountB >= minAmountB, "Slippage tolerance exceeded");// 超出滑点容差
require(tokenA.transferFrom(msg.sender, address(this), amountA), "Transfer failed");// 转账失败
require(tokenB.transfer(msg.sender, amountB), "Transfer failed");// 转账失败
reserveA += amountA;
reserveB -= amountB;
return amountB;
}
}
此修补程序很简单,但可以有效地缓解三明治攻击:
swapAforB
函数现在接受一个 minAmountB
参数。
amountB
之后,我们添加一个 require 语句:require(amountB >= minAmountB, "Slippage tolerance exceeded");
。通过此更改,三明治攻击不再有利可图。
如果攻击者试图抢先交易,价格将发生变化,并且为受害者计算的 amountB
将下降。 如果它低于他们指定的 minAmountB
,则受害者的交易将恢复。 当攻击失败时,攻击者已经花费了 gas 在他们的抢先交易上,但没有获得任何收益。
三明治攻击是一种寄生形式的矿工可提取价值 (MEV),它直接损害用户并削弱对 DeFi 协议的信任。 通过利用 mempool 的透明性和缺乏基本的安全检查,攻击者可以持续地从不受保护的交易中提取价值。 作为开发人员,你可以通过在所有价格敏感的用户交互中实施强大的滑点保护来有效地消除这种威胁。 通过利用像 Solodit 清单这样的工具,你可以构建不仅功能强大,而且公平且能够抵御掠夺性攻击的 DeFi 应用程序。 请继续关注下一期“Solodit 清单解释”!
- 原文链接: cyfrin.io/blog/solodit-c...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!