Skip to content
On this page

Ethernaut 守门人一号 (Gatekeeper One):穿越三重险境的智能合约挑战

亲爱的区块链安全爱好者们,今天我们来深入剖析 Ethernaut 平台上的一个经典且极具教育意义的关卡——Gatekeeper One。这个级别考验的不仅仅是 Solidity 编程技巧,更是对 EVM 内部机制、gas 控制以及位运算和类型转换的深刻理解。准备好,我们将一起解密这扇看似坚不可摧的“大门”!

挑战概览:谁能成为“入门者”?

Gatekeeper One 的目标很简单:成功调用 enter 函数,让你的地址成为 entrant。然而,这个 enter 函数被三道“守门人”修改器(modifier)层层把守,每一道都隐藏着独特的挑战。

solidity
contract GatekeeperOne {
    address public entrant; // 成功通过者将登记在此

    modifier gateOne() { /* ... */ } // 第一道门
    modifier gateTwo() { /* ... */ } // 第二道门
    modifier gateThree(bytes8 _gateKey) { /* ... */ } // 第三道门,需要一个密钥

    function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
        entrant = tx.origin; // 成功则登记 tx.origin
        return true;
    }
}

“记住你在 Telephone 和 Token 级别中学到的东西”,以及“了解 gasleft() 特殊函数”——这些提示无疑为我们指明了方向。

第一道门:msg.sendertx.origin 的迷雾

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

这扇门相对简单,但却是智能合约安全的基础知识点:msg.sendertx.origin 的区别。

  • tx.origin:是发起整个交易的原始外部账户(EOA)。
  • msg.sender:是当前调用函数的直接发起者。

如果直接用你的 EOA 调用 GatekeeperOne.enter(),那么 msg.sendertx.origin 将会相同,导致条件失败。因此,我们需要通过一个中间合约来调用 GatekeeperOne.enter()。这样,你的 EOA 作为 tx.origin,而中间合约的地址将作为 msg.sender,从而满足 msg.sender != tx.origin 的条件。这正是 Ethernaut Telephone 级别所教授的核心概念。

第二道门:精准的 Gas 控制艺术

solidity
modifier gateTwo() {
    require(gasleft() % 8191 == 0);
    _;
}

这无疑是整个挑战中最烧脑的部分!它要求当 gateTwo modifier 执行时,当前剩余的 gas 数量必须是 8191 的倍数。 gasleft() 是一个全局函数,返回当前可用的 gas 数量。每次执行操作,gas 都会减少。这意味着我们需要精确控制在 enter 函数被调用、进而 gateTwo modifier 开始执行时的剩余 gas 量。

关键洞察:

  1. 我们无法直接控制 gateTwo 执行时的 gasleft(),因为外部调用和 enter 函数本身都会消耗 gas。
  2. 但我们可以通过 call{gas: X}(...) 语法,精确指定一个外部函数调用所能消耗的最大 gas 量。
  3. 从我们的攻击合约(Hack)调用 GatekeeperOne.enter() 时,我们可以设置一个特定的 gas limit。例如:target.enter{gas: specificGasAmount}(key)
  4. GatekeeperOne.enter() 函数被调用后,会先消耗一些固定的 gas(例如函数签名检查、参数解码等),然后才进入 gateTwo modifier。
  5. 假设 enter 函数在进入 gateTwo 前会消耗 C 单位的 gas。如果我们传入的 specificGasAmountG_total,那么在 gateTwogasleft() 的值将是 G_total - C
  6. 我们的目标是让 (G_total - C) % 8191 == 0
  7. 由于 C 是一个在 EVM 上相对固定的值(尽管会受编译器版本和优化影响),我们可以通过穷举的方式找到它。Hack.sol 中的 tryAndLoop() 函数正是用于此目的:它从 0 迭代到 8190,尝试 target.enter{gas: 8191 * N + i}(key),直到找到一个 i 使得条件成立。这里的 8191 * N 是为了确保我们有足够的 gas 执行后续操作,并保持 8191 的倍数特性。

实际测试中,SEPOLIA_GASCATCH 在 Sepolia 测试网上被发现是 256。这意味着在调用 enter 时,前置消耗的 gas 使得 (8191 * N + 256 - C) % 8191 == 0 成立,或者说 (256 - C) % 8191 == 0 成立(在 8191 的一个周期内)。

第三道门:精妙的类型转换与位运算

solidity
modifier gateThree(bytes8 _gateKey) {
    require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)), "GatekeeperOne: invalid gateThree part one");
    require(uint32(uint64(_gateKey)) != uint64(_gateKey), "GatekeeperOne: invalid gateThree part two");
    require(uint32(uint64(_gateKey)) == uint16(uint160(tx.origin)), "GatekeeperOne: invalid gateThree part three");
    _;
}

这道门需要我们精心构造一个 bytes8 类型的 _gateKey。它包含三个 require 语句,每个都涉及到复杂的数据类型转换和数值比较。

让我们逐一分析:

  1. require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)))

    • uint64(_gateKey):将 bytes8(8字节)转换为 uint64
    • uint32(uint64(_gateKey)):取 uint64 的最低 4 字节(32位)。
    • uint16(uint64(_gateKey)):取 uint64 的最低 2 字节(16位)。
    • 这个条件意味着 uint64(_gateKey)最低 4 字节的最高 2 字节必须为零。换句话说,_gateKey 被解释为 uint64 后,其值必须小于或等于 uint16 的最大值(0xFFFF),且存储在最低 2 字节中。
  2. require(uint32(uint64(_gateKey)) != uint64(_gateKey))

    • 这个条件要求 uint64(_gateKey) 的值不能完全由其最低 4 字节决定。也就是说,uint64(_gateKey) 必须在最低 4 字节之上(即第 5 到第 8 字节)包含非零位。
  3. require(uint32(uint64(_gateKey)) == uint16(uint160(tx.origin)))

    • uint160(tx.origin):将 tx.origin 地址(20字节)截断为 uint160
    • uint16(uint160(tx.origin)):取 tx.origin 地址的最低 2 字节。
    • 这个条件将我们构造的 _gateKey最低 2 字节tx.origin 地址的最低 2 字节绑定起来。

综合构造 _gateKey

Hack.sol 的解决方案非常巧妙:

solidity
uint16 k16 = uint16(uint160(tx.origin)); // 获取 tx.origin 的最低 2 字节
uint64 k64 = uint64(1 << 63) + uint64(k16); // 构造 uint64 值
bytes8 key = bytes8(k64); // 转换为 bytes8

让我们验证一下这个 k64 是否满足所有条件:

  • k16tx.origin 的最低 2 字节,它是一个 uint16 值。
  • 1 << 63 是一个非常大的 uint64 值,它只有最高位是 1,其余都是 0

现在看 k64 ((1 << 63) + k16):

  1. uint32(uint64(k64)) == uint16(uint64(k64))?

    • uint64(k64) 的最低 4 字节实际上就是 k16(因为 1 << 63 远高于最低 4 字节)。
    • uint32(k64) 得到 k16
    • uint16(k64) 得到 k16
    • 所以 k16 == k16,第一个条件满足!
  2. uint32(uint64(k64)) != uint64(k64)?

    • uint32(k64) 得到 k16
    • uint64(k64)(1 << 63) + k16
    • 由于 1 << 63 是一个巨大的非零值,显然 k16 != (1 << 63) + k16。第二个条件满足!
  3. uint32(uint64(k64)) == uint16(uint160(tx.origin))?

    • uint32(uint64(k64)) 得到 k16
    • uint16(uint160(tx.origin)) 也是 k16
    • 所以 k16 == k16,第三个条件满足!

完美!我们成功构造了符合所有要求的 _gateKey

攻击合约(Hack.sol)的实现

攻击合约 Hack 将所有逻辑整合起来:

  1. 构造函数: 接收目标 GatekeeperOne 合约的地址。
  2. goEnter(uint256 _gas) 函数:
    • 计算 tx.origin 的最低 2 字节 k16
    • 使用 k16(1 << 63) 构造出 bytes8 类型的 key
    • 最重要的,它使用 call{gas: 8191 * 20 + _gas} 语法,以精确控制传递给 target.enter 函数的 gas 量。这里的 _gas 就是我们通过 tryAndLoop 找到的偏移量(例如 Sepolia 上的 256),8191 * 20 确保有足够的 gas 运行整个交易,并保持 8191 的倍数结构。
  3. tryAndLoop() 函数:
    • 这是一个辅助函数,用于在不知道具体 gas 偏移量时进行暴力破解。它会尝试 _gas08190 的所有值,直到 goEnter 成功为止。在实际部署中,一旦找到正确的偏移量,就可以直接使用 goEnter

关键学习点

通过 Gatekeeper One,我们学到了宝贵的经验:

  1. msg.sender vs tx.origin 理解这两者的区别,以及如何利用它们进行合约间的调用。
  2. EVM Gas 机制: 掌握 gasleft() 的行为,以及如何通过 call{gas: X} 精确控制外部调用的 gas 限制。这是编写高效和安全合约的关键。
  3. Solidity 类型转换与位运算: 深入理解 Solidity 中不同整型之间的隐式/显式转换规则,以及位运算符(如 <<)在数据处理中的应用。
  4. 分步解决复杂问题: 将一个大挑战分解为三个独立的子问题,逐个击破,最终整合解决方案。

Gatekeeper One 不仅是一个有趣的 CTF 关卡,更是一堂生动的区块链安全实战课。它强制我们深入思考 EVM 的底层细节,以及如何利用这些细节来绕过看似严密的逻辑。希望这篇文章能帮助你更好地理解和攻克这个精彩的挑战!

Built with AiAda