Token-0x

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

Fragile Use of Inline Assembly `return` in Internal View Functions

Author Revealed upon completion

Description:

The internal view helpers totalSupply_ and _balanceOf use Yul’s return opcode:

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 {
// ...
return(ptr, 0x20)
}
}

These functions are declared internal but use return(...) at the EVM level, which aborts execution of the entire call, not just the internal function. In the current codebase they are only used as direct return expressions in the public view wrappers:

function totalSupply() public view virtual returns (uint256) {
return totalSupply_();
}
function balanceOf(address owner) public view virtual returns (uint256) {
return _balanceOf(owner);
}

Here, the pattern works because totalSupply_ and _balanceOf are effectively acting as fully inlined implementations of the public functions. However, any future attempt to reuse these internal helpers in more complex logic (e.g., uint256 s = totalSupply_(); uint256 x = s + 1;) would silently break: the return in the assembly would exit the entire call before executing subsequent code.

Impact:

  • Currently low, because the functions are only used as direct return targets.

  • Future maintainers might treat totalSupply_ / _balanceOf as “normal” internal helpers and call them in more complex functions, leading to:

    • Unexpected early returns.

    • ABI decoding errors (if the outer function expects multiple return values but return sends only one).

    • Very subtle bugs that are hard to diagnose.

Proof of Concept:

You can create a derived token that tries to use totalSupply_ in additional logic:

contract BadExtension is ERC20 {
constructor() ERC20("Bad", "BAD") {}
function buggy() external view returns (uint256, uint256) {
uint256 s = totalSupply_(); // assembly `return` fires here
uint256 x = s + 1; // never executed
return (s, x); // never reached, and ABI expects 64 bytes
}
}

Any call to buggy() will actually return only the 32 bytes written by totalSupply_ and then stop execution. The ABI decoder on the caller side will expect two uint256 values (64 bytes) and likely revert or misinterpret the data.

Mitigation:

  • Avoid using low‑level return in internal helpers. Instead, write them in normal Solidity or restrict the pattern to external/public functions where you fully implement the ABI in assembly.

  • For example, implement totalSupply and balanceOf directly as assembly in the public functions, and make the internal helpers pure-Solidity:

function totalSupply() public view virtual returns (uint256 supply) {
assembly {
let slot := _totalSupply.slot
supply := sload(slot)
}
}
function _balanceOf(address owner) internal view returns (uint256) {
return _balances[owner];
}
  • If you keep the current pattern, clearly document (in comments) that totalSupply_ and _balanceOf must only be used as direct return targets and are not safe as general internal helpers.

Support

FAQs

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

Give us feedback!