Summary
Setting use_withdraw_auto
to true in L2 -> L1 bridging will lead to permanent loss of NFTs.
Vulnerability Details
To bridge NFTs from L2 -> L1, users can use the deposit_tokens()
function of bridge.cairo
:
fn deposit_tokens(
ref self: ContractState,
salt: felt252,
collection_l2: ContractAddress,
owner_l1: EthAddress,
token_ids: Span<u256>,
use_withdraw_auto: bool,
use_deposit_burn_auto: bool,
) {
ensure_is_enabled(@self);
assert(!self.bridge_l1_address.read().is_zero(), 'Bridge is not open');
// is not giving approval to the bridge.
let from = starknet
assert(_is_white_listed(@self, collection_l2), 'Collection not whitelisted');
let ctype = CollectionType::ERC721;
let erc721_metadata = erc721_metadata(collection_l2, Option::Some(token_ids))
let (name, symbol, base_uri, uris) = match erc721_metadata {
Option::Some(data) => (data.name, data.symbol, data.base_uri, data.uris),
Option::None => ("", "", "", array![].span())
}
escrow_deposit_tokens(ref self, collection_l2, from, token_ids);
let collection_l1 = self.l2_to_l1_addresses.read(collection_l2);
let req = Request {
@=> header: compute_request_header_v1(ctype, use_deposit_burn_auto, use_withdraw_auto),
hash: compute_request_hash(salt, collection_l2, owner_l1, token_ids),
collection_l1,
collection_l2,
owner_l1,
owner_l2: from,
name,
symbol,
base_uri,
ids: token_ids,
values: array![].span(),
uris,
new_owners: array![].span(),
}
let mut buf: Array<felt252> = array![];
req.serialize(ref buf);
starknet::send_message_to_l1_syscall(
self.bridge_l1_address.read().into(),
buf.span(),
)
.unwrap_syscall();
self.emit(DepositRequestInitiated {
hash: req.hash,
block_timestamp: starknet::info::get_block_timestamp(),
req_content: req
})
}
The use_withdraw_auto
parameter is user-controlled and can be set to either true or false by the user. As you can see, the value of this bool is not validated anywhere in the function and it is simply appended to the header
of the L2 to L1 Request
.
On the L1 bridge, the withdrawTokens()
function is used to withdraw the NFTs from the L2 -> L1 bridge request :
function withdrawTokens(
uint256[] calldata request
)
external
payable
returns (address)
{
if (!_enabled) {
revert BridgeNotEnabledError();
}
uint256 header = request[0];
@=> if (Protocol.canUseWithdrawAuto(header)) {
revert NotSupportedYetError();
} else {
_consumeMessageStarknet(_starknetCoreAddress, _starklaneL2Address, request);
}
Request memory req = Protocol.requestDeserialize(request, 0);
address collectionL1 = _verifyRequestAddresses(req.collectionL1, req.collectionL2);
CollectionType ctype = Protocol.collectionTypeFromHeader(header);
if (collectionL1 == address(0x0)) {
if (ctype == CollectionType.ERC721) {
collectionL1 = _deployERC721Bridgeable(
req.name,
req.symbol,
req.collectionL2,
req.hash
);
_whiteListCollection(collectionL1, true);
} else {
revert NotSupportedYetError();
}
}
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;
}
Here, you can see that the header
of the Request
is validated through the Protocol.canUseWithdrawAuto(header)
function, which checks the value of the use_withdraw_auto
bool that the user set in the L2 deposit_tokens()
function.
The withdraw auto functionality was removed from the function after a critical vulnerability was found in a previous audit. Hence, the function now reverts if use_withdraw_auto
is set to true. However, this will cause any request that was made with use_withdraw_auto
set to true to revert, and the NFTs that were supposed to be withdrawn from the L1 bridge to be stuck forever as there is no way to retrieve them back on L2 bridge.
Impact
Permanent loss of assets
Tools Used
Manual Review
Recommendations
Enforce use_withdraw_auto
to be set to false in bridge.cairo::deposit_tokens()
:
- header: compute_request_header_v1(ctype, use_deposit_burn_auto, use_withdraw_auto),
+ header: compute_request_header_v1(ctype, use_deposit_burn_auto, false),