Santa's List

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

SantasList::buyPresent() burns presentReceiver's SantasTokens and gives present to msg.sender

Root + Impact

Description

  • The normal behavior of SantasList.buyPresent() is that it should burn a one of msg.sender's SantaTokens, and it should mint an NFT to address presentReceiver.

  • The problem is twofold in buyPresent(): (1) it burns a token belonging to presentReceiver, and (2) it mints a present to msg.sender, instead of to presentReceiver, because _mintAndIncrement() mints to msg.sender.

  • Side note, more of a warning than a bug, about buyPresent: the README describes it as costing 2e18 to buy a present, but the burn function only burns 1e18 of SantaToken. It seems that the variable PURCHASED_PRESENT_COST could be used in this regard, but it goes unused.

/*
* @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();
}
. . .
function _mintAndIncrement() private {
@> _safeMint(msg.sender, s_tokenCounter++);
}

Risk

Likelihood:

  • This error will happen any time address presentReceiver has approved that the SantaToken contract can make changes to their balance.

  • There is incentive for malicious users to call this function, because it costs them nothing, and rewards them with a minted NTF as the msg.sender.

Impact:

  • The impact is the presentReceiver will have 1e18 of SantaToken tokens burned anytime a user calls the function with their address as a parameter.

  • The caller of the function will always have an NFT minted to them so long as the burn of presentReceiver's token occurs.

Proof of Concept

Below we show that user calls buyPresent and does not get SantaTokens burned. We also see that address presentReceiver, who is user2 here, loses 1e18 of SantaToken. We see that user, the caller of buyPresent, receives the present, and user2, the address presentReceiver, does not receiver a present.

// Testing via Foundry
// Setup for tests
// SPDX-License-Identifier: MIT
pragma solidity 0.8.22;
import {SantasList} from "../../src/SantasList.sol";
import {SantaToken} from "../../src/SantaToken.sol";
import {Test} from "forge-std/Test.sol";
import {console2} from "forge-std/console2.sol"; // added this
import {_CheatCodes} from "../mocks/CheatCodes.t.sol";
contract SantasListTest is Test {
SantasList santasList;
SantaToken santaToken;
address user = makeAddr("user");
address user2 = makeAddr("user2");
address santa = makeAddr("santa");
_CheatCodes cheatCodes = _CheatCodes(HEVM_ADDRESS);
function setUp() public {
vm.startPrank(santa);
santasList = new SantasList();
santaToken = SantaToken(santasList.getSantaToken());
vm.stopPrank();
}
function buyPresentBurnsReceiversTokens() public {
// ARRANGE
uint256 userSantaTokenBalanceBefore = santaToken.balanceOf(user);
uint256 user2SantaTokenBalanceBefore = santaToken.balanceOf(user2);
uint256 userSantasListBalanceBefore = santasList.balanceOf(user);
uint256 user2SantasListBalanceBefore = santasList.balanceOf(user2);
// ACT
vm.startPrank(user);
santaToken.approve(address(santasList), 1e18);
santasList.buyPresent(user2);
stopPrank();
// ASSERT
uint256 userSantaTokenBalanceAfter = santaToken.balanceOf(user);
uint256 user2SantaTokenBalanceAfter = santaToken.balanceOf(user2);
uint256 userSantasListBalanceAfter = santaList.balanceOf(user);
uint256 user2SantasListBalanceAfter = santaList.balanceOf(user2);
// assert that user's SantaToken balance stays the same
assertEq(userSantaTokenBalanceBefore, userSantaTokenBalanceAfter);
// assert that user2's SantaToken balance decreased by 1
assertEq(userSantaTokenBalanceBefore - 1e18, userSantaTokenBalanceAfter);
// assert that user gained 1 SantasList NFT
assertEq(userSantasListBalanceBefore + 1, userSantasListBalanceAfter);
// assert that user2's balance of SantasList NFTs remained the same
assertEq(user2SantasListBalanceBefore, user2SantasListBalanceAfter);
}

Recommended Mitigation

(1) buyPresent() should burn msg.sender's SantaTokens.

(2) _mintAndIncrement() should be re-written to accept an address of the recipient of the minted NFT. Additionally, _mintAndIncrement() needs to properly increment s_tokenCounter. This could be considered for a separate vulnerability submission, but we will include it here.

function buyPresent(address presentReceiver) external {
- i_santaToken.burn(presentReceiver);
+ i_santaToken.burn(msg.sender);
- _mintAndIncrement();
+ _mintAndIncrement(presentReceiver);
}
- function _mintAndIncrement() private {
+ function _mintAndIncrement(address recipient) private {
- _safeMint(msg.sender, s_tokenCounter++);
+ s_tokenCounter++;
+ _safeMint(recipient, s_tokenCounter);
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 12 days 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!