The function EggVault::depositEgg() doesn't have address checks, which can lead to some unexpected NFT ownership claims under certain situations.
EggVault::depositEgg() does not check for who is the caller, nor does it verify who was the previous owner of the NFT that was deposited.
This opens a small attack surface. Someone can execute a frontrunning attack:
Suppose a user holding an Egg NFT wants to deposit their NFT into the vault, and they want to do so using the EggVault's function depositEgg(), and don't want to use the EggHuntGame::depositEggToVault() function.
First, they transfer the NFT: nft.transferFrom(user, vault, tokenId)
Then, they call the deposit function by passing their own address as the depositor: vault.depositEgg(tokenId, user)
EggVault::depositEgg() just checks that the current owner of the NFT is itself, and makes the depositor argument the depositor of the NFT.
But, in the time between the above two transactions, someone can call the deposit function passing their address as the depositor: vault.depositEgg(tokenId, attacker). The function would execute, making the attacker eligible to withdraw the egg from the vault and become the owner of the NFT.
Here is a test that can be included in the test/EggHuntGameTest.t.sol::EggGameTest contract:
Loss of NFT ownership
Manual review
I would recommend to modify both the functions EggHuntGame::depositEggToVault() and EggVault::depositEgg(). Only the Vault should be responsible for transfer of the NFT.
Recommended behavior of EggHuntGame::depositEggToVault():
Check that the caller is the owner of the NFT.
Call the Vault contract's depositEgg() function.
Recommended behavior of EggVault::depositEgg():
Check the caller (msg.sender) and address depositor:
If the caller is NOT the Game contract, then depositor should be equal to the caller.
If the caller is the Game contract, then skip the depositor check.
Transfer NFT from depositor to itself. This ensures these things:
If a player directly calls the Vault's depositEgg():
As the depositor is the player itself, the player should own the NFT, otherwise this transfer would fail.
The player should have approved the NFT transfer to the Vault, for the same reasons as above.
If a player deposits via. EggHuntGame::depositEggToVault():
The depositor vs. caller check is skipped here, but in EggHuntGame::depositEggToVault(), we are already checking that its caller should be the NFT owner. That's why the player should own the NFT and should have provided approval to the vault to transfer the NFT (same conditions as above).
In this case, the vault trusts the game contract with the depositor address.
Rest of the lines in EggVault::depositEgg() are unchanged.
The user flow would change:
The user would need to approve their owned NFTs to the EggVault contract only, instead of the EggHuntGame contract.
Front-running depositEgg allows deposit ownership hijacking.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.