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.