Root + Impact
Description
The burnFaucetTokens function is designed to allow the owner to burn a specified amount of faucet tokens. The owner should only be able to burn the exact amount specified in the amountToBurn parameter.
The specific issue is a parameter mismatch where the function transfers the entire balance to the owner but only burns the amountToBurn, allowing the owner to steal the difference between the total balance and the burn amount.
function burnFaucetTokens(uint256 amountToBurn) external onlyOwner {
uint256 balance = faucetToken.balanceOf(address(this));
require(balance > 0, "No tokens to burn");
faucetToken.transfer(msg.sender, balance);
faucetToken.burn(amountToBurn);
emit FaucetTokensBurned(amountToBurn);
}
Risk
Likelihood:
-
Owner can call this function at any time with any amount
-
No special conditions or prerequisites required
-
Single transaction exploit with immediate profit
-
Can be executed repeatedly to drain all tokens
Impact:
-
Complete theft of all faucet tokens (1 billion tokens)
-
Permanent loss of user assets with no recovery mechanism
-
Complete breakdown of the faucet system
-
Users cannot claim any tokens after theft
Proof of Concept
This test demonstrates how the owner can steal all faucet tokens:
Setup: We give the faucet contract 1 billion tokens
Attack: The owner calls burnFaucetTokens(1) to burn only 1 token
Result: The owner receives all 1 billion tokens while only 1 token is burned
The exploit works because:
-
The function reads the entire balance with balanceOf(address(this))
-
It transfers this entire balance to the owner
-
But only burns the amountToBurn parameter
-
The difference (999,999,999 tokens) is stolen by the owner
pragma solidity ^0.8.20;
import {Test} from "forge-std/Test.sol";
import {RaiseBoxFaucet} from "../src/RaiseBoxFaucet.sol";
import {RaiseBoxToken} from "../src/RaiseBoxToken.sol";
contract OwnerTheftTest is Test {
RaiseBoxFaucet faucet;
RaiseBoxToken token;
address owner = makeAddr("owner");
uint256 constant INITIAL_SUPPLY = 1_000_000_000 * 10**18;
function setUp() public {
vm.startPrank(owner);
token = new RaiseBoxToken();
faucet = new RaiseBoxFaucet(address(token));
token.mintFaucetTokens(address(faucet), INITIAL_SUPPLY);
vm.stopPrank();
}
function testOwnerStealsAllTokens() public {
uint256 faucetBalanceBefore = token.balanceOf(address(faucet));
uint256 ownerBalanceBefore = token.balanceOf(owner);
assertEq(faucetBalanceBefore, INITIAL_SUPPLY);
assertEq(ownerBalanceBefore, 0);
vm.prank(owner);
faucet.burnFaucetTokens(1);
uint256 faucetBalanceAfter = token.balanceOf(address(faucet));
uint256 ownerBalanceAfter = token.balanceOf(owner);
assertEq(ownerBalanceAfter, INITIAL_SUPPLY);
assertEq(faucetBalanceAfter, 0);
}
}
Recommended Mitigation
function burnFaucetTokens(uint256 amountToBurn) external onlyOwner {
uint256 balance = faucetToken.balanceOf(address(this));
require(balance > 0, "No tokens to burn");
+ require(balance >= amountToBurn, "Insufficient balance to burn");
- faucetToken.transfer(msg.sender, balance);
+ faucetToken.transfer(msg.sender, amountToBurn);
faucetToken.burn(amountToBurn);
emit FaucetTokensBurned(amountToBurn);
}
Or alternatively, remove the transfer entirely if the intent is only to burn:
function burnFaucetTokens(uint256 amountToBurn) external onlyOwner {
uint256 balance = faucetToken.balanceOf(address(this));
require(balance > 0, "No tokens to burn");
+ require(balance >= amountToBurn, "Insufficient balance to burn");
- faucetToken.transfer(msg.sender, balance);
faucetToken.burn(amountToBurn);
emit FaucetTokensBurned(amountToBurn);
}