NFTBridge
60,000 USDC
View results
Submission Details
Severity: medium
Valid

A malicious actor can stuck other unsuspecting users' tokens forever

Summary

A malicious actor can stuck other unsuspecting users' tokens forever.

The problem stems from the fact that there's a reentrancy vulnerability in the Escrow::_withdrawFromEscrow() function that can be used on any collection to trap unsuspecting users to lock their tokens permanently if they use the bridge.

Vulnerability Details

The attack can be performed on any collection so let's take Everai for example.

Let's say that I've bridged my NFT with tokenId from L1 -> L2 and vice versa. Currently, the token is sitting in the L1 escrow and I have it on L2.

Now here's how the attack would go.

I bridge my token from L2 -> L1 where I specify the owner_l1 address i.e the to address that will receive the NFT on L1 to be an address to a contract I control with the following functionality:

contract Attacker {
address bridge = address(L1Bridge);
function withdrawFromBridge(uint256 calldata request) {
bridge.withdrawTokens(request);
}
function onERC721Received(address operator, address from, uint256 tokenId, bytes memory data) {
bridge.depositTokens(someSalt, collectionL1, ownerL2, tokenId);
}
}

The code above illustrates the basic functionality of the attacker contract.

Now, I'll call the withdrawFromBridge from my contract which will call the withdrawTokens function on the bridge contract and we'll enter the following functionality:

for (uint256 i = 0; i < req.tokenIds.length; i++) {
uint256 id = req.tokenIds[i];
bool wasEscrowed = _withdrawFromEscrow(ctype, collectionL1, req.ownerL1, id);
if (!wasEscrowed) {
IERC721Bridgeable(collectionL1).mintFromBridge(req.ownerL1, id);
}

Let's see what _withdrawFromEscrow does:

function _withdrawFromEscrow(
CollectionType collectionType,
address collection,
address to,
uint256 id
)
internal
returns (bool)
{
if (!_isEscrowed(collection, id)) {
return false;
}
address from = address(this);
if (collectionType == CollectionType.ERC721) {
//transfer the token from this contract to `to`
IERC721(collection).safeTransferFrom(from, to, id); <--- we reenter from here
} else {
IERC1155(collection).safeTransferFrom(from, to, id, 1, "");
}
<--- the rest of the function after the reentrancy will be executed from here
//set the depositor as address(0)
_escrow[collection][id] = address(0x0);
return true;
}

Notice how setting the depositor to address(0) with _escrow[collection][id] = address(0x0); is made after the transfer.

Since our token is currently sitting in the escrow on L1, the if is skipped, and the token is transferred with this IERC721(collection).safeTransferFrom(from, to, id);

The important thing to know is that safeTransferFrom will call the onERC721Received function on the receiving address if the address is a contract. Here's the ERC721 implementation:

function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory data) public virtual {
transferFrom(from, to, tokenId);
ERC721Utils.checkOnERC721Received(_msgSender(), from, to, tokenId, data);
}

As you can see, the tokenId is transferred then checkOnERC721Received is called and this is the functionality of the function:

function checkOnERC721Received(
address operator,
address from,
address to,
uint256 tokenId,
bytes memory data
) internal {
if (to.code.length > 0) {
---> try IERC721Receiver(to).onERC721Received(operator, from, tokenId, data) returns (bytes4 retval) {
if (retval != IERC721Receiver.onERC721Received.selector) {
// Token rejected
revert IERC721Errors.ERC721InvalidReceiver(to);
}
} catch (bytes memory reason) {
if (reason.length == 0) {
// non-IERC721Receiver implementer
revert IERC721Errors.ERC721InvalidReceiver(to);
} else {
/// @solidity memory-safe-assembly
assembly {
revert(add(32, reason), mload(reason))
}
}
}
}

So as confirmed with the code above we established that the token will be transferred and then the onERC721Received function will be called on the receiving contract.

Which, in our case, will deposit the token again to the bridge and bridge it from L1 --> L2. During this _escrow[collection][id] will be set to our contract's address, however, as soon as the onERC721Received is executed i.e the deposit function on the bridge, we'll return to execution on the _withdrawFromEscrow function and we'll execute the last line:

_escrow[collection][id] = address(0x0);

Let's summarize what we did:

  • we had tokenId in L1 escrow, and we owned in on L2

  • we bridged it from L1 -> L2, withdrew and with reentering we deposited it again from L1 to L2

  • execution continues in the _withdrawFromEscrow function and sets _escrow[collection][id] = address(0x0); although this mapping should point to the depositor(us)

Now the token is in the L1 escrow with depositor set as address(0) and we have the token on L2.

If we were to sell this token to any unsuspecting user and that user tries to bridge it, the token will be locked forever.

Let's see why.

He'll bridge it from L2 -> L1, and will call withdrawTokens on L1 which brings us to this functionality again:

for (uint256 i = 0; i < req.tokenIds.length; i++) {
uint256 id = req.tokenIds[i];
bool wasEscrowed = _withdrawFromEscrow(ctype, collectionL1, req.ownerL1, id);
if (!wasEscrowed) {
IERC721Bridgeable(collectionL1).mintFromBridge(req.ownerL1, id);
}

When we enter the _withdrawFromEscrow function wasEscrowed will return false:

function _withdrawFromEscrow(
CollectionType collectionType,
address collection,
address to,
uint256 id
)
internal
returns (bool)
{
//if not escrowed, return false
if (!_isEscrowed(collection, id)) {
return false;
}
//rest of the function

since the depositor is set to address(0) and this is what the _isEscrowed function is checking:

function _isEscrowed(address collection, uint256 id) internal view returns (bool)
{
return _escrow[collection][id] > address(0x0);
}

and withdrawTokens will continue with this:

if (!wasEscrowed) {
IERC721Bridgeable(collectionL1).mintFromBridge(req.ownerL1, id);
}

which will revert since it'll try to mint a tokenId that already exists(it's currently in the bridge contract). Or it'll revert if the the collection was a native L1 collection and mintFromBridge function doesn't exist.

Now the unsuspecting user's tokens are permanently stuck on L1 and L2, and there's nothing he can do to unlock them.

This attack doesn't cost anything other than some bridging fees and can be done for a big % of the total supply of any collection. For example, if that's done on most of the Everai NFTs it's almost guaranteed that some user will try to bridge his NFT and get his tokens stuck.

Impact

User's NFTs are lost forever which greatly damages the reputation of the protocol.

Tools Used

Manual review

Recommendations

One option is to use transferFrom instead of safeTransferFrom. However, an even better option is to just move _escrow[collection][id] = address(0x0); above in the function before any interactions. Like that:

function _withdrawFromEscrow(
CollectionType collectionType,
address collection,
address to,
uint256 id
)
internal
returns (bool)
{
if (!_isEscrowed(collection, id)) {
return false;
}
address from = address(this);
_escrow[collection][id] = address(0x0);
if (collectionType == CollectionType.ERC721) {
IERC721(collection).safeTransferFrom(from, to, id);
} else {
IERC1155(collection).safeTransferFrom(from, to, id, 1, "");
}
return true;
}
Updates

Lead Judging Commences

n0kto Lead Judge 10 months ago
Submission Judgement Published
Validated
Assigned finding tags:

finding-withdraw-reentrancy-creates-unbridgeable-tokens

Impact: - NFT already bridged won’t be bridgeable anymore without being stuck. Likelyhood: Low. - Attackers will corrupt their own tokens, deploying a risky contract interacting with an upgradable proxy. They have to buy and sell them without real benefits, except being mean. Some really specific and rare scenario can also trigger that bug.

Appeal created

0xasen Submitter
10 months ago
0xasen Submitter
10 months ago
n0kto Lead Judge
9 months ago
n0kto Lead Judge 9 months ago
Submission Judgement Published
Validated
Assigned finding tags:

finding-withdraw-reentrancy-creates-unbridgeable-tokens

Impact: - NFT already bridged won’t be bridgeable anymore without being stuck. Likelyhood: Low. - Attackers will corrupt their own tokens, deploying a risky contract interacting with an upgradable proxy. They have to buy and sell them without real benefits, except being mean. Some really specific and rare scenario can also trigger that bug.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.