Root + Impact
Description
-
When executing the ERC20Internals::_burn function, the user can set the value of the value parameter to a number greater than their own balance. Since there is no check for the availability of such number of tokens in the user's account, it can cause underflow and the user's balance increases by a huge number rather than decrease by the value number.
-
This way, the invariant of _totalSupply = Sum of all accounts, will also get broken if _totalSupply is greater than value.
-
In addition, if the value is greater than _totalSupply, it will also underflow and wrap around.
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: High
-
It occurs when the user simply calls the _burn function and tries to burn more tokens than they actually have in their balance.
-
Since the balance is not ckecked, even a user with absolutely no token (balances[user] == 0) can call the function and indirectly mint infinite number of tokens to themself.
Impact: High
-
It has the effect of minting unlimited amount of tokens to the user. This bug makes the token hugely abundant and worthless.
-
It may also severely break the invariant of _totalSupply = Sum of all accounts if the value is only greater than the user's balance but not greater than the _totalSupply.
Proof of Concept
Please copy this function to the Token.t.sol test file and run it.
function test_burnWithInvalidValueMintsUnlimitedTokens() public {
address user1 = makeAddr("user1");
token.mint(user1, 100e18);
address user2 = makeAddr("user2");
uint256 user2BalanceBefore = token.balanceOf(user2);
token.burn(user2, 1);
uint256 user2BalanceAfter = token.balanceOf(user2);
assertEq(user2BalanceBefore, 0, "Initial balance should be zero");
assertEq(user2BalanceAfter, type(uint256).max, "Balance should be max uint256 after burning 1 from zero balance");
assertGt(user2BalanceAfter, token.totalSupply(), "Balance should exceed total supply");
}
Recommended Mitigation
You should make the following changes to resolve the issue.
This code adds the fuctionality for account balance check and moves the state changes to after the balance check section.
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(0x04, account)
+ mstore(0x24, accountBalance)
+ mstore(0x44, value)
+ revert(0x00, 0x64)
+ }
+ let supplySlot := _totalSupply.slot
+ let supply := sload(supplySlot)
+ sstore(supplySlot, sub(supply, value))
sstore(accountBalanceSlot, sub(accountBalance, value))
}
}