The Smart Contract Weakness Classification and Test Cases (SWC) Registry is a set of Web3 vulnerabilities to avoid when writing smart contracts. It may seem daunting to understand every issue so I’ll do my best to demystify every issue and explain each vulnerability with real-world examples. This write-up is intended for those who are just starting out in solidity development and smart contract auditing.
SWC-107: Reentrancy
One of the major dangers of calling external contracts is that they can take over the control flow. In the reentrancy attack (a.k.a. recursive call attack), a malicious contract calls back into the calling contract before the first invocation of the function is finished. This may cause the different invocations of the function to interact in undesirable ways. In this article, I will be going through these five bullet points. This will be quite a long write-up, so buckle up and enjoy the read!
What is Reentrancy?
What is a fallback function?
How to remediate a potential Reentrancy
Summary
References
What is Reentrancy?
Take a look at this contract
The DepositFunds contract has two functions: deposit() and withdraw(). When deposit() is called, the balance of the user is increased and is recorded inside the balances mapping. This is the normal way to account for all user’s deposits. For example, if I deposit 1 ETH and you deposit 2 ETH, the contract will have 3 ETH inside, and it knows that the ETH in the contract comes from me (1 ETH) and you (2 ETH) only. (and not some other random person).
Let’s take a look at the withdraw function. The first line instantiates a variable called bal and this points to the balances mapping of the msg.sender. In this case, bal will be 1 ETH for me and 2 ETH for you since we deposited 1 ETH and 2 ETH respectively.
Next, there is a require statement that checks whether bal is greater than 0. This means that if I did not deposit any money in the contract via deposit(), then my bal will be 0.
After that, the call function is invoked (note SWC-104, unchecked call return value). This call function intends to send the value of bal from the contract to the msg.sender. If I call withdraw, the contract attempts to send 1 ETH from the contract back to me. The call function then checks whether the call function passes via sent.
Lastly, the balance is updated to 0.
Nothing seems wrong yet, right? It’s just like depositing money in the bank. When I deposit $100, my bank balance will increase by $100. When I withdraw $100, then my balance will decrease by $100. If I have $0 in my bank account, I cannot call withdraw.
Now, let’s imagine that the bank only updates the bank balance at the end of the day. Imagine I have $100 in my bank and I want to withdraw $100. The bank checks that my balance is greater or equal to $100, and proceeds with the withdrawal. After that, the bank processes my withdrawal and gives me the $100. However, the bank does not update my balance because it’s not the end of the day yet. How can I abuse this loophole?
I can try to withdraw $100 again. The bank checks whether my balance is greater or equal to $100, and proceeds with the withdrawal. After that, the bank processes my withdrawal and gives me another $100. My bank account is still not updated.
I can’t attempt to withdraw $1000, because the bank checks whether my balance is greater or equal to $100 at the start. Since withdrawing $1000 is greater than $100, the bank rejects my withdrawal. However, I can still continue to withdraw $100 until I have drained the bank’s funds.
This attack is known as reentrancy because I have re-entered the contract and ask for a withdrawal again without my updated account balance.
Going back to the code, let’s see how a contract can get exploited through reentrancy. When the call function is invoked, the fallback function of the receiving contract is called instead since the receiving contract does not have a call function.
What is a fallback function?
A fallback function is executed if none of the other functions match the intended function calls, in this case, the call function. A fallback function is like this analogy:
Imagine you are the owner of a restaurant and you have a special menu with dishes that your customers can order. You have a system in place where your waiters take the customers' orders and then communicate them to the kitchen, where the chefs prepare the dishes and send them out to the tables.
Now, imagine a customer who comes into your restaurant and wants to order a dish that isn't on the menu. Your waiters don't know how to handle this situation, and the kitchen doesn't know how to prepare the dish, so the customer's order is rejected.
In this scenario, the restaurant's system is similar to a smart contract in Solidity. Just like the waiters can only take orders for dishes that are on the menu, a smart contract can only execute functions that are defined in its code. If a message is sent to the contract that doesn't match any of the defined function signatures, the transaction will revert.
To handle unexpected messages, a Solidity contract can define a fallback function. The fallback function is like a chef in the kitchen who can handle special requests and prepare dishes that aren't on the menu. When a message is sent to the contract that doesn't match any function signatures, the Solidity runtime will automatically invoke the fallback function if one is defined.
With that in mind, what if an external contract has a malicious fallback function? Take a look at this attack contract and focus your attention on the fallback function:
The attack contract attempts to attack the DepositFunds contract shown at the beginning. Firstly, the attack creates a DepositFunds datatype and set it to depositFunds (the contract). Next, the attack contract writes a fallback function that checks whether the address of the depositFunds has more than 1 ether and call withdraw. Lastly, the attack contract writes an attack function. It checks whether the msg.value sent is more than or equal to 1 ether, calls the deposit function in the depositFunds contract and then immediately calls withdraw(). Here’s the whole overview of how the attack work
The attacker calls attack() in the Attack contract and passes in 1 ether
The attack() contract checks whether the amount passed into the contract is more than or equal to 1 ether. In this case, it is equal, so the require statement passes
The attack contract then call the depositFunds contract and call deposit() with a value of 1 ether
balances[msg.sender] in the depositFunds is increased to 1 ether. (msg.sender in this case is the attack contract, not an externally owned address)
Next, the attack contract calls withdraw in the depositFunds contract
Withdraw() in depositFunds contract creates an integer variable named bal and sets it to balances[msg.sender], in this case, 1 ether.
Withdraw() checks whether bal is greater than 0
withdraw() uses the call function and passes in the bal balance, 1 ether. This 1 ether is then transferred to the msg.sender, which is the attack contract.
Now, since the attack contract has no function called call, the fallback function is invoked instead. The fallback function checks if the balance of the depositFunds contract is greater than 1 ether, then calls withdraw again.
Withdraw() in depositFunds contract creates an integer variable named bal and sets it to balances[msg.sender], in this case, 1 ether.
Withdraw() checks whether bal is greater than 0. Since the balance has not been updated yet, bal is still greater than 0
withdraw() uses the call function and passes in the bal balance, 1 ether. This 1 ether is then transferred to the msg.sender, which is the attack contract.
The fallback function of the attack contract is called again. it checks whether the contract has a balance of 1 ether or more, then calls withdraw again.
This withdraw loop happens until the contract has less than 1 ether. The fallback function then skips the withdraw function, and now the balance is updated in the depositFunds function.
However, all the contract balance has already been drained
This diagram is a summary of how reentrancy works,
How to remediate a potential Reentrancy
The most effective way to prevent reentrancy is to have a Checks-Effects-Interactions pattern.
Checks
Is the input acceptable? If not, then either "Fail Early and fail hard" or return false. As a default approach, fail hard ensures the entire transaction reverts including all functions in all contracts that were involved. This relieves the caller of the responsibility for dealing with the "failed" case, which saves gas as well.
require(someCondition);
Effects
Optimistically update the contract to a new valid state assuming that interactions will be successful. This protects the contract from reentrancy. For emphasis, it must be a completely valid new state.
balance[msg.sender] = 0;
Interactions
.send()
, .transfer()
and .call()
as well as function
calls to "untrusted" contracts. If the called contract "fails hard" with require()
or revert()
then there is nothing to check. The whole thing will revert if something goes wrong. This is one of the advantages of using transfer()
to send ETH. If the contract returns(bool success)
then you need to check for the false
condition and decide what to do. This will always involve undoing the "optimistic accounting". If you want to carry on, then manually unwind it.
if(!msg.sender.send(amount)) {
balances[msg.sender] += amount; // because we zeroed it out in step 2
If you do not want to carry on, you can revert the whole thing and return to the original state.
require(msg.sender.send(amount)); // this is what `transfer` does
Use it also for calling contract functions. If they return something, check it.
require(otherContract.doSomething()); // where it returns a bool
or
bool success = otherContract.doSomething();
if(!success) {
// time to undo the "optimistic" accounting (effects) if we want to continue
In the deposit contract, the balances mapping should be updated before the transfer. The new DepositFunds contract looks like this:
Now, when the attack function calls withdraw, the balance value is updated to zero first. This is what happens instead:
The attacker calls attack() in the Attack contract and passes in 1 ether
The attack() contract checks whether the amount passed into the contract is more than or equal to 1 ether. In this case, it is equal, so the require statement passes
The attack contract then call the depositFunds contract and call deposit() with a value of 1 ether
balances[msg.sender] in the depositFunds is increased to 1 ether. (msg.sender in this case is the attack contract, not an externally owned address)
Next, the attack contract calls withdraw in the depositFunds contract
Withdraw() in depositFunds contract creates an integer variable named bal and sets it to balances[msg.sender], in this case, 1 ether.
Withdraw() checks whether bal is greater than 0
*** Withdraw() changes the balance to 0
withdraw() uses the call function and passes in the bal balance, 1 ether. This 1 ether is then transferred to the msg.sender, which is the attack contract.
Since the attack contract has no function called call, the fallback function is invoked instead. The fallback function checks if the balance of the depositFunds contract is greater than 1 ether, then calls withdraw again.
*** Withdraw() in depositFunds contract creates an integer variable named bal and sets it to balances[msg.sender]. *** However, the balance is set to zero. The require check in the next line where bal > 0 will fail. As such, the whole function execution will fail because functions in solidity are executed atomically in nature.
Another way to prevent reentrancy is to use a reentrancy modifier guard, which looks something like this.
Also, OpenZeppellin has its own ReentrancyGuard so that we don’t need to write the reentrancyGuard ourselves. The code is in the link below.
https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/security/ReentrancyGuard.sol
The reentrancyGuard works in a similar way as setting the balance to zero. Before anything, the keyword locked is equal to true. Only when the whole function is completed, then locked changes to false. This way, the attacking contract cannot enter the withdraw function again because locked is still true, and the modifier requires the locked value to be false
Summary
Always check the CEI pattern (checks, effects, interaction)
Make sure important functions have a reentrancy guard for extra protection
References
https://swcregistry.io/docs/SWC-107
https://hackernoon.com/hack-solidity-reentrancy-attack
https://www.alchemy.com/overviews/reentrancy-attack-solidity
https://ethereum.stackexchange.com/questions/66456/design-pattern-checks-effects-interactions-pattern
https://docs.openzeppelin.com/contracts/4.x/api/security