Skip to content
On this page

闯关Ethernaut:Gatekeeper Three 的巧妙破解之路

在Web3安全领域,Ethernaut 系列挑战以其精妙的设计和对智能合约漏洞的深度挖掘而闻名。今天,我们将深入探讨其中一个引人入胜的关卡——“Gatekeeper Three”,看看我们如何步步为营,闯过三道难关,最终成为合约的“入场者”(entrant)。

挑战概览:Gatekeeper Three

目标: 调用 GatekeeperThree 合约的 enter() 函数,并将 entrant 变量设置为我们的地址。

关卡提示:

  • 回顾低级函数的返回值。
  • 注意语义。
  • 刷新以太坊存储工作原理的知识。

这些提示像谜语一般,指引我们解开谜题的关键。让我们逐一剖析 GatekeeperThree 合约及其三道守卫。

深入合约:三道守卫

GatekeeperThree.sol 合约的核心在于 enter() 函数,它被三个 modifier(修饰符)所保护:gateOnegateTwogateThree。只有这三个条件都被满足,我们才能成功进入。

solidity
function enter() public gateOne gateTwo gateThree {
    entrant = tx.origin;
}

第一道守卫:gateOne()

solidity
modifier gateOne() {
    require(msg.sender == owner);
    require(tx.origin != owner);
    _;
}

这里是经典的 msg.sendertx.origin 的区分考察点。

  • msg.sender == owner:要求调用 enter() 函数的直接调用者必须是 owner
  • tx.origin != owner:要求发起整个交易的外部账户(EOA)不能是 owner

这两条 require 语句看似矛盾,但通过一个中间合约调用就能完美解决:

  1. 我们部署一个攻击合约(Hack.sol)。
  2. 在攻击合约中,我们首先调用 GatekeeperThreeconstruct0r() 函数(注意是 construct0r 而非 constructor),将攻击合约自身设置为 GatekeeperThreeowner
  3. 然后,在攻击合约中调用 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() 函数可以设置 allowEntrancetrue,但它需要满足一个条件:trick.checkPassword(_password) 返回 truetrickSimpleTrick 合约的实例。

我们来看 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()

攻击步骤:

  1. GatekeeperThree 合约需要先调用 createTrick() 来部署 SimpleTrick 实例。
  2. createTrick() 被调用后,我们立即在攻击合约中获取当前的 block.timestamp
  3. 然后,使用这个 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 提示“回顾低级函数返回值”的地方。它包含两个条件:

  1. address(this).balance > 0.001 etherGatekeeperThree 合约的余额必须大于 0.001 ether。
  2. 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 的策略,这里就迎刃而解了:

  1. 在攻击合约中调用 GatekeeperThree.construct0r(),将攻击合约自身设置为 owner
  2. 从攻击合约向 GatekeeperThree 转入超过 0.001 ether 的金额,以满足余额条件。
  3. 由于攻击合约 (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();
    }
}

攻击流程:

  1. 部署 Hack 合约,传入 GatekeeperThree 的地址。
  2. 调用 Hack.doEnter() 函数,同时向 Hack 合约转入足够的 Ether(例如 0.0011 ether 或更多,以覆盖给 GatekeeperThree 转账的需要)。

doEnter() 中:

  • target.construct0r();:将 Hack 合约设置为 GatekeeperThreeowner。这为 gateOnegateThree 的破解奠定了基础。
  • 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()。此时:
    • gateOnemsg.sender (Hack合约) == owner (Hack合约),tx.origin (EOA) != owner (Hack合约)。通过。
    • gateTwoallowEntrance 已经通过 getAllowance 设置为 true。通过。
    • gateThreeGatekeeperThree 余额 > 0.001 ether。payable(owner).send(0.001 ether) 会尝试向 Hack 合约发送,但 Hack 没有 receive/fallback 函数,导致 send() 失败并返回 false。通过。

所有守卫都已通过,entrant 变量最终会被设置为 tx.origin(即部署 Hack 合约的 EOA 地址)。

总结与学习

“Gatekeeper Three”是一个设计精良的 Ethernaut 挑战,它巧妙地融合了多个智能合约安全知识点:

  • msg.sender vs. tx.origin 理解这两者在合约交互中的区别至关重要。
  • 低级函数 send() 的行为: 掌握 send() 在不同场景下(如接收方是合约且无 receive/fallback 函数)的返回值,以及其 gas 限制。
  • 时序攻击与 block.timestamp 了解 block.timestamp 的特性及其在同一交易中可预测性。
  • 合约初始化模式: construct0r 这种非标准命名函数作为初始化器的使用。

通过解决这类挑战,我们不仅能提升对 Solidity 语言特性的理解,更能培养发现和利用智能合约漏洞的思维模式,这对于构建更安全的Web3应用至关重要。这正是 Ethernaut 系列的魅力所在!

Built with AiAda