Skip to content

Inline assembly bypasses view pure mutability check #16706

@researchzero-sec

Description

@researchzero-sec

Description

Solidity's view/pure mutability check on internal function pointers can be bypassed using inline assembly. At the Yul level, an internal function pointer is just a 256-bit value (a JUMPDEST address), and Yul has no view of Solidity's mutability annotation. As a result, assembly { strictPtr := looseFn } copies the raw value from a non-view fn pointer into a view-typed fn pointer variable without any check, and a subsequent call through the laundered pointer from a view/pure context compiles cleanly.

The ViewPureChecker (libsolidity/analysis/ViewPureChecker.cpp) analyzes AST-level expressions and does not traverse Yul assignments between user-defined variables. The Yul analyzer (libyul/AsmAnalysis.cpp) copies the value without consulting Solidity-level mutability annotations.

Runtime detection is asymmetric:

Outer call context Inner action Runtime result
STATICCALL (external view) nonViewFn does TSTORE REVERT empty (EVM-level)
STATICCALL nonViewFn does SSTORE REVERT empty (EVM-level)
STATICCALL nonViewFn does LOG* REVERT empty (EVM-level)
STATICCALL nonViewFn does CREATE REVERT empty (EVM-level)
CALL (external non-view) nonViewFn does TSTORE SUCCEED — TSTORE applied
CALL nonViewFn does SSTORE SUCCEED — SSTORE applied
Internal call (no boundary) any mutation SUCCEED — no check

External view calls enter STATICCALL context, so any state-touching opcode (SSTORE, TSTORE, LOG, CREATE, SELFDESTRUCT) reverts with empty data. Internal calls have no STATICCALL boundary, so the laundered call freely modifies state and the view annotation is completely bypassed. The bypass is general — the same trick works for non-pure → pure, non-payable → payable, and any combination of mutability strictness levels.

Expected: the compiler should reject (or at least warn about) Yul assignments between function-pointer-typed variables when the mutability of the source pointer is weaker than the mutability of the destination pointer, so that the source-level view/pure annotation remains a meaningful invariant rather than an analyzer artifact.

A canonical fixture already exists in the test suite:

// test/libsolidity/semanticTests/inlineAssembly/tstore_hidden_staticcall.sol
contract C {
    function f() internal {
        assembly { tstore(0, 0) }
    }
    function g() public view {
        function() internal ptr = f;
        function() internal view ptr2;
        assembly { ptr2 := ptr }
        ptr2(); // we force calling the non-view function, which should
                // result in a revert during the staticcall
    }
    function test() public {
        this.g(); // an external call to a view function should use static call
    }
}
// ----
// test() -> FAILURE

Environment

  • Compiler version: 0.8.x at commit 47b9dedda
  • Compilation pipeline (legacy, IR, EOF): both legacy and via-IR exhibit the bypass (gas figures from the fixture are ~98M on both pipelines)
  • Target EVM version (as per compiler settings): an EVM version with TSTORE support (Cancun or later)
  • Framework/IDE (e.g. Foundry, Hardhat, Remix): Foundry (forge test)
  • Operating system: Linux Ubuntu Jammy

Steps to Reproduce

contract Subject {
    uint256 transient ts;

    function setTs(uint256 v) internal {
        assembly { tstore(ts.slot, v) }
    }

    function launderedCall(uint256 v) external view {
        function(uint256) internal ptr = setTs;          // OK
        function(uint256) internal view ptr2;            // declare view
        assembly { ptr2 := ptr }                         // Yul cast — no check
        ptr2(v);                                         // compile passes
    }
}

Calling patterns:

  • address(s).staticcall(abi.encodeCall(Subject.launderedCall, (1))) → REVERT with empty data (STATICCALL+TSTORE blocked by the EVM).
  • Calling launderedCall from another function with no external boundary (e.g. function callIt() internal { launderedCall(1); }) — the bypass succeeds and state is mutated from a "view" context.

The minimal pattern from test/libsolidity/semanticTests/inlineAssembly/tstore_hidden_staticcall.sol (shown in the Description) reproduces the compile-time bypass directly.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions