OneSwap Series 8 - The Evil Has a Name: Re-entrancy

In the development of smart contracts, an important issue that needs to be considered is the probability of re-entrancy attacks. One of the most typical cases is the attack suffered by the DAO project in 2016, which resulted in the theft of about 3.6 million ETH and, eventually, the hard fork of Ethereum from Ethereum Classic.

The principle of a re-entrancy attack is simple: smart contracts on Ethereum can call each other. Assume that Contract B, which is controlled by a hacker, is called during the execution of Contract A and the call of Contract A can be re-entered during the execution of Contract B. If Contract A does not update its internal state before calling the external contract, it may be misused by Contract B with assets being stolen.

Take the following Contract C as an example:

contract C{
function deposit() external{
....
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(msg.sender.call.value(amount)());
balances[msg.sender] = 0;
}
}

Contract C provides deposit and withdraw interfaces for ETH. Users can deposit a certain amount of ETH to the contract to use other functions provided by it, such as obtaining interest and voting. When they need to exit, they can call the withdraw interface to retrieve the deposited assets.

We can see that the withdrawmethod of Contract C first reads the total amount of funds deposited by the current trader, and then calls the callmethod to transfer ETH of this amount to the trader's account. But since msg.sendermay be a contract account, the contract's fallbackmethod is triggered on reception of ETH, and Contract C's withdrawmethod is called again in the fallbackmethod. When re-entering the withdrawmethod, Contract C will once again read the total amount of funds deposited by the trader, which is exactly the same as last time. Therefore, the malicious contract receives the same amount of ETH again and re-enters the withdrawmethod of Contract C until its balance is exhausted.

Another concern is the cross-function re-entrancy attack. When some internal states are shared between the two methods of a contract X, another contract Y is called by one method, and then Y re-enters another method of X, causing similar consequences.

It’s easy to prevent re-entrancy attacks: to apply the lock mechanism or to follow the Checks-effects-interactions mode when coding.

The lock mechanism adds a state variable inside the contract, which is used to identify whether the contract is re-entered. When a key method of the contract is called, it firstly verifies whether the contract is already in a locked state. If so, it reverts the transaction, If not, the lock will be locked, and will not be opened until the current method is done. Therefore, when the external contract re-enters the contract, the contract is already locked and thus immune to attacks.

Again, we take Contract C as an example. With the lock mechanism applied, the solution is as follows: Add the unlockedvariable to the contract, which needs to be initialized to true when the contract is constructed. In addition, the modifier onlyUnlockedis added to the withdrawmethod, and assets can be withdrawn only when the contract is not in a re-entrant state.

contract C{
bool unlocked;
modifier onlyUnlocked{
require(unlocked,"contract is already locked");
unlocked = false;
_;
unlocked = true;
}
function deposit() external{
....
}
function withdraw() external onlyUnlocked{
uint256 amount = balances[msg.sender];
require(msg.sender.call.value(amount)());
balances[msg.sender] = 0;
}
}

It is worth noting that in the above mechanism a lock variable unlockedis defined to identify whether the contract is unlocked, and a lockedvariable can also be defined to do the same job. The difference between the two is that the unlockedvariable needs to be set to true during construction, while the lockedvariable skips this step.

The Checks-effects-interactionsrefers to the pattern that developers should first check the internal state when coding, then change it, and finally interact with external contracts. Taking Contract C as an example, the contract in this mode is written as follows:

contract C{
function deposit() external{
....
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount != 0,"account balance is zero");
balances[msg.sender] = 0;
require(msg.sender.call.value(amount)());
}
}

When a user withdraws assets, contracts C firstly verifies whether the account balance is 0. If yes, the transaction is reverted. If not, the account balance is set to 0, and then execute the actual transfer logic. At this point, when the fallbackmethod of the malicious contract calls the withdrawmethod again, the attacker cannot withdraw more assets because the account balance has been cleared.

There is a case which requires no special consideration of re-entrancy attacks, that is, the contract does not save any internal state. In that case, even if the current contract is re-entered, no state will be changed as above mentioned, so severe consequences will never occur. This is the same case with the Routercontract in the Oneswap project, which is only responsible for some calculations and the call forwarding to the Paircontract. Even if the contract is re-entered during the call, it just repeats calculations. What really matters is how the Paircontract prevents re-entrancy attacks.

Compared to EOA (externally owned account), transfers to contract accounts are full of risks, mainly because the called contract code is uncontrollable. You might suggest that we should distinguish between EOA and contract accounts through the extcodesizecommand and then check whether the contract bytecode of the recipient account is 0: if yes, it is an EOA; otherwise it is a contract account. But in fact, even if the return value of the extcodesizeinstruction is 0, we cannot say for sure that this is not a contract account. The extcodesizeinstruction obtains the contract code size of the corresponding account at the moment of the current call, and there is no available runtime code when the contract is initialized, so the return value of the extcodesizeinstruction is also 0 at this time. If the return value of the extcodesizecommand is not 0, then the account must be a contract account; otherwise it does not suggest that the account is an EOA.

Summary

This article introduces the principles and defensive measures of re-entrancy attacks that smart contracts may face, and illustrates situations in which re-entrancy attacks may not cause serious damage, with the Router contract of the Oneswap project as an example. Nevertheless, developers need to stay vigilant and try their best to block all possible entrances of re-entrancy attacks using the lock mechanism or Checks-effects-interactions mode when coding.

Written by

A fully decentralized exchange protocol on Smart Contract, with permission-free token listing and automated market making.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store