Inline assembly bypasses view pure mutability check
- Dominant language
- C++
- Stars
- 25.7k
- Forks
- 6.2k
- Avg merge
- 1d 11h
- Merged PRs (30d)
- 21
Description
## 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:
```solidity
// 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
```solidity
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.
Contributor guide
Research direction
Start with libsolidity/analysis/ViewPureChecker.cpp and libyul/AsmAnalysis.cpp, then run test/libsolidity/semanticTests/inlineAssembly/tstore_hidden_staticcall.sol through the legacy and via-IR pipelines. Trace how the assembly assignment and function-pointer call are analyzed. Done means weaker-mutability pointers cannot bypass the source-level check, with the fixture reflecting the expected compile-time behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- cpp, solidity
- Domain
- compilers, security
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 45/100