Appearance
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.sender 与 tx.origin 的迷雾
solidity
modifier gateOne() {
require(msg.sender != tx.origin);
_;
}
这扇门相对简单,但却是智能合约安全的基础知识点:msg.sender 和 tx.origin 的区别。
tx.origin:是发起整个交易的原始外部账户(EOA)。msg.sender:是当前调用函数的直接发起者。
如果直接用你的 EOA 调用 GatekeeperOne.enter(),那么 msg.sender 和 tx.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 量。
关键洞察:
- 我们无法直接控制
gateTwo执行时的gasleft(),因为外部调用和enter函数本身都会消耗 gas。 - 但我们可以通过
call{gas: X}(...)语法,精确指定一个外部函数调用所能消耗的最大 gas 量。 - 从我们的攻击合约(
Hack)调用GatekeeperOne.enter()时,我们可以设置一个特定的 gas limit。例如:target.enter{gas: specificGasAmount}(key)。 - 当
GatekeeperOne.enter()函数被调用后,会先消耗一些固定的 gas(例如函数签名检查、参数解码等),然后才进入gateTwomodifier。 - 假设
enter函数在进入gateTwo前会消耗C单位的 gas。如果我们传入的specificGasAmount是G_total,那么在gateTwo中gasleft()的值将是G_total - C。 - 我们的目标是让
(G_total - C) % 8191 == 0。 - 由于
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 语句,每个都涉及到复杂的数据类型转换和数值比较。
让我们逐一分析:
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 字节中。
require(uint32(uint64(_gateKey)) != uint64(_gateKey))- 这个条件要求
uint64(_gateKey)的值不能完全由其最低 4 字节决定。也就是说,uint64(_gateKey)必须在最低 4 字节之上(即第 5 到第 8 字节)包含非零位。
- 这个条件要求
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 是否满足所有条件:
k16是tx.origin的最低 2 字节,它是一个uint16值。1 << 63是一个非常大的uint64值,它只有最高位是1,其余都是0。
现在看 k64 ((1 << 63) + k16):
uint32(uint64(k64)) == uint16(uint64(k64))?uint64(k64)的最低 4 字节实际上就是k16(因为1 << 63远高于最低 4 字节)。uint32(k64)得到k16。uint16(k64)得到k16。- 所以
k16 == k16,第一个条件满足!
uint32(uint64(k64)) != uint64(k64)?uint32(k64)得到k16。uint64(k64)是(1 << 63) + k16。- 由于
1 << 63是一个巨大的非零值,显然k16 != (1 << 63) + k16。第二个条件满足!
uint32(uint64(k64)) == uint16(uint160(tx.origin))?uint32(uint64(k64))得到k16。uint16(uint160(tx.origin))也是k16。- 所以
k16 == k16,第三个条件满足!
完美!我们成功构造了符合所有要求的 _gateKey。
攻击合约(Hack.sol)的实现
攻击合约 Hack 将所有逻辑整合起来:
- 构造函数: 接收目标
GatekeeperOne合约的地址。 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的倍数结构。
- 计算
tryAndLoop()函数:- 这是一个辅助函数,用于在不知道具体 gas 偏移量时进行暴力破解。它会尝试
_gas从0到8190的所有值,直到goEnter成功为止。在实际部署中,一旦找到正确的偏移量,就可以直接使用goEnter。
- 这是一个辅助函数,用于在不知道具体 gas 偏移量时进行暴力破解。它会尝试
关键学习点
通过 Gatekeeper One,我们学到了宝贵的经验:
msg.sendervstx.origin: 理解这两者的区别,以及如何利用它们进行合约间的调用。- EVM Gas 机制: 掌握
gasleft()的行为,以及如何通过call{gas: X}精确控制外部调用的 gas 限制。这是编写高效和安全合约的关键。 - Solidity 类型转换与位运算: 深入理解 Solidity 中不同整型之间的隐式/显式转换规则,以及位运算符(如
<<)在数据处理中的应用。 - 分步解决复杂问题: 将一个大挑战分解为三个独立的子问题,逐个击破,最终整合解决方案。
Gatekeeper One 不仅是一个有趣的 CTF 关卡,更是一堂生动的区块链安全实战课。它强制我们深入思考 EVM 的底层细节,以及如何利用这些细节来绕过看似严密的逻辑。希望这篇文章能帮助你更好地理解和攻克这个精彩的挑战!