Root + Impact
Description
The _burn() function performs state updates directly in Yul without applying Solidity’s built-in checked arithmetic. The function substracts value from both _totalSupply and the caller’s balance without verifying that the account holds at least value tokens.
-
Describe the normal behavior in one or more sentences
-
When accountBalance < value, the Yul-level sub(accountBalance, value) underflows and produces a very large 256-bit integer. This silently writes an invalid balance to storage, corrupting the accounting across the entire token system.
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:
This will occur whenever burn operations depend on user-controlled inputs, such as token redemption mechanisms or protocol-driven burns without preceding balance verification.
Impact:
Total supply desynchronization: total supply is reduced even though the account did not have sufficient tokens to burn, further corrupting system-wide accounting.
Proof of Concept
function test_underflow() public {
uint256 amount = 100e18;
address account = makeAddr("account");
token.mint(account, amount);
uint256 balance = token.balanceOf(account);
assertEq(balance, amount);
assertEq(token.totalSupply(), amount);
token.burn(account, amount + 1 ether);
balance = token.balanceOf(account);
console.log("balance after burn:", balance);
}
[PASS] test_underflow() (gas: 65174)
Logs:
balance after burn: 115792089237316195423570985008687907853269984665640564039456584007913129639936
Traces:
[65174] TokenTest::test_underflow()
├─ [0] VM::addr(<pk>) [staticcall]
│ └─ ← [Return] account: [0x359e534d4C79745c3c0A0BC80d80cfAe9e82699e]
├─ [0] VM::label(account: [0x359e534d4C79745c3c0A0BC80d80cfAe9e82699e], "account")
│ └─ ← [Return]
├─ [45004] Token::mint(account: [0x359e534d4C79745c3c0A0BC80d80cfAe9e82699e], 100000000000000000000 [1e20])
│ └─ ← [Stop]
├─ [720] Token::balanceOf(account: [0x359e534d4C79745c3c0A0BC80d80cfAe9e82699e]) [staticcall]
│ └─ ← [Return] 100000000000000000000 [1e20]
├─ [317] Token::totalSupply() [staticcall]
│ └─ ← [Return] 100000000000000000000 [1e20]
├─ [1270] Token::burn(account: [0x359e534d4C79745c3c0A0BC80d80cfAe9e82699e], 101000000000000000000 [1.01e20])
│ └─ ← [Stop]
├─ [720] Token::balanceOf(account: [0x359e534d4C79745c3c0A0BC80d80cfAe9e82699e]) [staticcall]
│ └─ ← [Return] 115792089237316195423570985008687907853269984665640564039456584007913129639936 [1.157e77]
├─ [0] console::log("balance after burn:", 115792089237316195423570985008687907853269984665640564039456584007913129639936 [1.157e77]) [staticcall]
│ └─ ← [Stop]
└─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 21.53ms (9.08ms CPU time)
Recommended Mitigation
function _burn(address account, uint256 value) internal {
require(account != address(0), "Invalid burn address");
uint256 accountBalance = balanceOf(account);
require(accountBalance >= value, "Insufficient balance to burn");
assembly ("memory-safe") {
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)
sstore(accountBalanceSlot, sub(accountBalance, value))
}
}