Summary
Transfering escrowed NFTs in Ethereum is safe whereas minting new ones not
Vulnerability Details
In the Starknet bridge implementation, when a token is either escrowed or it has to be minted, it does not check for the ERC721 acceptance in case it is a contract.
#[l1_handler]
fn withdraw_auto_from_l1(
ref self: ContractState,
from_address: felt252,
req: Request
) {
...
let is_escrowed = !self.escrow.read((collection_l2, token_id)).is_zero();
if is_escrowed {
IERC721Dispatcher { contract_address: collection_l2 }
.transfer_from(from, to, token_id);
} else {
if (req.uris.len() != 0) {
let token_uri = req.uris[i];
IERC721BridgeableDispatcher { contract_address: collection_l2 }
.mint_from_bridge_uri(to, token_id, token_uri.clone());
} else {
IERC721BridgeableDispatcher { contract_address: collection_l2 }
.mint_from_bridge(to, token_id);
}
}
i += 1;
};
self.emit(WithdrawRequestCompleted {
hash: req.hash,
block_timestamp: starknet::info::get_block_timestamp(),
req_content: req
});
}
Internally, the transfer_from method does not check if the contract can handle NFTs and mint_from_bridge, mint_from_bridge_uri neither do.
On the other hand, in the Ethereum implementation, the escrowed transfers are 'safe' which checks for the acceptance of NFTs in case the receiver is a contract but minting new ones don't.
function withdrawTokens(
uint256[] calldata request
)
external
payable
returns (address)
{
...
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);
}
}
emit WithdrawRequestCompleted(req.hash, block.timestamp, request);
return collectionL1;
}
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) {
IERC721(collection).safeTransferFrom(from, to, id);
} else {
IERC1155(collection).safeTransferFrom(from, to, id, 1, "");
}
_escrow[collection][id] = address(0x0);
return true;
}
This only method being safe can make incompatible for some contracts, because a contract will be able to withdraw tokens that are newly minted without implementing the ERC721 receiver interface, but will unable to withdraw tokens that already exist.
Impact
Medium
Imagine the following situation:
A user con Starknet sends an NFT to an Ethereum contract
The contract does not implement the ERC721Receiver interface but can still withdraw the NFT
It sends it back to Starknet
The user on Starknet sends back to the Ethereum contract
The NFT gets stuck forever because the contract does not implement the interface to receive the NFT
Tools Used
Manual review
Recommendations
Making all the transfering/minting methods similiar with the acceptance method will lead to a more consistent protocol. My recommendation is to set all the methods to not check for acceptance because there is only a single situation where it checks for acceptance, all the other ones don't.
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) {
- IERC721(collection).safeTransferFrom(from, to, id);
+ IERC721(collection).transferFrom(from, to, id);
} else {
// TODO:
// Check here if the token supply is currently 0.
IERC1155(collection).safeTransferFrom(from, to, id, 1, "");
}
_escrow[collection][id] = address(0x0);
return true;
}