Solidity智能合约的重入攻击
Posted mutourend
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Solidity智能合约的重入攻击相关的知识,希望对你有一定的参考价值。
1. 何为重入攻击?
如果一个计算机程序或子程序在执行过程中被中断,然后在前一次调用完成之前可被安全地再次调用,则称之为可重入。
中断的原因可为:
- 内部动作,如jump 或 call;
- 外部动作,如interrupt 或 signal。
一旦重入调用完成后,之前的调用就将恢复执行。使用某些代码执行此操作将导致重入攻击。
智能合约在其正常执行过程中可能会通过:
- function call
- 或 simply transfer Ether
来调用其它合约。
这些合约可:
- 自己调用其它合约
- call back to the contract that called them
- call back to any other in the call stack
这样的合约可称为是可重入的。
重入本身不是问题,但当其会引起状态不一致时,就有问题了。
当合约的相关不变量为true时,可认为state是一致的。如ERC20中,其主要不变量为:所有的balance之和不会超过已知的总供应量。
通常情况下,函数假设其开始运行时合约状态是一致的,函数运行后的状态也仍然是保持一致的。而在函数执行过程中,只要没人发现,其状态是可能是不一致的。此时,就有可能存在重入攻击的问题。
因此,不仅应要求执行后的状态一致,还应要求在每一个可能的重入点都保持状态一致。
2. 重入攻击举例
以下代码存在重入攻击问题:
function withdraw(uint _amount) public {
if (amount <= balances[msg.sender]) {
msg.sender.call.value(_amount)();
balances[msg.sender] -= _amount;
}
}
重入攻击的点为:
- 先发送了ether,然后才更新sender的balance
- 当通过call.value()来调用代码时,执行的代码会被赋予所有可用的gas。
将一直执行直到没有ether或gas。
详细可参看 重入攻击概述,借助Remix工具来实操理解重入攻击。
3. 重入攻击的分类
重入攻击可分为:
- single function 重入攻击
function withdraw(uint _amount) public {
if (amount <= balances[msg.sender]) {
msg.sender.call.value(_amount)();
balances[msg.sender] -= _amount;
}
}
- cross-function 重入攻击
function transfer(address to, uint amount) external {
if (balances[msg.sender] >= amount) {
balances[to] += amount;
balances[msg.sender] -= amount;
}
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(msg.sender.call.value(amount)());
balances[msg.sender] = 0;
}
此时,与single function 重入攻击类似,withdraw将调用the attacker’s fallback function。不同之处在于fallback函数将调用transfer函数而不是递归调用withdraw函数。由于在该调用之前没有将sender的balance设为0,transfer函数将transfer a balance that has already been spent。
4. 重入攻击的破坏力
最有影响的是2016年的The DAO攻击,造成了3600个Ether的损失,使得以太坊进行了硬分叉。详细可参见:
5. 如何避免重入攻击
推荐的做法有:
- 采用Checks-Effects-Interactions模式
function withdraw(uint _amount) public {
if (amount <= balances[msg.sender]) { //Checks
balances[msg.sender] -= _amount; //Effects
msg.sender.call.value(_amount)(); //Interactions
}
}
- 使用更安全的transfer或send函数(send为transfer的底层实现)来发送ether,应transfer和send函数的gas仅有2300,这点gas仅够捕获一个event,所以将无法进行可重入攻击。
function withdraw(uint _amount) public {
if (amount <= balances[msg.sender]) {
msg.sender.transfer(_amount)();
balances[msg.sender] -= _amount;
}
}
- 使用互斥锁:添加一个在代码执行过程中锁定合约的状态变量,可防止重入调用
bool reEntrancyMutex = false;
function withdraw(uint _amount) public {
require(!reEntrancyMutex);
reEntrancyMutex = true;
if(balances[msg.sender] >= _amount) {
if(msg.sender.call.value(_amount)()) {
_amount;
}
balances[msg.sender] -= _amount;
reEntrancyMutex = false;
}
}
- 使用OpenZeppelin官方的ReentrancyGuard合约中的
nonReentrant
modifier:
若在合约的执行过程中无法确保不变量成立,应尽量避免调用其它(untrusted)合约。如果不得不调用,可使用reentrancy guard来避免reentrancy问题。
在函数中增加nonReentrant
modifier可保证其不可重入,任何对该函数的重入操作都将以revert the call的方式来拒绝
当合约中有多个函数时,由于modifier的粒度在单个函数,若想完全避免重入,应对每个函数都添加nonReentrant
modifier。否则,仍然可以通过其他函数来重入然后发起重入攻击,若该函数可能破坏不变量。
在为函数添加nonReentrant
modifier时,应注意,合约中的public
变量将自动有一个getter函数来读取其值,没有办法为该getter函数添加modifier。大多数情况下,这不会引起重入问题,但是仍然值得注意,因为其它合约可通过getter函数观察到由于broken invariants导致的不一致状态。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
/**
* @dev Contract module that helps prevent reentrant calls to a function.
*
* Inheriting from `ReentrancyGuard` will make the {nonReentrant} modifier
* available, which can be applied to functions to make sure there are no nested
* (reentrant) calls to them.
*
* Note that because there is a single `nonReentrant` guard, functions marked as
* `nonReentrant` may not call one another. This can be worked around by making
* those functions `private`, and then adding `external` `nonReentrant` entry
* points to them.
*
* TIP: If you would like to learn more about reentrancy and alternative ways
* to protect against it, check out our blog post
* https://blog.openzeppelin.com/reentrancy-after-istanbul/[Reentrancy After Istanbul].
*/
abstract contract ReentrancyGuard {
// Booleans are more expensive than uint256 or any type that takes up a full
// word because each write operation emits an extra SLOAD to first read the
// slot's contents, replace the bits taken up by the boolean, and then write
// back. This is the compiler's defense against contract upgrades and
// pointer aliasing, and it cannot be disabled.
// The values being non-zero value makes deployment a bit more expensive,
// but in exchange the refund on every call to nonReentrant will be lower in
// amount. Since refunds are capped to a percentage of the total
// transaction's gas, it is best to keep them low in cases like this one, to
// increase the likelihood of the full refund coming into effect.
uint256 private constant _NOT_ENTERED = 1;
uint256 private constant _ENTERED = 2;
uint256 private _status;
constructor () {
_status = _NOT_ENTERED;
}
/**
* @dev Prevents a contract from calling itself, directly or indirectly.
* Calling a `nonReentrant` function from another `nonReentrant`
* function is not supported. It is possible to prevent this from happening
* by making the `nonReentrant` function external, and make it call a
* `private` function that does the actual work.
*/
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;
}
}
- 采用pull payment模式 来替代 push fund to a receiver。OpenZeppelin提供了PullPayment合约。其提供了
_asyncTransfer
函数,与transfer
类似。然而,它不会将资金发送给接收者,而是将其转移到托管合约中。此外,PullPayment还为接收者提供了一个公共功能来提取(pull)他们的支付:withdrawPayments
。
附录-Solidity fallback函数
fallback函数为合约中的一种特殊函数,其具有如下特性:
- It is called when a non-existent function is called on the contract.
- It is required to be marked external.
- It has no name. (Solidity合约中最多仅能有一个unnamed function。)
- It has no arguments
- It can not return any thing.
- It can be defined one per contract.
- If not marked payable, it will throw exception if contract receives plain ether without data.( is executed whenever a contract would receive plain Ether, without any data, in this case it must be payable.)
具体举例为:
pragma solidity ^0.5.0;
contract Test {
uint public x ;
function() external { x = 1; }
}
contract Sink {
function() external payable { }
}
contract Caller {
function callTest(Test test) public returns (bool) {
(bool success,) = address(test).call(abi.encodeWithSignature("nonExistingFunction()"));
require(success);
// test.x is now 1
address payable testPayable = address(uint160(address(test)));
// Sending ether to Test contract,
// the transfer will fail, i.e. this returns false here.
return (testPayable.send(2 ether));
}
function callSink(Sink sink) public returns (bool) {
address payable sinkPayable = address(sink);
return (sinkPayable.send(2 ether));
}
}
参考资料
[1] Protect Your Solidity Smart Contracts From Reentrancy Attacks
[2] Reentrancy Attack in a Smart contract
[3] Reentrancy After Istanbul
[4] 重入攻击概述
[5] Solidity fallback函数
以上是关于Solidity智能合约的重入攻击的主要内容,如果未能解决你的问题,请参考以下文章
区块链 智能合约安全 重入攻击(re-entrancy attack)DAO incident