You can check out the code for this article on our GitHub.
Preamble
This post is part 3 in continuation of our Smart Contract Security Series.
This post is part of our Smart Contract Security Series:
- Ownership Exploit
- Private Variable Exploit
- Reentrancy Attack
- tx.origin Phishing Attack
- Denial of Service Attack
- Storage Collision Attack
- Randomness Attack
- Replay Attack
The post discusses the next attack called the reentrancy attack.
One famous reentrancy attack that occurred in 2016 was the DAO attack leading to losses of $60 million.
Let us try to emulate the attack and see the possible solutions for such an attack. It begins with the attack, followed by three techniques to prevent this attack, and then the conclusion.
Let’s go!
Exploit
To emulate the exploit consider the following contracts. One is the regular savings bank account contract and another is the attacker contract.
In the reentrancy attack, the hacker would typically use a contract to attack the victim.
Here are the two Solidity contracts defined below.
In a file called savingsBank.sol
:
contract SavingsBank { 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; } // Helper function to check the balance of this contract function getBalance() public view returns (uint) { return address(this).balance; } }
An attacker contract called attacker.sol
:
import "./savingsBank.sol"; contract Attacker { SavingsBank public savingsStore; constructor(address _savingsStoreAddress) { savingsStore = SavingsBank(_savingsStoreAddress); } // Fallback is called when SavingsBank sends Ether to this // contract. fallback() external payable { if (address(savingsStore).balance >= 1 ether) { savingsStore.withdraw(); } } function attack() external payable { require(msg.value >= 1 ether); savingsStore.deposit{value: 1 ether}(); savingsStore.withdraw(); } // Helper function to check the balance of this contract function getBalance() public view returns (uint) { return address(this).balance; } }
The contracts are briefly described below.
The contract SavingsBank
.
- A map that maps the address to the amount.
- A
deposit()
function to deposit the amount. - A
withdraw()
function to withdraw the balance amount using call. This function appears perfectly normal as after the balance withdrawal we make the associated balance of themsg.sender
(i.e. withdrawer) to 0. We will soon see how this function can be exploited. - Finally, a
getBalance()
function to get the total balance of the contract.
The Attacker
contract.
- A constructor that gets the instance of the
SavingsBank
contract. - A fallback function is defined that gets called as soon as the
SavingsBank
contract sends some Ether (i.e., when the message call is initiated in thewithdraw()
function ofSavingsBank
). - An attack function to trigger the hack.
- Finally,
getBalance()
to get the balance of the attacker contract.
For the compilation and deployment of contracts refer to the Truffle post here.
How does the exploit occur?. Let us see this in steps.
Let’s assume the SavingsBank
has a balance of β3
β Ether or three users have deposited β1
β Ether each.
- The attacker calls the
attack()
function which initially deposits 1 Ether. The map of theSavingsBank
contract gets updated with,balances[msg.sender] = '1'
Ether (or 1,000,000,000,000,000,000 Wei), wheremsg.sender
is the address of the attacker. - The next function that gets called is,
withdraw()
. Thewithdraw()
function inSavingsBank
gets the balance of the attacker and checks if it is >0, which in this case is β1
β Ether, thustrue
. It makes a message call and sends β1
β Ether by calling the fallback function of the attacker contract.
The fallback function checks if the balance of the SavingsBank
contract is >= β1β
Ether, in this case, it will be left with β3
β Ether because the attacker had deposited β1
β Ether and has withdrawn it back. Again the withdraw()
function is triggered. As the balances[msg.sender]
is still > 0, it again results in a message call and sends β1
β Ether to the fallback function. This process is repeated until the SavingsBank
contract is completely drained of all the Ether.
What exactly happened?
Here is how the functions were called
Attacker.attack
SavingsBank.deposit
SavingsBank.withdraw
- Attacker fallback (receives 1 Ether)
SavingsBank.withdraw
- Attacker fallback (receives 1 Ether)
SavingsBank.withdraw
- Attacker fallback (receives 1 Ether)
SavingsBank.withdraw
- Attacker fallback (receives 1 Ether)
This process of withdraw() <-> fallback
would continue until SavingsBank
is left with no Ether, and only at the end the balances[msg.sender]
is set to β0
β in the withdraw()
function.
Solutions
The above problem of reentrancy can be solved with three possible solutions. Let us look into each of them in detail and decide on the best possible solution among the three.
Solution 1: Using transfer() or send()
In the SavingsBank
contract, instead of using the msg.sender.call()
, we can either use msg.sender.transfer(amount)
or msg.sender.send(amount)
.
Both transfer()
and send()
, due to the inherent design of EVM (Ethereum virtual machine), provide a stipend of 2300 gas to the fallback function.
Thus when the fallback function tries to execute withdraw()
function it needs more gas than 2300 and the minimum gas of 2300 will not be sufficient to call withdraw()
, thus failing the transaction. The gas 2300 provided by transfer()
or send()
is only sufficient to execute a simple logging function.
With this, it is possible to stop the reentrancy attack.
However, note that this is not a very clean solution. What if you want to execute more complex transactions in the fallback function, not necessarily reentrancy?
It will not be possible to use transfer()
or send()
in such cases and in general, the best practice is not to use transfer()
or send()
to send Ether.
All the contracts have now been using call()
for transferring Ether as it can provide all the remaining gas as part of the transaction and also the possibility of specifying the amount of gas to be sent (msg.sender.call{value: msg.value, gas: 5000}
).
Solution 2: Code Correction
To fix the reentrancy problem it is possible to make the correction in the code to avoid entering the call()
function. The correction is given below in SavingsBank
contract.
function withdraw() public { uint bal = balances[msg.sender]; require(bal > 0); balances[msg.sender] = 0; (bool sent, ) = msg.sender.call{value: bal}(""); require(sent, "Failed to send Ether"); }
As can be seen above, the balances of the msg.sender
is set to 0 before making the message call()
. In such a case when withdraw()
is called again, bal = 0
and it will fail to enter because of the condition require(bal >0)
.
In this case, too, it is a workaround solution by reorganizing the code sequence and is not the preferred method as the attacker is still able to enter in the withdraw()
function.
Solution 3: ReentrancyGuard from OpenZeppelin
By inheriting the OpenZeppelinβs ReentrancyGuard.sol
in the SavingsBank
contract.
Code below:
import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/security/ReentrancyGuard.sol" contract SavingsBank is ReentrancyGuard { function withdraw() public nonReentrant { 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; } }
With the reentrancy guard added, the withdraw()
function would get called only once due to the mutex lock used in the modifier nonReentrant() - _status = _ENTERED
.
This is the preferred clean solution as it does not allow the attacker to enter the withdraw()
function subsequently after the first time.
Conclusion
In this part we saw how a reentrancy attack can cause serious damage during the withdrawal of funds. Unknowingly the attacker can withdraw all the funds.
We also looked at three possible solutions including the transfer/send methods, changing the sequence in the code, and finally the OpenZeppelinβs ReentrancyGuard.
It is best to choose the ReentrancyGuard solution over solutions one and two for it being clean and reusable code.
Thanks and Happy Hacking or preventing the same :-)!
Learn Solidity Course
Solidity is the programming language of the future.
It gives you the rare and sought-after superpower to program against the “Internet Computer”, i.e., against decentralized Blockchains such as Ethereum, Binance Smart Chain, Ethereum Classic, Tron, and Avalanche – to mention just a few Blockchain infrastructures that support Solidity.
In particular, Solidity allows you to create smart contracts, i.e., pieces of code that automatically execute on specific conditions in a completely decentralized environment. For example, smart contracts empower you to create your own decentralized autonomous organizations (DAOs) that run on Blockchains without being subject to centralized control.
NFTs, DeFi, DAOs, and Blockchain-based games are all based on smart contracts.
This course is a simple, low-friction introduction to creating your first smart contract using the Remix IDE on the Ethereum testnet – without fluff, significant upfront costs to purchase ETH, or unnecessary complexity.