Santa's List

AI First Flight #3
Beginner FriendlyFoundry
EXP
View results
Submission Details
Severity: high
Valid

H-03] buyPresent() allows callers to burn another user's SANTA tokens without authorization

Root + Impact

Description

  • A user should spend their own SantaTokens when purchasing a present. If a user wants to purchase a present for someone else, the token expenditure should still be authorized by the token owner.

  • buyPresent() accepts an arbitrary presentReceiver parameter and passes it directly to SantaToken.burn():

function buyPresent(address presentReceiver) external {
i_santaToken.burn(presentReceiver);
_mintAndIncrement();
}
SantaToken.burn() only checks whether the caller is SantasList:
if (msg.sender != i_santasList) {
revert SantaToken__NotSantasList();
Since SantasList is the caller, the burn succeeds regardless of who owns the tokens.
Therefore an attacker can provide another user's address as presentReceiver, causing that user's SANTA balance to be burned.
The resulting NFT is then minted to msg.sender, meaning the attacker receives the NFT while the victim pays for it.
Solidity
function buyPresent(address presentReceiver) external {
// @> Attacker controls `presentReceiver`
// @> No authorization from the token owner is required.
i_santaToken.burn(presentReceiver);
// @> NFT goes to msg.sender, not presentReceiver.
_mintAndIncrement();
}

Risk

Likelihood:

  • buyPresent() is externally callable.

  • The caller controls presentReceiver.

  • SantaToken.burn() does not require approval because SantasList itself is authorized.

Impact:

  • An attacker can destroy another user's SantaTokens.

  • The attacker receives the NFT generated by the victim's tokens.

  • Users can lose assets without signing an approval transaction.

Proof of Concept

The following test demonstrates the complete attack:
function testAttackerCanBurnVictimTokens() public {
address victim = makeAddr("victim");
address attacker = makeAddr("attacker");
// Give the victim 1 SANTA token through the legitimate
// token distribution flow.
// Alternatively, mint/setup the token in the test environment.
deal(address(santaToken), victim, 1e18);
// Confirm that the victim owns the tokens before the attack.
assertEq(santaToken.balanceOf(victim), 1e18);
// The attacker calls buyPresent() but supplies the victim's
// address as presentReceiver.
vm.prank(attacker);
santasList.buyPresent(victim);
// The victim's SANTA tokens were burned even though the
// victim never approved the attacker or SantasList to spend them.
assertEq(santaToken.balanceOf(victim), 0);
// The NFT is minted to the attacker because _mintAndIncrement()
// uses msg.sender as the recipient.
assertEq(santasList.balanceOf(attacker), 1);
}

PoC Explanation:

The attack works because the protocol incorrectly treats presentReceiver as both the account whose tokens should be burned and the input controlled by the caller.

The attack flow is:

The victim owns 1 SANTA.
The attacker calls:
santasList.buyPresent(victim);
Inside buyPresent(), presentReceiver == victim.
SantasList calls:
i_santaToken.burn(victim);
SantaToken sees that msg.sender == SantasList, so the burn is authorized.
The victim's 1 SANTA is destroyed.
_mintAndIncrement() mints the NFT to msg.sender, which is the attacker.
Therefore, the victim pays while the attacker receives the purchased NFT.

The critical authorization failure is that authorization is based only on the caller being SantasList; there is no authorization from the address whose tokens are being burned.




Recommended Mitigation

If the intended behavior is that the caller must pay for their own present, the contract should burn the caller's tokens rather than an arbitrary address:
function buyPresent(address presentReceiver) external {
- i_santaToken.burn(presentReceiver);
- _mintAndIncrement();
+ i_santaToken.burn(msg.sender);
+ _safeMint(presentReceiver, s_tokenCounter++);
}
This separates the two concepts:
msg.sender = who pays for the present
presentReceiver = who receives the NFT
For example:
Attacker
|
| buyPresent(victim)
v
SantasList
|
| burn(msg.sender)
v
Attacker's SANTA tokens
|
| NFT
v
Victim
Under this implementation, an attacker can still purchase a present for another user, but the attacker must pay for it with their own SANTA tokens.
If the protocol instead intentionally allows a caller to spend another user's SANTA tokens, then explicit authorization from the token owner is required.
For example, the token could use an allowance-based mechanism:
function buyPresent(address tokenOwner, address presentReceiver) external {
i_santaToken.transferFrom(tokenOwner, address(this), PRESENT_COST);
i_santaToken.burn(address(this));
_safeMint(presentReceiver, s_tokenCounter++);
}
The victim would first need to explicitly approve the SantasList contract to spend their SANTA:
santaToken.approve(address(santasList), amount);
The important security property is that SantasList must not be able to arbitrarily burn tokens from an address merely because that address was supplied as a function argument. Token ownership and spending authorization must be enforced independently from the NFT recipient.
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour ago
Submission Judgement Published
Validated
Assigned finding tags:

[H-03] SantasList::buyPresent burns token from presentReceiver instead of caller and also sends present to caller instead of presentReceiver.

## Description The `buyPresent` function sends the present to the `caller` of the function but burns token from `presentReceiver` but the correct method should be the opposite of it. Due to this implementation of the function, malicious caller can mint NFT by burning the balance of other users by passing any arbitrary address for the `presentReceiver` field and tokens will be deducted from the `presentReceiver` and NFT will be minted to the malicious caller. Also, the NatSpec mentions that one has to approve `SantasList` contract to burn their tokens but it is not required and even without approving the funds can be burnt which means that the attacker can burn the balance of everyone and mint a large number of NFT for themselves. `buyPresent` function should send the present (NFT) to the `presentReceiver` and should burn the SantaToken from the caller i.e. `msg.sender`. ## Vulnerability Details The vulnerability lies inside the SantasList contract inside the `buyPresent` function starting from line 172. The buyPresent function takes in `presentReceiver` as an argument and burns the balance from `presentReceiver` instead of the caller i.e. `msg.sender`, as a result of which an attacker can specify any address for the `presentReceiver` that has approved or not approved the SantasToken (it doesn't matter whether they have approved token or not) to be spent by the SantasList contract, and as they are the caller of the function, they will get the NFT while burning the SantasToken balance of the address specified in `presentReceiver`. This vulnerability occurs due to wrong implementation of the buyPresent function instead of minting NFT to presentReceiver it is minted to caller as well as the tokens are burnt from presentReceiver instead of burning them from `msg.sender`. Also, the NatSpec mentions that one has to approve `SantasList` contract to burn their tokens but it is not required and even without approving the funds can be burnt which means that the attacker can burn the balance of everyone and mint a large number of NFT for themselves. ```cpp /* * @notice Buy a present for someone else. This should only be callable by anyone with SantaTokens. * @dev You'll first need to approve the SantasList contract to spend your SantaTokens. */ function buyPresent(address presentReceiver) external { @> i_santaToken.burn(presentReceiver); @> _mintAndIncrement(); } ``` ## PoC Add the test in the file: `test/unit/SantasListTest.t.sol` Run the test: ```cpp forge test --mt test_AttackerCanMintNft_ByBurningTokensOfOtherUsers ``` ```cpp function test_AttackerCanMintNft_ByBurningTokensOfOtherUsers() public { // address of the attacker address attacker = makeAddr("attacker"); vm.startPrank(santa); // Santa checks user once as EXTRA_NICE santasList.checkList(user, SantasList.Status.EXTRA_NICE); // Santa checks user second time santasList.checkTwice(user, SantasList.Status.EXTRA_NICE); vm.stopPrank(); // christmas time 🌳🎁 HO-HO-HO vm.warp(santasList.CHRISTMAS_2023_BLOCK_TIME()); // User collects their NFT and tokens for being EXTRA_NICE vm.prank(user); santasList.collectPresent(); assertEq(santaToken.balanceOf(user), 1e18); uint256 attackerInitNftBalance = santasList.balanceOf(attacker); // attacker get themselves the present by passing presentReceiver as user and burns user's SantaToken vm.prank(attacker); santasList.buyPresent(user); // user balance is decremented assertEq(santaToken.balanceOf(user), 0); assertEq(santasList.balanceOf(attacker), attackerInitNftBalance + 1); } ``` ## Impact - Due to the wrong implementation of function, an attacker can mint NFT by burning the SantaToken of other users by passing their address for the `presentReceiver` argument. The protocol assumes that user has to approve the SantasList in order to burn token on their behalf but it will be burnt even though they didn't approve it to `SantasList` contract, because directly `_burn` function is called directly by the `burn` function and both of them don't check for approval. - Attacker can burn the balance of everyone and mint a large number of NFT for themselves. ## Recommendations - Burn the SantaToken from the caller i.e., `msg.sender` - Mint NFT to the `presentReceiver` ```diff + function _mintAndIncrementToUser(address user) private { + _safeMint(user, s_tokenCounter++); + } function buyPresent(address presentReceiver) external { - i_santaToken.burn(presentReceiver); - _mintAndIncrement(); + i_santaToken.burn(msg.sender); + _mintAndIncrementToUser(presentReceiver); } ``` By applying this recommendation, there is no need to worry about the approvals and the vulnerability - 'tokens can be burnt even though users don't approve' will have zero impact as the tokens are now burnt from the caller. Therefore, an attacker can't burn others token.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!