Token-0x

First Flight #54
Beginner FriendlyDeFi
100 EXP
Submission Details
Impact: high
Likelihood: medium

[H-1] Using low-level return in ERC20Internals::_balanceOf bypasses internal control flow and breaks ERC-20 compatibility

Author Revealed upon completion

Root + Impact
[H-1] Using low-level return in ERC20Internals::_balanceOf bypasses internal control flow and breaks ERC-20 compatibility

Description

The ERC20Internals::_balanceOf function uses a low-level return inside an assembly block, which immediately exits the entire call frame. When called internally, this bypasses any subsequent Solidity code in the caller, potentially skipping important logic or checks. This behavior can break internal control flow, violate assumptions in higher-level ERC-20 functions, and reduce compatibility with contracts or tools expecting standard ERC-20 balanceOf semantics. Additionally, the function reverts for address(0), which is inconsistent with ERC-20 expectations of returning a zero balance.

assembly {
if iszero(owner) {
revert(0, 0)
}
let baseSlot := _balances.slot
let ptr := mload(0x40)
mstore(ptr, owner)
mstore(add(ptr, 0x20), baseSlot)
let dataSlot := keccak256(ptr, 0x40)
let amount := sload(dataSlot)
mstore(ptr, amount)
mstore(add(ptr, 0x20), 0)
return(ptr, 0x20) // @> This low-level return bypasses internal function logic
}

Risk

Likelihood:

  • Occurs when _balanceOf is called internally from another function or contract, because the assembly return immediately exits the entire call frame.

  • Occurs during contract extensions or hooks that rely on _balanceOf for checks, calculations, or accounting before completing their logic.

Impact:

  • Internal control flow is bypassed, potentially skipping critical checks or updates in higher-level ERC-20 functions such as transferFrom, hooks, or custom extensions.

  • Compatibility with wallets, contracts, and tools that rely on standard ERC-20 balanceOf behavior is broken, leading to unexpected failures or incorrect accounting.

Proof of Concept:
The following Foundry test demonstrates the issue. The balanceOf function is expected to trigger the caller-specific logic inside ERC20Internals, but the implementation bypasses it completely, returning the raw balance without executing the intended checks. To validate this behaviour, a temporary hook was added in Token.sol:

bool public afterBalanceOfRan;

function testHook() external returns (uint256) {
afterBalanceOfRan = false;

bool public afterBalanceOfRan;
function testHook() external returns (uint256) {
afterBalanceOfRan = false;
uint256 x = _balanceOf(msg.sender);
// THIS SHOULD RUN — but will NOT run due to the assembly return
afterBalanceOfRan = true;
return x;
}

This hook allows us to detect whether control flow continues after _balanceOf.
It should execute the final line and set afterBalanceOfRan = true, but due to the assembly-level early return inside balanceOf, the function exits before reaching this line.

<details><summary>Proof of Code</summary>
function test_balanceOfBypassesCallerLogic() public {
// Mint some tokens to user1
address user1 = makeAddr("user1");
token.mint(user1, 100 ether);
// Call balanceOf which should trigger custom caller logic,
// but the vulnerable implementation bypasses it entirely.
uint256 balance = token.balanceOf(user1);
// This variable would only be set to true if the internal logic ran.
bool flag = token.afterBalanceOfRan();
console.log("Returned Balance:", balance);
console.log("afterBalanceOfRan Flag:", flag);
// Expected: internal hook runs → flag = true
// Actual: hook never runs → flag = false
assertEq(balance, 100 ether, "Balance returned is correct but logic was skipped");
assertFalse(flag, "Internal caller logic was bypassed");
}
</details>

Recommended Mitigation:
Option 1: Replace the entire assembly implementation with a plain Solidity getter that returns 0 for address(0). This restores ERC-20 semantics and ensures internal callers continue execution.
Option 2: Keep assembly for a storage read but avoid return inside assembly: compute and load the slot in assembly, store into a local uint256, and return via Solidity.

- remove this code
- assembly {
- if iszero(owner) {
- revert(0, 0)
- }
-
- let baseSlot := _balances.slot
- let ptr := mload(0x40)
- mstore(ptr, owner)
- mstore(add(ptr, 0x20), baseSlot)
- let dataSlot := keccak256(ptr, 0x40)
- let amount := sload(dataSlot)
- mstore(ptr, amount)
- mstore(add(ptr, 0x20), 0)
- return(ptr, 0x20) // <-- remove this low-level return
- }
+ add this code
+ // ERC-20 semantics: balanceOf(address(0)) => 0 (do not revert)
+ if (owner == address(0)) {
+ return 0;
+ }
+ // Assuming _balances is mapping(address => uint256)
+ return _balances[owner];
+ }
OR
+ if (owner == address(0)) {
+ return 0;
+ }
+
+ uint256 amount;
+ assembly {
+ // compute storage slot for _balances[owner] and load into `amount`
+ let baseSlot := _balances.slot
+ let ptr := mload(0x40)
+ mstore(ptr, owner)
+ mstore(add(ptr, 0x20), baseSlot)
+ let dataSlot := keccak256(ptr, 0x40)
+ amount := sload(dataSlot)
+ // do not call `return` here — assign amount and exit assembly
+ }
+
+ return amount;
+ }

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!