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.
Description
Solidity's
view/puremutability 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 aview-typed fn pointer variable without any check, and a subsequent call through the laundered pointer from aview/purecontext 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:
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
viewannotation is completely bypassed. The bypass is general — the same trick works fornon-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/pureannotation remains a meaningful invariant rather than an analyzer artifact.A canonical fixture already exists in the test suite:
Environment
47b9deddaforge test)Steps to Reproduce
Calling patterns:
address(s).staticcall(abi.encodeCall(Subject.launderedCall, (1)))→ REVERT with empty data (STATICCALL+TSTORE blocked by the EVM).launderedCallfrom 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.