Reentrancy Exploits: Contract States and Fallback Functions

Introduction

“Ethereum is a deterministic but practically unbounded state machine, consisting of a globally accessible singleton state and a virtual machine that applies changes to that state.” (ME 39). A state machine, formally, is “nothing more than a binary relation on a set”, where elements of the set are known as states; however, it may best be understood as an “abstract model of step-by-step processes” (MCS 167). On Ethereum specifically, state is one giant data structure known as a hexary Patricia Markle Trie and all of this information is stored on-chain using the root hash of the data structure. The EVM defines what transactions are valid state transitions, the formal specifications can be found in appendix H of the yellow paper. Valid transactions on the Ethereum blockchain are what contain changes therein to the one and only canonical state of the blockchain. What is very important to note, however, is that a single transaction can be of arbitrary and unbounded complexity given that it fits within a block. With a set of valid states, we can then express the transition of state as q ----> r, where q is the initial state and r is the new, valid state. Lastly, there is no going “in-between” q and r, failure to execute a transaction will cause it to revert back to q. With unbounded transaction complexity using smart contracts and cross-contract calls, some patterns lead to reentrant calls and can drain smart contracts of their funds in the worst cases.

Ethereum smart contracts can make different types of calls to other smart contracts. When your smart contract’s functions have public (can be called in more contexts) or external function state mutability, they are accessible to other contracts. In this image I declared a new contract named “unknowingContract” and a function named “funnyBusiness” that takes no arguments, but is publicly accessible, doesn’t read or write from the blockchain, and returns a string that says, “funny business”, located in memory.

An example of a cross-contract interaction
An example of a cross-contract interaction

Inside of our newly declared contract “access”, I declare a new contract type called unknownContract, make it public, and name it “addy”. The constructor takes a single argument upon deployment and is responsible for setting the value of this global variable to the correct address of the contract instance to be accessed. Then, I declared a new publicly visible function called “knockKnock” that returns a boolean variable. The body of the function contains a low-level call to the funnyBusiness function in the aforesaid instance of unknownContract. When we call “knockKnock”, the very first thing that happens is the low-level call from access to unknowingContract’s function “funnyBusiness”. The context of the execution, however, is unknowingContract. So, the function will return “funny business” inside of unknowingContract. After the function call finishes executing in unknowingContract, we continue in access with a check to ensure the call succeeded and then returns the bool inside this contract (which is always true or the transaction will just fail -- so yes, it’s redundant and I would not do this in production). This is an example of a valid entrant call by a smart contract since a contract called another contract’s function.

Reentering Contracts via Fallback Functions

Victim Contract
Victim Contract
Attacking Contract
Attacking Contract

As I mentioned earlier, it is possible for Ethereum’s smart contracts to be of practically unbounded complexity. The previous example was relatively simple in its execution path: call function in B => which calls a function in A’s execution context => and then finishes with the execution context of B. We are going to follow a new example which complicates this series of events. For this example, we will be focusing on the withdraw function. Withdraw takes a singular argument: an unsigned integer named “_amount” and it has public function state mutability. The first line initiates a conditional that states: if the caller of this function was accredited with depositing ether into the smart contract (stored in a hashmap), then they can have the amount transferred to them via a low-level function call. Subsequently, the balance of the caller of this function will be updated to reflect the withdrawal amount. This pattern for withdrawing funds is really dangerous, as it turns out. 

The reason this pattern is so dangerous is because of how state was handled in the execution of the withdraw function. The only check performed at the start of the contract was such that there exists a stored unsigned integer equal to the accredited amount at the address calling the function inside the hashmap. The transfer of funds through the low-level transfer function is the first change in state, and only then do we subtract _amount from the value stored in the hashmap -- which is the next change in state. Let’s drive a metaphorical wedge in between these state changes! How? Cross-contract calls and a fallback function! In order to dynamically invoke functions, the EVM uses something called a function selector. It takes the first 4 bytes of the keccack256 hash of the function name and its argument types. This is known as the function signature. For example, in this case of the withdraw function in our example, it is bytes4(keccack256(abi.encodePacked(withdraw(uint)))). Anyway, if there is no function signature detected when calling a contract, some contracts have a special type of function that handles these cases. This is what we refer to as a fallback function.

If we look at contract B’s function called “triggerFallback”, this is what “attacks” contract A and drains its balance. First, we accredit ourselves with some Ether in the victim contract. Now, for the interesting part, through contact B, we call the withdraw function on contract A and take Ether into contract B. Why? When contract A sends contract B the Ether (with no function signature) before the mapping updates, we can trigger additional logic inside of the fallback function in contract B. This additional logic is a simple conditional that will check the balance of contract A, if the balance is greater than a small amount of Ether, withdraw again and emit a log of the value contained in the transaction that triggered this fallback function initially. Problematically, this will cause B to reenter contract A and call withdraw yet again, triggering the fallback function once more -- rinse and repeat until the contract is drained of its balance. So, the sequence of state updates allows contract B to make multiple, calls to contract A, after the initial call to withdraw and before A is able to continue executing the withdraw function and the internal states of the contract update can reflect the change of balance in the hashmap.

Fortunately, reentrancy exploits are really simple to prevent with best practices! The checks, effects, interactions pattern is important to know as a developer. Checks ensure sufficient restrictions of function inputs to prevent edge cases and unintended outputs, effects refer to changes in state, and interactions refer to cross-contract calls. In the example used above, the CEI pattern does not hold; interactions occurred before effects. However, to fix this, we can change the order of execution so that the hashmap’s value is appropriately altered prior to the transfer of ether (interaction) (see below).

Safely rewriting the withdraw function using the CEI pattern.
Safely rewriting the withdraw function using the CEI pattern.

In any case, there is a further option that I also implement in production: Open Zeppelin’s “NonReentrant” library. The library adds a modifier called “nonReentrant”. This modifier prevents contracts from calling themselves directly or indirectly after the initial function call. The other option is through the pattern used in OZ’s Pull Payment contract. The philosophy here is to simply transfer the funds to an external escrow contract, without running its code, so that reentrancy isn’t a possibility. Remember anon, don’t immortalize your mistakes on the blockchain and stay safe.

Works Cited

(ME) Antonopoulos, Andreas M. Mastering Ethereum. Stanford University Press, 2021. Pg. 167.

(MCS) Lehman, Leighton, Meyer. Mathematics for Computer Science, June 15th, 2017.

Pg. 39.

Subscribe to Crypdough.eth
Receive the latest updates directly to your inbox.
Verification
This entry has been permanently stored onchain and signed by its creator.