A cross-chain reentrancy attack is an exploit that targets smart contract function calls across different chains, and can lead to loss of funds in the smart contract. Learn more about this vulnerability with examples to ensure your code doesn’t leave open doors to attackers.

The vulnerability

Events emitted at inappropriate locations in code can create cross-chain reentrancy vulnerabilities. Such an event could be used to trigger a call on a different chain while still incomplete on the original one. You can find example code to run in your environment here.

Example contract

This is an example of a vulnerable contract: it can be deployed on multiple chains. It allows minting from only one designated chain while enabling transfer tokens between chains. This means only one token with the same tokenId on multiple chains should exist.

By calling the crossChainTransfer function, the user can transfer tokens. This burns the token on the source chain. The action emits a message relayed by a validator off-chain. These validators then call the function on the destination chain, minting a token with a specified tokenId.

Attack example

The vulnerability is in the minting process. There is an external call when minting via _safeMint to confirm the possibility of a lockout token. Furthermore, tokenIds++; follows after the _safeMint function call.

An attacker can exploit this by doing the following:

While in the external call, i.e. in the onERC721Received function, they can call the crossChainTransfer function and call mint again.

This creates a situation where tokenIds++; executes twice, resulting in identical tokenId tokens existing on multiple chains.

Attacker contract

Wake code

In our testing environment, we have two different chains: chain1 and chain2. In the test_expected_usage function, we are sending tokens from chain1 to chain2 through a relay. The relay captures events emitted on chain1 and forwards those events with messages to the chain2. This call is only allowed by the validator, so we are sending it via the validator of each chain using from_=validator_chain1 and so on.

Wake output

We could create the same tokenId token on two chains.

Prevention

Checks-effects-interactions

This prevention method ensures state changes are completed before external calls.

Reentrancy-guard

Reentrancy guards provide additional protection against this type of attack.

Post-external call verification

You can verify the value of tokenIds after the _safeMint function call completes and revert the transaction if tokenIds changed unexpectedly, however this approach adds complexity.

Conclusion

It is critical to be aware of external calls in functions, because these create doors for reentrancy attacks. Always consider what functions might be triggered during these external calls.

For more examples, read our Reentrancy Examples Github Repository. There are other types of reentrancy attacks and also protocol-specific reentrancies, and our blog features deep dives on some of these as well.