本文深入探讨了区块链中的抢跑攻击,分析了攻击者如何利用透明交易的特性实现自己的利益。通过实例和示例代码,作者提供了防范抢跑攻击的最佳实践,包括保护'获取或创建'模式、两步交易的安全性检查、避免尘埃攻击和正确使用承诺-揭示方案。通过理解这些攻击方式,开发者可以更好地设计安全的智能合约。
了解抢跑攻击如何利用区块链透明性,以及如何通过安全模式和最佳实践来保护你的智能合约。
在“Solodit 检查清单解读”系列的上一期中,我们探讨了捐赠攻击。我们看到,看似无害的代币转账如何被针对你的智能合约武器化。
今天,我们将转变思路,解决另一个同样隐蔽的漏洞:抢跑攻击。
为了说明,想象一下你在一个繁忙的农贸市场,价格根据需求变化。你看到一个绝妙的交易,稀有松露价格为50美元,而你知道在其他地方价值100美元。当你走上前去购买时,一个能够看到所有即将到来的客户订单的市场内部人士注意到了你的意图。他们迅速在你之前插队,以50美元的价格购买了松露,然后立即以90美元的价格出售给你。你仍然得到了松露,但现在这个中介已经攫取了本该属于你的40美元的价值。
这就是抢跑攻击的简要概括——看见别人的待处理 交易 在一个公开系统中,然后插入你自己的交易,利用你知道的即将到来的价格变动来获利。
在区块链中,当攻击者利用 内存池(待挖掘的交易等着被处理的地方,就像等发货的包裹)透明的特性,来查看即将到来的交易时,就会发生这种情况。他们随后制作自己的交易,并提高Gas费用,确保他们的交易首先被执行。这可能导致严重的后果,从去中心化交易所(DEX)中的价格操纵到被盗的非同质化代币(NFT)。
在这篇文章中,我们将剖析与抢跑攻击相关的四个关键检查项,赋予你编写更安全、更稳健代码的知识和技能。
以下是我们将要讨论的内容:
SOL-AM-FrA-1: '获取或创建'模式: 你的“获取或创建”模式是否防范抢跑攻击?
SOL-AM-FrA-2: 两个交易操作: 两个交易操作是否设计得能够安全地防护抢跑攻击?
SOL-AM-FrA-3: 小额交易: 用户能否通过先发制人造成他人交易的回退?
SOL-AM-FrA-4: 提交-揭示方案: 协议是否使用了正确用户绑定的提交-揭示方案?
为了获得最佳体验,请打开一个标签页,查阅Solodit 检查清单。
在我们深入检查清单之前,让我们首先开始一些基本的定义。
抢跑攻击: 恶意角色观察到内存池中的待处理交易。为了利用该交易中透露的信息,他们快速地在其之前执行自己的交易。通常,他们通过提供更高的Gas价格来确保他们的交易优先处理。
获取或创建模式: 是一种智能合约设计模式,涉及检索现有资源(例如现有的交易对在 DEX 上)或在资源不存在时创建一个新的。如果未妥善保护,该模式可能脆弱于抢跑攻击。
两个交易操作的抢跑攻击: 当智能合约中的一个关键操作分为两个或多个独立的交易时,就出现了这种脆弱性。攻击者可以利用这些交易之间的时间差插入自己的操作,可能破坏预期的流程或盗取资产。
小额交易攻击(通过小额交易回退): 抢跑攻击的一种特定形式,攻击者在合法交易之前,向交易方发送小额的无足轻重的金额(“小额交易”)。这导致合法交易由于状态被修改或未满足的预先条件而失败(回退)。
提交-揭示方案: 是一种密码学协议,旨在确保参与者在提交信息而不立即披露的情况下的公平和保密性。它由两个阶段组成:提交阶段,参与者提交一个加密承诺(通常是他们期望动作的哈希与一个随机秘密或随机数组合),和揭示阶段,参与者在指定的时间后披露原始的动作和秘密。这个过程确保没有参与者可以修改其提交或对其他参与者的动作做出反应,直到所有承诺被最终确定。有些版本的协议确保只有做出承诺的人才能稍后揭示。这防止了其他人的干扰,使参与者负有责任。
让我们检查一个涉及工厂合约(ExploitableContract
)的场景,该合约旨在管理VulnerablePool
实例。预期用法是用户(由_poolCreator
识别)调用getOrCreatePool
,指定一个_initialPrice
。如果为该创建者的池子不存在,合约将创建它。否则,它将返回现有的池子。这种“获取或创建”模式是在智能合约中常见的设计选择。想象一个池子需要为一次交换创建,而有人想要增加流动性。
过程可能如下所示:
而这正是麻烦的开始!当在单个交易中天真地实现时,“获取或创建”模式可能对抢跑攻击脆弱。攻击者可以监控内存池中试图创建特定资源的交易(例如我们与一个_poolCreator
地址相关的VulnerablePool
)。
看到受害者的意图参数后,攻击者可以快速提交自己的交易调用相同的函数(getOrCreatePool
),但却使用不同的恶意参数(例如,操控的_initialPrice
)。通过支付更高的Gas费用,攻击者确保他们的交易优先执行。这在受害者的标识符下创建了资源,但使用了攻击者的参数,劫持了创建步骤。
当受害者的原始交易最终执行时,检查(address(pools[_poolCreator]) == address(0)
)发现资源已经存在。创建步骤被跳过,并且该函数返回由攻击者创建的带有操控参数的资源。毫不知情的受害者随后与这个受损的资源交互。
就像预定一个特定的会议室,结果却被人悄悄提前进入并用坏掉的投影仪替换了。最终你使用了这个房间,但设备却遭到了操控。
以下是代码中的模式:
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract VulnerablePool {
address public poolCreator;
uint256 public initialPrice;
constructor(address _poolCreator, uint256 _initialPrice) {
poolCreator = _poolCreator;
initialPrice = _initialPrice;
}
}
contract ExploitableContract {
mapping(address => VulnerablePool) public pools;
function getOrCreatePool(address _poolCreator, uint256 _initialPrice)
public returns (VulnerablePool)
{
if (address(pools[_poolCreator]) == address(0)) {
// 攻击者可以用不同的 initialPrice 抢跑此交易
pools[_poolCreator] = new VulnerablePool(_poolCreator, _initialPrice);
}
return pools[_poolCreator];
}
function viewPoolInitialPrice(address _poolCreator) public view returns (uint256) {
if(address(pools[_poolCreator]) != address(0)) {
return pools[_poolCreator].initialPrice();
} else {
return 0;
}
}
}
以下是一个演示该脆弱性的测试用例。该测试通过使用初始价格为50的池创建模拟抢跑攻击,而受害者则希望用初始价格为100的池来创建。攻击者成功地率先进行抢跑攻击,受害者最终与攻击者的池交互而不是自己的池。
function testFrontRunning() public {
uint256 intendedPrice = 100;
uint256 attackPrice = 50;
// 1. 受害者打算以 intendedPrice 创建一个池
vm.startPrank(victim);
// exploitableContract.getOrCreatePool(victim, intendedPrice); //模拟受害者即将调用此函数。
// 2. 攻击者监控到此交易并用 attackPrice 抢跑交易
vm.stopPrank();
vm.startPrank(attacker);
exploitableContract.getOrCreatePool(victim, attackPrice);
vm.stopPrank();
// 3. 受害者的交易现在执行
vm.startPrank(victim);
exploitableContract.getOrCreatePool(victim, intendedPrice); // 该调用不应更改价格,因为池已存在
// 4. 断言池的初始价格现在是攻击者的价格,而非受害者的预期价格
assertEq(exploitableContract.viewPoolInitialPrice(victim), attackPrice, "池的初始价格应为攻击者的价格。");
vm.stopPrank();
}
如何避免这种脆弱性?
分开创建和交互: 将资源的创建与对资源的交互分成两笔独立交易。
参数验证: 检查目标资源的参数,如不正确则回退。
这一检查项展示了多步骤过程中的常见脆弱性。
在许多智能合约中,某些操作需要多笔交易才能完成。例如,用户可能首先批准一个合约支出他们的代币,然后调用一个函数来执行交易。如果设计不当,该两步过程可能对抢跑攻击脆弱。
以下示例中的NFTRefinanceMarket
合约被设计为市场或托管机构,NFT所有者可以在此存入他们的NFT,也许作为贷款的抵押品或参与其他去中心化金融(DeFi)活动。预期工作流程利用了标准的ERC-721批准机制,要求NFT所有者进行两笔不同的交易:
批准交易: NFT所有者首先在NFT合约本身中调用approve()
函数,授予NFTRefinanceMarket
合约地址管理特定tokenId
的权限。
再融资交易: NFT所有者随后调用NFTRefinanceMarket
合约中的refinance()
函数,指定相同的tokenId
。该函数旨在:
NFTRefinanceMarket
合约的监管之下。refinance()
的地址(假定为原始所有者)在tokenCreditors
映射中,将其与存入的NFT关联。本质上,它的目的是一个安全的两步过程,以便所有者将他们的NFT锁定在市场合约中,并由该合约跟踪他们的所有权/债权。它的结构是这样的:
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
interface INFT is IERC721 {
function mint(address to, uint256 tokenId) external;
}
contract NFTRefinanceMarket is Ownable {
INFT public nft;
mapping(uint256 => address) public tokenCreditors;
constructor(INFT _nft) Ownable(msg.sender) {
nft = _nft;
}
function refinance(uint256 _tokenId) external {
// 检查该合约是否获得批准以转移NFT
require(nft.getApproved(_tokenId) == address(this), "未获批准");
address originalOwner = nft.ownerOf(_tokenId);
// 将NFT作为抵押品拉入合约
nft.transferFrom(originalOwner, address(this), _tokenId);
// 记录调用者为此NFT的债权人
tokenCreditors[_tokenId] = msg.sender;
}
}
然而,攻击者可以抢跑交易,有效地窃取NFT,如下所示:
function testFrontRunRefinance() public {
// 1. 用户批准合约以再融资其NFT
assertEq(nft.ownerOf(tokenId), user);
vm.startPrank(user);
nft.approve(address(nftRefinanceMarket), tokenId);
assertEq(nft.getApproved(tokenId), address(nftRefinanceMarket));
vm.stopPrank();
// 2. 攻击者监控内存池,在用户调用再融资之前,攻击者先调用再融资
vm.startPrank(attacker);
// 模拟攻击者抢跑攻击该交易
// 在真实场景中,攻击者会提高Gas价格以使其交易优先包含
nftRefinanceMarket.refinance(tokenId);
// 验证攻击者现在被标记为该NFT的债权人
assertEq(nftRefinanceMarket.tokenCreditors(tokenId), attacker);
// 验证NFT现在归属于合约
assertEq(nft.ownerOf(tokenId), address(nftRefinanceMarket));
vm.stopPrank();
// 3. 用户试图再融资NFT,但它已经被攻击者再融资
vm.startPrank(user);
vm.expectRevert("未获批准");
nftRefinanceMarket.refinance(tokenId);
vm.stopPrank();
}
这种类型的抢跑攻击发生是因为存在两个交易之间的时间间隙,攻击者可以在这些调用之间插入。
那么,我们如何避免呢?实施检查以确保第一笔交易属于同一用户,并调用第二个交易。例如,要求refinance
函数的msg.sender
与第一笔交易中批准合约的地址相同。
小额交易攻击涉及攻击者用少量代币抢跑合法交易,可能导致原交易失败。通过改变链上状态,攻击者的交易使受害者交易的假设失效,最终导致失败。这就像是微妙地移动拼图的一块,足以阻止某人完成整个画面。
以下是一个简化的场景:
// 合约中有一个需要零余额的函数
contract Auction {
IERC20 public token;
event AuctionStarted(uint256 id);
constructor(address _token) {
token = IERC20(_token);
}
// 需要零余额才能执行的函数
function startAuction() external returns (uint256) {
// 可以被利用的脆弱检查
require(token.balanceOf(address(this)) == 0, "余额必须为零");
// 拍卖逻辑将在这里展开
uint256 id = 1;
emit AuctionStarted(id);
return id;
}
}
这个Auction
合约的脆弱性在于攻击者可以通过小额交易攻击阻止任何人启动拍卖。
如果Alice想启动拍卖并提交一个调用startAuction()
的交易,攻击者Bob可以通过向合约地址发送一笔微不足道的金额(小额交易)来抢跑她的交易。当Alice的交易被处理时,检查require(token.balanceOf(address(this)) == 0, "余额必须为零")
将失败,因为合约的余额不再为零,导致她的交易回退。
为了防范小额交易攻击,我们可以实施容忍阈值,而不是精确的余额检查,并使用访问控制来限制谁可以触发敏感功能。
提交-揭示方案 旨在保护敏感的链上操作,如拍卖中的出价,免受信息泄漏和在交易确认过程中的抢跑攻击。在一个典型的方案中,用户首先向合约提交一个承诺(他们意图动作和一个秘密盐的密码学哈希)。在指定的提交期结束后,便开始揭示期,用户在此期间提交他们的原始动作和盐。合约验证提交的动作和盐的哈希结果是否与先前存储的承诺一致。这就像是在一个信封中封存你的出价,提交它,只有在所有出价都提交后才打开。
然而,如果提交本身没有唯一地与提交它的用户绑定,这个方案可能会脆弱。一个设计不当的方案可能允许一个观察到用户提交交易的攻击者(例如,在内存池中)复制该承诺或干扰揭示过程。这种特定的脆弱性集中于缺乏用户身份认证的提交哈希,允许攻击者破坏拍卖。这就像有人完美地复制了你封存信封的内容,并在他们的名字下提交了他们的相同副本。
让我们看一个示例:
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
/**
* 概述:
* 检查项ID:SOL-AM-FrA-4
*
* 此测试展示了提交-揭示拍卖合约中的抢跑攻击脆弱性。
* 脆弱性在于协议未在承诺中包含提交者的地址,
* 使得任何人都能够揭示另一用户的承诺并声称奖励。
*/
contract Auction {
mapping(address => bytes32) public commitments;
address public winner;
uint256 public winningBid;
bool public revealed;
uint256 public endTime;
constructor(uint256 _duration) {
endTime = block.timestamp + _duration;
}
// 用户用盐提交出价承诺
function commit(bytes32 commitment) public {
require(block.timestamp < endTime, "拍卖结束");
commitments[msg.sender] = commitment;
}
// 脆弱的揭示函数 - 未在承诺中包含提交者地址
function reveal(uint256 bid, bytes32 salt) public {
require(block.timestamp > endTime, "尚未达到揭示时间");
require(!revealed, "已揭示");
// 脆弱性:承诺不包括提交者的地址
// 这使得任何人都可以使用相同的出价和盐创建相同的承诺
bytes32 expectedCommitment = keccak256(abi.encode(bid, salt));
// 攻击者可以提交相同的值,然后揭示
require(commitments[msg.sender] == expectedCommitment, "无效的承诺");
// 揭示者成为赢家
if (bid > winningBid) {
winningBid = bid;
winner = msg.sender;
}
revealed = true;
}
function claimReward() public view returns (address) {
require(block.timestamp > endTime && revealed, "拍卖未结束或未揭示");
return winner;
}
}
以下是该代码演示的脆弱性分解:
承诺哈希(
keccak256
(abi.encode(bid, salt)))
的计算未包括提交者的地址(msg.sender)。
由于提交者的地址并不是哈希的一部分,所以任何发现正当出价和盐值的诚实竞标者都可以计算出相同的承诺哈希。
攻击者观察到诚实竞标者的交易,调用commit()
并提供相同的哈希。这意味着,诚实竞标者的地址和攻击者的地址在承诺映射中映射到相同的bytes32
值。
reveal()
函数检查commitments[msg.sender] == expectedCommitment
。这意味着它验证reveal (msg.sender)
的调用者之前是否提交了与提供出价和盐匹配的哈希,如下所示:
contract AuctionTest is Test {
Auction public auction;
address public bidder1;
address public bidder2;
uint256 public auctionDuration = 1 days;
function setUp() public {
bidder1 = address(1);
bidder2 = address(2);
auction = new Auction(auctionDuration);
vm.warp(block.timestamp + 1 minutes);
}
function testFrontRunningReveal() public {
uint256 bid1 = 1 ether;
bytes32 salt1 = keccak256("secret1");
// 两个竞标者使用相同的出价和盐创建相同的承诺
// 这是因为承诺未包括提交者的地址
bytes32 commitment = keccak256(abi.encode(bid1, salt1));
// 竞标者1先提交承诺
vm.prank(bidder1);
auction.commit(commitment);
// 攻击者看到出价和盐值(例如,从内存池或其他侧面通道)
// 提交相同的值
vm.prank(bidder2);
auction.commit(commitment);
vm.warp(block.timestamp + auctionDuration);
// 攻击者通过先揭示获得抢跑优势
vm.prank(bidder2);
auction.reveal(bid1, salt1);
// 攻击者通过抢跑攻击成为赢家
assertEq(auction.winner(), bidder2);
assertEq(auction.winningBid(), bid1);
}
}
由于攻击者确实提交了相同的哈希,他们可以成功调用reveal(bid, salt)
。如果他们在诚实竞标者之前这样做(例如,通过支付更高的Gas费用来进行抢跑),就会通过检查commitments[attacker] == expectedCommitment
。攻击者的地址(msg.sender
在该揭示调用内)随后被设定为赢家。
我们如何确保承诺唯一绑定于提交者?
在创建承诺时,要将提交者的地址包括在哈希计算中(通常由用户在链外执行)。承诺应这样计算:commitment = keccak256(abi.encode(msg.sender, bid, salt))
(根据用例,可以添加其他可能的附加字段,例如chainId、nonce等)。
揭示函数必须使用相同的结构重构哈希,将msg.sender
结合在揭示调用中:bytes32 expectedCommitment = keccak256(abi.encode(msg.sender, bid, salt));
。
所有示例可在我的 GitHub 这里 找到。
抢跑攻击,例如可能由于设计缺陷的提交-揭示方案带来的攻击,是透明区块链环境固有的重大威胁。由于待处理的交易通常驻留在一个公共内存池中,在确认之前,恶意参与者可以观察意图并战略性地提交他们自己的交易,通常伴随更高的Gas费用,优先于目标交易以便于获利。这种在最终执行之前观察和反应的能力,是公共区块链特性带来的独特挑战。
因此,在开发或审计任何智能合约系统时,采用对抗性心态是绝对关键的,特别是在时间和信息可用性方面:
总是问: “该协议中的任何功能或交互是否可能被有利可图的抢跑?”
考虑: 哪些潜在的敏感信息(如出价、交易细节、价格数据、治理投票)在行动最终不可变地解决之前被揭示?
分析: 攻击者通过在特定用户操作前或后立即执行交易,可以取得何种具体利益?他们能否利用套利、操控价格、窃取奖励或审查他人?
实施适当的对策,例如在必要时使用安全的提交-揭示模式或最小化对可预见外部事件的依赖。这就是开发人员构建更具弹性和可靠系统的方式。以攻击者的思维方式预见脆弱性,是设计稳健和安全智能合约的基础。
将探索这些安全概念视为你培训的开始,而不是结束,开启你的智能合约安全冒险!
敬请关注“Solodit 检查清单解读”系列的下一期。
- 原文链接: cyfrin.io/blog/solodit-c...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!