Solodit检查清单详解:矿工攻击

  • cyfrin
  • 发布于 5天前
  • 阅读 35

本文解释了矿工攻击,即验证者如何利用智能合约中的 block.timestamp、随机性和交易排序。文章详细讨论了三种攻击类型:使用 block.timestamp 进行时间敏感操作的风险、使用区块属性生成随机数、以及交易排序敏感性。针对每种攻击,文章都提供了示例代码、漏洞解释、缓解措施以及测试用例,旨在帮助开发者保护智能合约免受这些微妙但潜在破坏性的攻击。

Solodit 清单解释 (6): 矿工攻击

了解验证者如何在智能合约中利用 block.timestamp、随机性和交易排序。保护你的代码免受这些微妙的区块链攻击。

今天,我们将深入探讨 Miner Attacks。验证者(在工作量证明(PoW)系统中以前称为矿工)是 transaction 处理器,他们对 blockchain 机制拥有相当大的控制权。了解他们在共识协议中的固有权限对于实施安全的 smart contracts 至关重要。

我们将探讨验证者可以影响合约执行的场景。不是通过彻底的黑客攻击,而是通过利用他们在区块链 consensus mechanism 中的位置。

具体来说,我们将解决以下清单项目:

  • SOL-AM-MA-1: block.timestamp 是否用于时间敏感的操作?

  • SOL-AM-MA-2: 合约是否使用区块属性(如时间戳或难度)来生成随机数?

  • SOL-AM-MA-3: 合约逻辑是否对交易排序敏感?

让我们深入研究并揭示如何保护你的智能合约免受这些微妙但可能具有破坏性的攻击!

为了获得最佳体验,请打开一个包含 Solodit checklist 的选项卡以供参考。注意:我们之前已经介绍过包括拒绝服务(part 1, part 2)、donation attacksfront-running attacksgriefing attacks 在内的主题。请务必查看它们。

矿工在区块链系统中的影响

矿工在 PoW 区块链网络中至关重要,他们通过竞争性的 解决密码难题 过程来验证交易并保护网络。这个称为 mining 的过程需要大量的计算资源和能量来生成满足网络难度目标的有效 PoW 解决方案。

以太坊从 PoW 到权益证明(PoS)的演变已将“矿工”转变为“验证者”。 然而,由于其历史普遍性和技术连续性,区块链安全社区继续使用诸如 “miner extractable value”“miner manipulation” 之类的术语。但是,这些攻击现在由在合并后的以太坊生态系统中控制交易排序的验证者执行。

矿工拥有多项协议授予的能力,这些能力直接影响区块链的运行。他们根据费用激励(以太坊中的 gas 价格)确定区块内的交易包含和排序,从而使他们能够控制 mempool 处理队列。这种排序能力可以通过战略性交易定位来捕获 maximal extractable value (MEV) 的利润,而不会违反共识规则。矿工可以在网络定义的容差范围内操纵区块时间戳,在以太坊中通常为 ±15 秒,在比特币中最多为 2 小时,这可能会影响时间相关的合约逻辑,例如解锁期或利息计算。

此外,矿工还会影响区块属性,包括 gas 限制,并通过其挖矿活动确定 blockhash 值。这些属性成为 immutable 区块链 state 的一部分,并且会影响依赖它们来实现功能的合约。

我们主要关注无需控制大多数 hashrate 即可执行的矿工操作。具体来说,那些与区块时间戳、用于生成随机数的区块属性以及交易排序漏洞相关的操作。这些利用可以由网络参与度适中的矿工执行,并且代表针对已部署智能合约的实际攻击向量。

虽然存在其他有趣的攻击向量,包括:

  • 51% attacks:控制大部分哈希率以实现双重支出。

  • Selfish mining: 战略性地扣留区块以增加相对奖励。

  • Timejacking: 操纵网络时间感知。

  • Eclipse attacks: 孤立节点与诚实节点

但是,这些通常需要大量的计算资源超出标准挖矿操作的复杂网络控制机制。此类攻击 针对共识层漏洞,而不是应用层合约漏洞,因此不属于我们当前智能合约安全考虑的范围。

SOL-AM-MA-1: block.timestamp 是否用于时间敏感的操作?

  • 描述:可能影响时间相关的合约逻辑,矿工可能会将 block.timestamp 操纵几秒钟。

  • 补救措施:对于关键的时间操作,请使用 block.number 而不是时间戳,或确保操纵容差是可以接受的。

block.timestamp 表示矿工提议的区块时间。虽然通常准确,但矿工有一定的调整余地。这种余地允许时间敏感逻辑中存在潜在漏洞,例如拍卖或质押期。

此漏洞是矿工操纵的直接结果,反映了矿工影响区块创建的能力。虽然矿工无法设置任意时间戳,但他们在共识规则内确实拥有一定的控制权。在以太坊上,这大约是 +/- 900 秒(15 分钟),但确切范围可能因区块链而异。

想象一下,在拍卖中,矿工会移动时间戳以支持他们的出价或同伙的出价。这可能会导致不公平的优势,例如过早结束拍卖或操纵竞标过程。

让我们看一个拍卖示例:

pragma solidity ^0.8.0;

// SPDX-License-Identifier: UNLICENSED
contract Auction {
    uint public auctionEndTime;
    address public highestBidder;
    uint public highestBid;
    mapping(address => uint) public pendingReturns;
    bool public ended;

    event BidPlaced(address bidder, uint amount);
    event AuctionEnded(address winner, uint amount);

    constructor(uint _duration) {
        auctionEndTime = block.timestamp + _duration; // Vulnerability!
    }

    function isAuctionEnded() public view returns (bool) {
        return block.timestamp >= auctionEndTime; // Vulnerable comparison!
    }

    function bid() public payable {
        require(!isAuctionEnded(), "Auction has ended");
        require(msg.value > highestBid, "Bid not high enough");

        if (highestBid > 0) {
            pendingReturns[highestBidder] += highestBid;
        }

        highestBidder = msg.sender;
        highestBid = msg.value;
        emit BidPlaced(msg.sender, msg.value);
    }

    function endAuction() public {
        require(!ended, "Auction already ended");
        require(isAuctionEnded(), "Auction not yet ended");

        ended = true;
        emit AuctionEnded(highestBidder, highestBid);
    }

    function withdraw() public returns (bool) {
        uint amount = pendingReturns[msg.sender];
        if (amount > 0) {
            pendingReturns[msg.sender] = 0;

            // Use call instead of transfer for better compatibility
            (bool success, ) = payable(msg.sender).call{value: amount}("");
            require(success, "Transfer failed");
        }
        return true;
    }
}

在此 Auction 合约中,auctionEndTimeblock.timestamp + _duration 确定。矿工可以巧妙地调整 block.timestamp 以提前结束或延迟拍卖。他们可能会延迟足够的时间以允许他们的出价包含在区块中,或者提前它以排除竞争出价。

这是一个使用 Foundry 暴露问题的测试:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

import "forge-std/Test.sol";

/**
 * Overview:
 * Checklist Item ID: SOL-AM-MA-1
 *
 * This test demonstrates the vulnerability of using block.timestamp for time-sensitive operations in an auction.
 *
 * VULNERABILITY EXPLANATION:
 * 1. Miners can manipulate block.timestamp by several seconds (typically up to 900 seconds/15 minutes)
 * 2. In high-value auctions, a miner could slightly advance the timestamp to prematurely end an auction
 * 3. Critical financial decisions (where a second matters) should not rely on block.timestamp precision
 */
contract AuctionTest is Test {
    Auction public auction;
    uint256 initialDuration = 1 days;
    address bidder1 = address(0x1);
    address bidder2 = address(0x2);
    address minerBidder = address(0x3);

    function setUp() public {
        auction = new Auction(initialDuration);
        vm.deal(bidder1, 10 ether);
        vm.deal(bidder2, 10 ether);
        vm.deal(minerBidder, 10 ether);
    }

    function testRealisticTimestampManipulation() public {
        // Bidder1 places initial bid
        vm.prank(bidder1);
        auction.bid{value: 1 ether}();
        assertEq(auction.highestBidder(), bidder1);

        // Fast forward to near the end of auction (just 30 seconds remaining)
        vm.warp(block.timestamp + initialDuration - 30);

        // MinerBidder places bid
        vm.prank(minerBidder);
        auction.bid{value: 1.5 ether}();
        assertEq(auction.highestBidder(), minerBidder);

        // Bidder2 attempts to place a last-second bid,
        // but miner manipulates timestamp by just 31 seconds
        // NOTE: This is a realistic manipulation that could occur in practice
        vm.warp(block.timestamp + 31); // Just enough to end the auction

        // Bidder2's transaction fails because the miner-manipulated
        // timestamp has passed the auction end time
        vm.prank(bidder2);
        vm.expectRevert("Auction has ended");
        auction.bid{value: 2 ether}();

        // MinerBidder ends auction and wins despite Bidder2's higher bid
        vm.prank(minerBidder);
        auction.endAuction();

        // Verify miner won unfairly by manipulating timestamp
        assertEq(auction.highestBidder(), minerBidder);
        assertEq(auction.highestBid(), 1.5 ether);
    }
}

此示例高亮显示了矿工如何在共识规则内调整 block.timestamp,这可能会影响拍卖的结果。在提供的测试中,矿工将时间戳提前 31 秒以提前结束拍卖,阻止接受更高的出价并将拍卖不公平地授予他们自己

缓解措施

不要直接依赖 block.timestamp,而是考虑使用 block.number

pragma solidity ^0.8.0;

contract FixedAuction {
    uint256 public auctionEndBlock;
    uint256 public blockDuration;

    constructor(uint256 _blockDuration) {
        auctionEndBlock = block.number + _blockDuration;
        blockDuration = _blockDuration; // Duration of auction in blocks
    }

    function finalizeAuction() public {
        require(block.number >= auctionEndBlock, "Auction is not yet over.");
        // Distribute funds
    }
}

在此 FixedAuction 示例中,我们使用 block.number 而不是 block.timestamp 来确定拍卖的结束时间。这使得拍卖的时间可预测并且可以抵抗矿工对 block.timestamp 的操纵

但是,矿工可以选择根本不挖矿区块,这会延迟拍卖结束,但这是一种不太直接的操纵形式。此外,使用 block.number 会使拍卖依赖于区块创建速度。如果区块时间不一致,则拍卖持续时间在实际时间中可能会略有不同。

如果需要更精细和一致的时间,请考虑使用 Chainlink oracle,但请注意复杂性和 gas 成本的增加。

通常,建议避免设计对秒级别的时间敏感的功能,因为矿工可以在容差范围内操纵区块时间戳(在以太坊中通常为 ±15 秒)。智能合约应该实现具有足够缓冲期的时间方案,以防止通过微小的时间戳调整进行经济利用。

SOL-AM-MA-2: 合约是否使用区块属性(如时间戳或难度)来生成随机数?

  • 描述:区块属性(时间戳,难度)和其他可预测的值不应用作随机数,因为它们可以被矿工影响或预测。

  • 补救措施:改用安全随机源,例如 Chainlink VRF、commit-reveal 方案或可证明的公平随机化机制。

区块链上的真正随机性很难block.timestampblock.difficulty 乍一看似乎是随机的,但矿工可以影响它们,从而使结果可预测。

矿工可以在共识规则内操纵 block.timestamp,虽然他们对 block.difficulty 的短期控制非常有限,但通常来说,依靠这些来获得安全关键逻辑是个坏主意。

想象一下,矿工提前知道中奖号码的彩票。这绝对不是一个公平的游戏。同样,这属于矿工操纵,矿工利用他们的影响力来获得优势。

让我们看一个有缺陷的彩票合约:

pragma solidity ^0.8.0;

// SPDX-License-Identifier: UNLICENSED

contract Lottery {
    address public winner;

    function pickWinner() public {
        // Vulnerable randomness generation using block.timestamp
        uint256 randomNumber = uint256(block.timestamp) % 100;
        if (randomNumber == 7) {
            winner = msg.sender;
        } else {
            winner = address(0);
        }
    }

    function getBlockTimestamp() public view returns (uint256) {
        return block.timestamp;
    }
}

在这里,pickWinner 使用 block.timestamp 来生成一个“随机”数。恶意矿工可以调整包含 pickWinner 交易的区块的时间戳,从而影响结果并操纵彩票。

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

import "forge-std/Test.sol";

/**
 * Overview:
 * Checklist Item ID: SOL-AM-MA-2
 *
 * This test demonstrates how using `block.timestamp` for randomness in a lottery contract can be exploited by miners.
 * A miner can manipulate the `block.timestamp` to influence the outcome of the randomNumber and potentially win the lottery.
 * The test attempts to call the pickWinner function repeatedly in the same block to find desired 'randomNumber' by manipulating block timestamp
 */
contract LotteryTest is Test {
    Lottery public lottery;
    address public attacker = address(0x123);

    function setUp() public {
        lottery = new Lottery();
        vm.deal(attacker, 1 ether);
    }

    function testPredictableRandomness() public {
        vm.startPrank(attacker);

        uint256 initialTimestamp = block.timestamp;
        bool winnerFound = false;

        // Try timestamps close to current to find a winning timestamp.
        for (uint256 i = 0; i < 10; i++) {
            // Slightly modify the timestamp
            uint256 manipulatedTimestamp = initialTimestamp + i;

            // Manually set the block timestamp for the next call.
            vm.warp(manipulatedTimestamp);

            lottery.pickWinner();
            if (lottery.winner() == attacker) {
                winnerFound = true;
                break;
            }
        }

        assertTrue(winnerFound, "Attacker should be able to manipulate timestamp to win");
        vm.stopPrank();
    }
}

此测试表明,矿工可以通过在一个小范围内更改区块时间戳来控制结果,从而获得他们想要的结果。在该测试中,重复调用 pickWinner 函数,并略作修改的时间戳,直到攻击者的 address 被选为获胜者。这使得彩票对参与者来说非常不公平。缓解措施

不要依赖容易操纵的区块属性,而是使用安全随机源,例如 Chainlink VRF,它提供可验证且不可预测的随机数,从而确保公平性并防止操纵。区块属性(例如 block.prevrandao)也比 block.timestamp 更好,但不应依赖它们,因为它们有被弃用的可能性。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {VRFConsumerBaseV2Plus} from "@chainlink/contracts/src/v0.8/vrf/dev/VRFConsumerBaseV2Plus.sol";
import {VRFV2PlusClient} from "@chainlink/contracts/src/v0.8/vrf/dev/libraries/VRFV2PlusClient.sol";

/**
 * @title SecureLottery
 * @notice A lottery contract using Chainlink VRF V2.5 for verifiable randomness
 * @dev This is an example contract and should not be used in production without proper auditing
 */
contract SecureLottery is VRFConsumerBaseV2Plus {
    // Chainlink VRF configuration
    uint256 public s_subscriptionId;
    bytes32 public keyHash = 0x787d74caea10b2b357790d5b5247c2f63d1d91572a9846f780606e4d953677ae; // Sepolia gas lane
    uint32 public callbackGasLimit = 100000;
    uint16 public requestConfirmations = 3;
    uint32 public numWords = 1;

    // Lottery state variables
    uint256 public randomNumber;
    address public winner;
    mapping(uint256 => bool) public requestIds;
    uint256 public lastRequestId;

    // Events
    event RandomnessRequested(uint256 requestId);
    event WinnerSelected(address winner, uint256 randomNumber);

    /**
     * @param subscriptionId Chainlink VRF subscription ID
     * @param vrfCoordinator Address of the VRF Coordinator contract
     */
    constructor(
        uint256 subscriptionId,
        address vrfCoordinator
    ) VRFConsumerBaseV2Plus(vrfCoordinator) {
        s_subscriptionId = subscriptionId;
    }

    /**
     * @notice Request random number from Chainlink VRF
     * @param useNativePayment Whether to pay in native tokens (true) or LINK (false)
     * @return requestId The ID of the randomness request
     */
    function requestRandomWinner(bool useNativePayment) external returns (uint256 requestId) {
        // Request randomness from Chainlink VRF
        requestId = s_vrfCoordinator.requestRandomWords(
            VRFV2PlusClient.RandomWordsRequest({
                keyHash: keyHash,
                subId: s_subscriptionId,
                requestConfirmations: requestConfirmations,
                callbackGasLimit: callbackGasLimit,
                numWords: numWords,
                extraArgs: VRFV2PlusClient._argsToBytes(
                    VRFV2PlusClient.ExtraArgsV1({
                        nativePayment: useNativePayment
                    })
                )
            })
        );

        requestIds[requestId] = true;
        lastRequestId = requestId;
        emit RandomnessRequested(requestId);
        return requestId;
    }

    /**
     * @notice Callback function called by VRF Coordinator when randomness is fulfilled
     * @param requestId The ID of the randomness request
     * @param randomWords The random words generated by Chainlink VRF
     */
    function fulfillRandomWords(
        uint256 requestId,
        uint256[] calldata randomWords
    ) internal override {
        require(requestIds[requestId], "Request not found");
        require(randomWords.length > 0, "Random words array is empty");

        // Process the random value
        randomNumber = randomWords[0] % 100; // Example: limit to 0-99 range

        // Lottery winner selection logic would go here
        // For example, if you have participants in an array:
        // winner = participants[randomNumber % participants.length];

        emit WinnerSelected(winner, randomNumber);
    }

    /**
     * @notice Get the status of a VRF request
     * @param requestId The ID of the randomness request
     * @return exists Whether the request exists
     */
    function getRequestStatus(uint256 requestId) external view returns (bool exists) {
        return requestIds[requestId];
    }
}

SecureLottery 合约使用 Chainlink VRF 生成一个真正的随机数。requestRandomWords 函数从 Chainlink VRF 服务请求一个随机数。fulfillRandomWords 函数(从 VRFConsumerBaseV2Plus 继承时必须存在)接收随机数并使用它来确定获胜者。这确保了公平且不可预测的彩票结果。

使用 Chainlink VRF 会引入外部依赖项和 gas 成本。它还需要设置 Chainlink VRF 订阅并管理相关费用。但是,这些成本通常是提高安全性和公平性的值得的权衡。

其他替代方法包括使用 commit-reveal scheme。虽然 commit-reveal 方案更复杂,但它们可能更具成本效益,但它们需要仔细的设计才能确保适当的安全性。

SOL-AM-MA-3: 合约逻辑是否对交易排序敏感?

  • 描述:矿工控制交易排序,并可以利用它进行抢先交易、尾随交易或三明治攻击。

  • 补救措施:通过允许用户指定可接受的结果来实施保护,当违反这些结果时 revert 交易。

矿工决定将交易包含在区块中的顺序。虽然矿工通常优先考虑 gas 价格较高的交易,但他们没有义务这样做。这允许恶意矿工(或复杂的机器人)战略性地排序交易以获取利益。这种利用可能导致抢先交易、尾随交易或三明治攻击。这些攻击利用对交易顺序的操纵来提取价值。

考虑一个去中心化交易所(DEX)。矿工在 mempool 中看到特定代币的大量买入订单。他们可以在大额订单之前插入他们自己的买入订单(抢先交易),从而推高价格然后,他们可以在大额订单之后插入他们的卖出订单(尾随交易或三明治攻击),从而从初始交易引起的价格上涨中获利。

这是一个简化的、易受攻击的 DEX:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

// Simple ERC20 token for testing
contract TestToken is ERC20 {
    constructor(string memory name, string memory symbol, uint256 initialSupply) ERC20(name, symbol) {
        _mint(msg.sender, initialSupply);
    }
}

// Simplified DEX that's vulnerable to front-running
contract VulnerableDEX {
    TestToken public tokenA;
    TestToken public tokenB;
    uint public reserveA;
    uint public reserveB;

    constructor(address _tokenA, address _tokenB) {
        tokenA = TestToken(_tokenA);
        tokenB = TestToken(_tokenB);
    }

    // Initialize liquidity
    function addLiquidity(uint amountA, uint amountB) external {
        tokenA.transferFrom(msg.sender, address(this), amountA);
        tokenB.transferFrom(msg.sender, address(this), amountB);
        reserveA += amountA;
        reserveB += amountB;
    }

    // Calculate output amount for a given input
    function _calculateSwapOutput(address tokenIn, uint amountIn) internal view returns (uint amountOut) {
        require(tokenIn == address(tokenA) || tokenIn == address(tokenB), "Invalid token");

        bool isTokenA = tokenIn == address(tokenA);

        if (isTokenA) {
            amountOut = (reserveB * amountIn) / (reserveA + amountIn);
            require(amountOut < reserveB, "Insufficient liquidity");
        } else {
            amountOut = (reserveA * amountIn) / (reserveB + amountIn);
            require(amountOut < reserveA, "Insufficient liquidity");
        }

        return amountOut;
    }

    // Execute the swap with pre-calculated output
    function _executeSwap(address tokenIn, uint amountIn, uint amountOut, address sender) internal {
        bool isTokenA = tokenIn == address(tokenA);

        if (isTokenA) {
            tokenA.transferFrom(sender, address(this), amountIn);

            // Update reserves
            reserveA += amountIn;
            reserveB -= amountOut;

            // Transfer output tokens to the user
            tokenB.transfer(sender, amountOut);
        } else {
            tokenB.transferFrom(sender, address(this), amountIn);

            reserveB += amountIn;
            reserveA -= amountOut;

            tokenA.transfer(sender, amountOut);
        }
    }

    // Vulnerable swap function (no minimum output)
    function swap(address tokenIn, uint amountIn) external returns (uint amountOut) {
        // Calculate the expected output
        amountOut = _calculateSwapOutput(tokenIn, amountIn);

        // Execute the swap
        _executeSwap(tokenIn, amountIn, amountOut, msg.sender);

        return amountOut;
    }
}

在此 VulnerableDEX 合约中,swap 函数缺乏滑点保护,使其容易受到三明治攻击。这些攻击是一种最大可提取价值(MEV)的利用,其中通过操纵交易顺序来提取利润。

当受害者向 mempool 提交 swap 交易时,复杂的 MEV 搜索者会识别出机会并执行三步攻击:

  1. 抢先交易:攻击者首先放置一笔购买目标资产的交易,故意推高价格。

  1. 受害者交易:受害者的 swap 以这种人为抬高的价格执行,由于缺乏最低输出保证,收到的代币比预期少。

  1. 尾随交易:攻击者以受害者交易创造的较高价格出售之前获得的代币,从而将价格差作为利润。

虽然任何监视 mempool 的参与者都可以执行三明治攻击,但区块生产者具有特权的交易排序功能,这使他们能够更确定地执行这些攻击

以下是一个演示该漏洞的测试:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract TransactionOrderingTest is Test {
    VulnerableDEX dex;
    TestToken tokenA;
    TestToken tokenB;

    address victim = address(1);
    address attacker = address(2);
    address liquidityProvider = address(3);

    uint256 initialLiquidityA = 1000 ether;
    uint256 initialLiquidityB = 1000 ether;

    function setUp() public {
        // Deploy tokens with higher initial supply to support both tests
        tokenA = new TestToken("Token A", "TKNA", 100000 ether);
        tokenB = new TestToken("Token B", "TKNB", 100000 ether);

        // Deploy DEX
        dex = new VulnerableDEX(address(tokenA), address(tokenB));

        // Setup DEX with liquidity
        tokenA.transfer(liquidityProvider, 2000 ether);
        tokenB.transfer(liquidityProvider, 2000 ether);

        vm.startPrank(liquidityProvider);
        tokenA.approve(address(dex), initialLiquidityA);
        tokenB.approve(address(dex), initialLiquidityB);
        dex.addLiquidity(initialLiquidityA, initialLiquidityB);
        vm.stopPrank();

        // Give fresh tokens to victim and attacker for each test
        // We give them enough for both tests
        tokenA.transfer(victim, 20 ether);
        tokenA.transfer(attacker, 200 ether);
    }

    function testSandwichAttack() public {
        // Record initial balances
        uint attackerInitialBalanceA = tokenA.balanceOf(attacker);

        // Victim approves DEX to spend tokens
        vm.prank(victim);
        tokenA.approve(address(dex), 10 ether);

        // Attacker approves DEX to spend tokens
        vm.prank(attacker);
        tokenA.approve(address(dex), 100 ether);
        vm.prank(attacker);
        tokenB.approve(address(dex), type(uint256).max); // Allow selling tokens

        // STEP 1: Attacker front-runs by buying tokenB with a large amount of tokenA
        console.log("--- STEP 1: Attacker front-runs victim's trade ---");
        vm.prank(attacker);
        uint frontrunBought = dex.swap(address(tokenA), 100 ether);
        console.log("Attacker spent:", 100 ether, "tokenA");
        console.log("Attacker received:", frontrunBought, "tokenB");

        // Record pool state after front-run
        uint reserveAAfterFrontrun = dex.reserveA();
        uint reserveBAfterFrontrun = dex.reserveB();
        console.log("Pool state after front-run - Reserve A:", reserveAAfterFrontrun, "Reserve B:", reserveBAfterFrontrun);

        // STEP 2: Victim's transaction executes at a worse price
        console.log("\n--- STEP 2: Victim's trade executes at worse price ---");
        uint expectedOutputWithoutFrontrun = (initialLiquidityB * 10 ether) / (initialLiquidityA + 10 ether);

        vm.prank(victim);
        uint victimReceived = dex.swap(address(tokenA), 10 ether);
        console.log("Victim spent:", 10 ether, "tokenA");
        console.log("Victim expected to receive (without front-running):", expectedOutputWithoutFrontrun, "tokenB");
        console.log("Victim actually received:", victimReceived, "tokenB");
        console.log("Victim lost:", expectedOutputWithoutFrontrun - victimReceived, "tokenB due to front-running");

        // Record pool state after victim's trade
        uint reserveAAfterVictim = dex.reserveA();
        uint reserveBAfterVictim = dex.reserveB();
        console.log("Pool state after victim - Reserve A:", reserveAAfterVictim, "Reserve B:", reserveBAfterVictim);

        // STEP 3: Attacker back-runs by selling the tokenB they bought
        console.log("\n--- STEP 3: Attacker back-runs by selling tokenB ---");
        vm.prank(attacker);
        uint backrunReceived = dex.swap(address(tokenB), frontrunBought);
        console.log("Attacker sold:", frontrunBought, "tokenB");
        console.log("Attacker received:", backrunReceived, "tokenA");

        // Calculate attacker's profit in tokenA
        uint attackerFinalBalanceA = tokenA.balanceOf(attacker);
        int attackerProfit = int(attackerFinalBalanceA) - int(attackerInitialBalanceA);

        console.log("\n--- SANDWICH ATTACK SUMMARY ---");
        console.log("Attacker initial tokenA balance:", attackerInitialBalanceA);
        console.log("Attacker final tokenA balance:", attackerFinalBalanceA);
        console.log("Attacker's profit:", uint(attackerProfit), "tokenA");

        // Verify the profit is positive
        assertGt(attackerFinalBalanceA, attackerInitialBalanceA, "Attacker should profit from the sandwich attack");
    }
}

此测试演示了一个经典的三明治攻击,其中攻击者有策略地围绕受害者的交易。首先,他们通过购买来抢先交易,从而人为地抬高代币价格。然后,他们以这种被操纵的价格执行受害者的 swap,从而导致严重的滑点。最后,攻击者通过出售来尾随交易,从而捕获由差价产生的利润。缓解措施

缓解策略取决于具体情况,但总的来说,滑点保护通过确保受害者的交易不会以不利的价格执行来防止三明治攻击。允许用户指定他们愿意在交易中承受的最大滑点。如果价格超出此阈值,则交易将被 revert。这可以保护用户免受三明治攻击。

这是一个滑点保护的示例,它可以缓解上述测试中描述的三明治攻击。

// Secure swap function with minimum output requirement
    function swapWithMinimumOutput(
        address tokenIn,
        uint amountIn,
        uint minAmountOut
    ) external returns (uint amountOut) {
        // Calculate the expected output
        amountOut = _calculateSwapOutput(tokenIn, amountIn);

        // Check slippage before executing the swap
        require(amountOut >= minAmountOut, "Slippage too high");

        // Execute the swap
        _executeSwap(tokenIn, amountIn, amountOut, msg.sender);

        return amountOut;
    }

所有示例都可以在我的 GitHub here 上找到。

结论

我们已经探讨了矿工虽然对区块链的运行至关重要,但也可能影响智能合约的执行,从而导致严重漏洞。我们已经看到,依赖 block.timestamp 进行时间敏感的操作、使用区块属性进行随机化以及忽略交易排序如何为矿工操纵打开大门。

主要收获

  • block.timestamp 不是可靠的时间来源。 对于关键的时间操作,请使用 block.number 或外部预言机。

  • 切勿使用区块属性进行随机化。 选择安全
  • 原文链接: cyfrin.io/blog/solodit-c...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
cyfrin
cyfrin
Securing the blockchain and its users. Industry-leading smart contract audits, tools, and education.