How to find, exploit, and mitigate reentrancy vulnerabilities in smart contracts

Gershon Ballas
Ginger Security
Published in
5 min readNov 18, 2022

--

Reentrancy attacks are some of the most lethal vulnerabilities known to the cryptoverse. Heck, the original DAO hack was a reentrancy exploitation (link).

And unfortunately (or fortunately if you’re a bounty hunter / black-hat), despite years of them being known and understood, they still consistently happen in the wild. Just last week (Nov 10, 2022) there was an attack on DFX Finance, resulting in ~$5m in lost assets (tweet, transaction).

But fear not! We’ve got you covered. Today we’re gonna learn about how to find, exploit, and mitigate reentrancy attacks, regardless of blockchain/language. 🧑‍💻

Before we begin… what on earth is “reentrancy”?

“Reentrancy” simply means causing a function to call (or re-enter) itself.

Wait… so you mean recursion?

Well, technically yes, it is recursion.

But in the context of blockchain security, it means:

  • User calls a function in contract A
  • That function in contract A does a callback to a contract controlled by the user (contract B)
  • Contract B calls contract A again (it re-enters it)
  • Contract A may call contract B again, and vice-versa, until one of the contracts will stop
Reentrancy diagram

In that case, contract A is said to be “re-entrant”.

Hmm.. so why is that insecure? Lots of contracts do callbacks!

That’s true. And there’s nothing wrong with that.

In fact, every time you transfer an NFT (ERC-721), it does a callback to the receiving address (which of course, may then re-enter the calling function by calling it again).

So where is the problem here?

Let’s look at a simple Solidity example:

interface IPointReceiver {
function onPointReceived() public;
}

contract PointCounter {

// Number of points each participant has
mapping(addres => uint256) public points;

// Function that allows first time participants to get a point for free
function getFirstPointForFree() public {

// CHECK that sender didn't already receive their free point
require(points[msg.sender] == 0, "Sender is not a first time participant");

// INTERACT - Notify participant that they received a point via callback
IPointReceiver(msg.sender).onPointReceived();

// EFFECT - Give them that point
points[msg.sender] += 1;
}
}

Looks good right? But what if I as the point receiver implement a contract like this:

import "../path/to/PointCounter.sol";

contract ExploitContract is IPointReceiver {
PointCounter public constant TARGET = PointCounter(0x777788889999AaAAbBbbCcccddDdeeeEfFFfCcCc);

function exploit() public {
TARGET.getFirstPointForFree();
}

function onPointReceived() public {
// Keep re-entering until we get at least 5 free points
if (TARGET.points[address(this)] >= 5) {
return;
}

// Re-enter getFirstPointForFree() func
TARGET.getFirstPointForFree();
}
}

If you run the transaction in your head, beginning with ExploitContract’s exploit() function, you will see that:

  • exploit() calls getFirstPointForFree()
  • getFirstPointForFree()’s CHECK is successfully passed
  • getFirstPointForFree() calls onPointReceived()
  • onPointReceived() calls getFirstPointForFree() again
  • getFirstPointForFree()’s CHECK is successfully passed again! (since the EFFECT line wasn’t called yet)
  • This happens 5 times and our exploit contract gets 5 free points!

Okay, so something f*cked up that shouldn’t have happened just did. But how do we formalize and generalize our understanding of it? How do we avoid such attacks?

Mitigation #1: CHECKS — EFFECTS — INTERACTIONS

If you look at the getFirstPointForFree() again you will see that it’s got three parts labeled:

  • CHECKS — Checking that conditions are valid
  • INTERACTIONS — Interacting with other smart contracts, which may be controlled by a malicious actor (via callback)
  • EFFECTS — Changing the contract’s state (updating the points mapping)

Most functions would have parts of them that could be categorized into one of those three.

Let’s list a couple of axioms:

  • The EFFECTS part updates the state of the contract
  • The CHECKS part behaves in accordance to the contract state, and will only behave correctly if the state is up to date
  • Whenever there is an INTERACTIONS part, the function may be re-entered at that point

Therefore:

  • If the INTERACTIONS part happens before the EFFECTS part, it may be used to re-enter the function, and get to the CHECKS without having updated the contract state
  • This could result in the CHECKS part allowing transactions that it shouldn’t (as it did in the example above — allowing us to claim yet another free point)

This is exactly what happened in our example contract. It did:

CHECKS — INTERACTIONS — EFFECTS

Instead of:

CHECKS — EFFECTS — INTERACTIONS

And if you’re serious about blockchain security, you’ll wanna tattoo that so you don’t forget, like this guy did:

CHECKS-EFFECTS-INTERACTIONS

If you follow the checks-effects-interactions pattern, you will be safe from reentrancy attacks 100% of the time.

However, some contracts, like Uniswap and other AMM’s, do not follow the checks-effects-interactions pattern on purpose (in order to allow flash-loans, we won’t get into that here).

For that, there’s the reentrancy mutex.

Mitigation #2: Reentrancy mutex to the rescue

If there’s no conceivable benign circumstance under which your contract should be reentrant, you might as well forbid that altogether. You can simply do that by using a reentrancy mutex like so:

contract PointCounter {
uint256 private constant _NOT_ENTERED = 1;
uint256 private constant _ENTERED = 2;

uint256 private _status;

// Number of points each participant has
mapping(addres => uint256) public points;

constructor() {
_status = _NOT_ENTERED;
}

modifier nonReentrant() {
require(_status == _NOT_ENTERED, "Reentrancy blocked.");

_status = _ENTERED;

_;

_status = _NOT_ENTERED;
}

// Function that allows first time participants to get a point for free
function getFirstPointForFree() public {
// CHECK that sender didn't already receive their free point
require(points[msg.sender] == 0, "Sender is not a first time participant");

// INTERACT - Notify participant that they received a point via callback
IPointReceiver(msg.sender).onPointReceived();

// EFFECT - Give them that point
points[msg.sender] += 1;
}

}

If you try the same exploit we did above, you’ll see that this time the reentrancy attack will fail with the message “Reentrancy blocked.”

Instead of implementing the nonReentrant() modifier by yourself, you can simply import OpenZeppelin’s ReentrancyGuard.sol.

Conclusion

Reentrancy attacks are rather simple once you get the gist of it. The most important thing is to USE THE CHECKS-EFFECTS-INTERACTIONS PATTERN! (Unless you really know what you’re doing and there’s a good reason for you not to use it, like Uniswap with flash-loans). Either way, as an extra precautionary step, use a reentrancy guard modifier if there’s no reason that your contract/function should be reentrant.

In next week’s article we will look at an example of a reentrancy attack on StarkNet, a non-EVM blockchain. Stay tuned. 👀

--

--