Summary
The KittyConnect contract allows users to mint NFTs representing ownership of cats. The safeTransferFrom function is used to transfer the ownership of a given cat. But there is an issue in this function that leads to ownership inconsistencies.
Vulnerability Details
The KittyConnect::safeTransferFrom frunction transfers the cat ownership to a new owner:
function safeTransferFrom(address currCatOwner, address newOwner, uint256 tokenId, bytes memory data) public override onlyShopPartner {
require(_ownerOf(tokenId) == currCatOwner, "KittyConnect__NotKittyOwner");
require(getApproved(tokenId) == newOwner, "KittyConnect__NewOwnerNotApproved");
_updateOwnershipInfo(currCatOwner, newOwner, tokenId);
emit CatTransferredToNewOwner(currCatOwner, newOwner, tokenId);
_safeTransfer(currCatOwner, newOwner, tokenId, data);
}
The function calls the internal function _updateOwnershipInfo to update the information about the new and prevous owners:
function _updateOwnershipInfo(address currCatOwner, address newOwner, uint256 tokenId) internal {
s_catInfo[tokenId].prevOwner.push(currCatOwner);
s_catInfo[tokenId].idx = s_ownerToCatsTokenId[newOwner].length;
s_ownerToCatsTokenId[newOwner].push(tokenId);
}
Actually, this function adds the current owner to the prevOwner array of the cat and adds the tokenId to the newOwner address. But the function doesn't remove the tokenId from the current owner's collection of tokenIds. That means, the current owner is still owner of the cat.
Impact
The safeTransferFrom function does not remove the token from the s_ownerToCatsTokenId mapping of the current owner after a successful transfer. This results in the previous owner still having a reference to a token they no longer own.
The test function test__safetransferCatToNewOwner function in KittyTest.t.sol file demontrates this issue. You can execute this test using the foundry command: forge test --match-test "test_safetransferCatToNewOwner" -vvvvv.
function test_safetransferCatToNewOwner() public {
string memory catImageIpfsHash = "ipfs://QmbxwGgBGrNdXPm84kqYskmcMT3jrzBN8LzQjixvkz4c62";
uint256 tokenId = kittyConnect.getTokenCounter();
address newOwner = makeAddr("newOwner");
vm.prank(partnerA);
kittyConnect.mintCatToNewOwner(user, catImageIpfsHash, "Meowdy", "Ragdoll", block.timestamp);
vm.prank(user);
kittyConnect.approve(newOwner, tokenId);
vm.expectEmit(false, false, false, true, address(kittyConnect));
emit CatTransferredToNewOwner(user, newOwner, tokenId);
vm.prank(partnerA);
kittyConnect.safeTransferFrom(user, newOwner, tokenId);
assertEq(kittyConnect.ownerOf(tokenId), newOwner);
console.log(kittyConnect.getCatsTokenIdOwnedBy(user).length);
assertEq(kittyConnect.getCatsTokenIdOwnedBy(user).length, 0);
assertEq(kittyConnect.getCatsTokenIdOwnedBy(newOwner).length, 1);
assertEq(kittyConnect.getCatsTokenIdOwnedBy(newOwner)[0], tokenId);
assertEq(kittyConnect.getCatInfo(tokenId).prevOwner[0], user);
assertEq(kittyConnect.getCatInfo(tokenId).prevOwner.length, 1);
assertEq(kittyConnect.getCatInfo(tokenId).idx, 0);
}
The test fails due to this line: assertEq(kittyConnect.getCatsTokenIdOwnedBy(user).length, 0);. The user is the prevous owner and should have 0 cats tokenId, but the protocol doesn't update the tokens owned by the user after the transfer ownership of the cat. Therefore, the prevous owner still owns 1 catsTokenId:
Ran 1 test for test/KittyTest.t.sol:KittyTest
[FAIL. Reason: assertion failed: 1 != 0] test_safetransferCatToNewOwner() (gas: 418363)
Logs:
1
Tools Used
Manual Review, Foundry
Recommendations
Implement a function to remove the token from the prevous owner:
+ function _removeTokenFromPreviousOwner(address prevOwner, uint256 tokenId) internal {
+ uint256 lastTokenIndex = s_ownerToCatsTokenId[prevOwner].length - 1;
+ uint256 tokenIndex = s_catInfo[tokenId].idx;
+ // Only need to swap if the token to remove is not the last one
+ if (tokenIndex != lastTokenIndex) {
+ uint256 lastTokenId = s_ownerToCatsTokenId[prevOwner][lastTokenIndex];
+ // Move the last token to the slot of the token to remove
+ s_ownerToCatsTokenId[prevOwner][tokenIndex] = lastTokenId;
+ // Update the moved token's index
+ s_catInfo[lastTokenId].idx = tokenIndex;
+ }
+ // Remove the last token (which is now the token to remove)
+ s_ownerToCatsTokenId[prevOwner].pop();
+ }
Then calls this function from the KittyConnect::_updateOwnershipInfo function:
function _updateOwnershipInfo(address currCatOwner, address newOwner, uint256 tokenId) internal {
s_catInfo[tokenId].prevOwner.push(currCatOwner);
s_catInfo[tokenId].idx = s_ownerToCatsTokenId[newOwner].length;
s_ownerToCatsTokenId[newOwner].push(tokenId);
//@audit add a function to remove the tokenId from the currCatOwner
+ _removeTokenFromPreviousOwner(currCatOwner, tokenId);
}