Summary
Users are able to bridge as many nfts as they want without having any nfts. The user just needs to call bridgeNftWithData()
with the appropriate parameters.
Vulnerability Details
The bridgeNftWithData()
function on KittyBridge
does not have any check to confirm if the user is the owner of the nft they are trying to bridge or if the nft exists. The function only checks if the destinationChainSelector is on the allowed and if the receiver is a zero address. This is an external function, it can be called by anyone. The user or attacker can bridge non existent nft on any chain on the Allowlist.
function bridgeNftWithData(uint64 _destinationChainSelector, address _receiver, bytes memory _data)
external
onlyAllowlistedDestinationChain(_destinationChainSelector)
validateReceiver(_receiver)
returns (bytes32 messageId)
{
// @audit attacker can mint as many as possible nfts
// Create an EVM2AnyMessage struct in memory with necessary information for sending a cross-chain message
Client.EVM2AnyMessage memory evm2AnyMessage = _buildCCIPMessage(_receiver, _data, address(s_linkToken));
// Initialize a router client instance to interact with cross-chain router
IRouterClient router = IRouterClient(this.getRouter());
// Get the fee required to send the CCIP message
uint256 fees = router.getFee(_destinationChainSelector, evm2AnyMessage);
if (fees > s_linkToken.balanceOf(address(this))) {
revert KittyBridge__NotEnoughBalance(s_linkToken.balanceOf(address(this)), fees);
}
// @audit link token needs to approve router
// approve the Router to transfer LINK tokens on contract's behalf. It will spend the fees in LINK
+ s_linkToken.approve(address(router), fees);
messageId = router.ccipSend(_destinationChainSelector, evm2AnyMessage);
emit MessageSent(messageId, _destinationChainSelector, _receiver, _data, address(s_linkToken), fees);
return messageId;
}
Impact
Financial Loss: Malicious actors can exploit this vulnerability to bridge and steal valuable NFTs they do not own, leading to significant financial losses for legitimate NFT owners.
Trust and Reputation Damage: If this vulnerability is discovered and exploited, it can severely undermine the trust and reputation of the project, potentially causing a loss of user confidence and adoption.
.
POC
In the POC below the attacker was able to bridge unlimited nfts when the attacker currently has no nfts on the source chain.
NB: This test only worked because i have fixed the already identified approval issue with the bridgeNftWithData()
function.
function test_bridgeUnlimitedNft() public {
address sender = makeAddr("sender20");
uint64 chainId = 16015286601757825753;
bytes memory data = abi.encode(
makeAddr("catOwner"),
"meowdy",
"ragdoll",
"ipfs://QmbxwGgBGrNdXPm84kqYskmcMT3jrzBN8LzQjixvkz4c62",
block.timestamp,
partnerA
);
vm.prank(address(kittyConnectOwner));
kittyBridge.allowlistDestinationChain(chainId, true);
vm.startPrank(sender);
for (uint256 i; i < 10; i++) {
kittyBridge.bridgeNftWithData(chainId, sender, data);
}
vm.stopPrank();
}
Tools Used
VS Code, Foundry and Manual Review
Recommendations
Add the tokenId to the bridgeNftWithData()
parameters and check if the caller is the owner of the token
function bridgeNftWithData(uint64 _destinationChainSelector, address _receiver, bytes memory _data, uint256 tokenId)
external
onlyAllowlistedDestinationChain(_destinationChainSelector)
validateReceiver(_receiver)
returns (bytes32 messageId)
{
+ address catOwner = _ownerOf(tokenId);
+ require(msg.sender == catOwner);
// Create an EVM2AnyMessage struct in memory with necessary information for sending a cross-chain message
Client.EVM2AnyMessage memory evm2AnyMessage = _buildCCIPMessage(_receiver, _data, address(s_linkToken));
// Initialize a router client instance to interact with cross-chain router
IRouterClient router = IRouterClient(this.getRouter());
// Get the fee required to send the CCIP message
uint256 fees = router.getFee(_destinationChainSelector, evm2AnyMessage);
if (fees > s_linkToken.balanceOf(address(this))) {
revert KittyBridge__NotEnoughBalance(s_linkToken.balanceOf(address(this)), fees);
}
// @audit link token needs to approve router
// approve the Router to transfer LINK tokens on contract's behalf. It will spend the fees in LINK
+ s_linkToken.approve(address(router), fees);
messageId = router.ccipSend(_destinationChainSelector, evm2AnyMessage);
emit MessageSent(messageId, _destinationChainSelector, _receiver, _data, address(s_linkToken), fees);
return messageId;
}