Token-0x

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

[M-1] Assembly helper return Kills Caller Execution.

Author Revealed upon completion

Root + Impact

Description

totalSupply_() and _balanceOf() in src/helpers/ERC20Internals.sol read storage via inline assembly and then execute return(ptr, 0x20).

totalSupply_():

function totalSupply_() internal view returns (uint256) {
assembly {
let slot := _totalSupply.slot
let supply := sload(slot)
mstore(0x00, supply)
return(0x00, 0x20)
}
}

_balanceOf()

function _balanceOf(address owner) internal view returns (uint256) {
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)
}
}

Risk

The Yul return opcode terminates the entire call frame, not just the helper. Any inheriting contract that queries these helpers midway through its logic immediately exits the outer function, skipping every statement, event, and revert that should have followed. Because they are marked internal, downstream extensions will naturally reuse them inside hooks, guarded burns, permit flows, or meta-transaction and will unknowingly drop critical logic.

Impact

Complex flows that call _balanceOf/totalSupply_ are unusable, the call appears to succeed (it even returns the stored value), but all state updates and security checks placed after the helper are silently skipped. A burn hook that first reads the balance never performs the actual burn, allowance spending routines can exit before decrementing allowances, and any invariant that relied on post-checks simply never runs. This makes extensions extremely brittle, breaks accounting invariants, and lets adversaries bypass guards by triggering the helper mid-function.

Proof of Concept

The test test_balanceHelperShortCircuitsExecutionFlow performs a low-level call to burnWithHooks (a created function to show how a realistic extension would call into the helper and unintentionally abort the caller), and then checks those flags plus balances/supply to prove the short circuit.

test/HelperViewsShortCircuit.t.sol deploys HookedToken, a derivative that:

  1. Sets preHookEntered to true, then calls _beforeBurn, which in turn invokes _balanceOf.

  2. Afterwards it intends to set postHookReached, call _burn, and flag burnRoutineReached.

The test test_balanceHelperShortCircuitsExecutionFlow() mints 100 tokens to alice, then (via a low-level call) invokes burnWithHooks(10 ether) and asserts:

  • the external call succeeded,

  • postHookReached and burnRoutineReached are still false, proving the function stopped right after _balanceOf,

  • both alice’s balance and totalSupply remain 100 ether because _burn was never reached.

Running forge test --match-test test_balanceHelperShortCircuitsExecutionFlow reproduces the issue and shows that any logic placed after the helper silently never executes.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {ERC20} from "../src/ERC20.sol";
contract HookedToken is ERC20 {
bool public preHookEntered;
bool public postHookReached;
bool public burnRoutineReached;
constructor() ERC20("HookedToken", "HOOK") {}
function mint(address account, uint256 value) external {
_mint(account, value);
}
function burnWithHooks(uint256 value) external returns (bool) {
preHookEntered = true;
_beforeBurn(msg.sender, value);
postHookReached = true;
_burn(msg.sender, value);
burnRoutineReached = true;
return true;
}
function _beforeBurn(address owner, uint256 value) internal view {
uint256 balance = _balanceOf(owner);
if (balance < value) {
revert("INSUFFICIENT_BALANCE");
}
}
}
contract HelperViewsShortCircuitTest is Test {
HookedToken private token;
address private alice;
function setUp() public {
token = new HookedToken();
alice = makeAddr("alice");
token.mint(alice, 100 ether);
}
function test_balanceHelperShortCircuitsExecutionFlow() public {
vm.prank(alice);
(bool ok, ) = address(token).call(abi.encodeWithSelector(token.burnWithHooks.selector, 10 ether));
assertTrue(ok, "call should appear successful to the caller");
assertTrue(token.preHookEntered(), "pre-hook flag proves execution started");
assertFalse(token.postHookReached(), "execution aborted immediately after calling _balanceOf");
assertFalse(token.burnRoutineReached(), "burn logic never ran due to early return");
assertEq(
token.balanceOf(alice),
100 ether,
"balance remains unchanged because _burn was never reached"
);
assertEq(
token.totalSupply(),
100 ether,
"total supply untouched because call short-circuited"
);
}
}

Recommended Mitigation

Stop using return inside the helper views. Load the slots into return variables so Solidity performs the return,
or reimplement these helpers in Solidity entirely. This preserves control flow for callers and prevents future extensions from aborting mid-execution. Add regression tests that call the helpers before executing additional logic to ensure execution continues.

@@
+ function totalSupply_() internal view returns (uint256) {
+ assembly {
+ let slot := _totalSupply.slot
+ let supply := sload(slot)
- mstore(0x00, supply)
- return(0x00, 0x20)
- }
- }
@@
+ function _balanceOf(address owner) internal view returns (uint256) {
+ 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)
+ balance := sload(dataSlot)
- }
- }

Support

FAQs

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

Give us feedback!