High severity
An assembly return halts the whole transaction
Updated 2026-10-22
Inline assembly (Yul) has its own control flow, and its return is not Solidity’s return. In Yul, return(offset, length) is the raw EVM RETURN opcode: it ends the entire current call — the whole transaction, as far as your contract is concerned — and hands back length bytes of memory starting at offset. It does not return a value from the Solidity function that contains the assembly block. A developer who writes return(ptr, 0x20) inside assembly { } meaning “return this number from my function” instead stops execution dead: every statement after the call is skipped, and the function hands back whatever raw bytes the assembly pointed at rather than its declared return type. The keyword that returns from a Yul function is leave; at the top level of an inline block you simply assign the function’s named return variable and let control fall through.
Vulnerable pattern
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;
contract Rewards {
mapping(address => uint256) public owed;
uint256 public totalPaid;
// BUG: assembly `return(ptr, 0x20)` is the EVM RETURN opcode. It halts the
// WHOLE transaction and returns 32 bytes of memory -- it does NOT return
// from this Solidity function. The author meant to leave the Yul context.
function _amount(address user) internal view returns (uint256 amount) {
uint256 o = owed[user];
assembly {
let ptr := mload(0x40)
mstore(ptr, o)
return(ptr, 0x20)
}
}
function claim() external returns (uint256 paid) {
paid = _amount(msg.sender);
// None of this runs: the assembly return above ended the transaction.
owed[msg.sender] = 0;
totalPaid += paid;
(bool ok, ) = msg.sender.call{value: paid}("");
require(ok, "transfer failed");
}
}The call to _amount() never comes back. Its assembly return ends the transaction right there, so the three lines after it in claim() — zeroing the user’s balance, adding to totalPaid, and actually sending the ETH — are silently skipped. Worse, the transaction still succeeds: it returns the 32 bytes the assembly wrote, which an integrating contract decoding claim()’s “return value” will happily read as the paid amount. So a caller sees a successful claim that returns the right-looking number, while on-chain the balance was never decremented and no funds moved. Flip the arithmetic and the same shape skips a guard that was meant to prevent a double-spend, a nonce bump, a reentrancy-lock release, or an access check — whatever happened to sit after the call. Because the halt is invisible in a quick read (the code looks like it returns a value and keeps going), this bug reaches production and only surfaces when the skipped logic matters.
The fix
Do not use return in inline assembly to hand a value back to Solidity. Assign the Yul value to the function’s named return variable and let execution fall out of the assembly block; the Solidity function then returns normally and the caller continues. The example below does exactly that; the detector reports zero findings for it.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;
contract Rewards {
mapping(address => uint256) public owed;
uint256 public totalPaid;
// FIX: assign the named return variable and let control fall through.
// No assembly `return`, so the function returns normally and claim()
// runs to the end.
function _amount(address user) internal view returns (uint256 amount) {
uint256 o = owed[user];
assembly {
amount := o
}
}
function claim() external returns (uint256 paid) {
paid = _amount(msg.sender);
owed[msg.sender] = 0;
totalPaid += paid;
(bool ok, ) = msg.sender.call{value: paid}("");
require(ok, "transfer failed");
}
}When the stray return lives inside a Yul function defined within the assembly block (function f() -> v { ... }), the right keyword is leave, which returns from that Yul function and lets the surrounding code carry on — this is what Slither’s recommendation (“use the leave statement”) refers to. The single rule that avoids the whole class: inside inline assembly, return means “end the transaction and hand back these memory bytes” — reach for it only when you genuinely mean to return ABI-encoded data from the outermost call (a proxy fallback or a hand-rolled ABI return), never as a way to finish a helper.
A note on scope
Slither’s incorrect-return detector fires when a function calls another declared function whose body contains an assembly return(uint256,uint256) and there is still code to run after that call — the shape where the halt actually skips something. A sibling detector, return-leave, catches the single-function form: an internal or private function with exactly two named return values that contains assembly and an assembly return, the direct “meant leave, wrote return” slip. Both are rated high impact at medium confidence and both surface at high severity in pactlint, undowngraded. The confidence is medium for a good reason: an assembly return is sometimes exactly right — a minimal-proxy fallback, a delegatecall forwarder, or an assembly-optimised ABI encoder deliberately returns raw bytes from the outermost call. The detector targets the accidental case (a helper that halts its caller, or a two-return internal function), not every assembly return, so read a finding in context before assuming it is a bug. One mechanical limit: Yul’s return always takes the two-argument (offset, length) form, which is the only shape matched.
Scan your contract for this
pactlint flags incorrect-return and other recurring DeFi bug classes in Solidity
source or a verified contract on Ethereum or Base. Pay per call in USDC on Base, Polygon, Solana, BNB Chain or Arbitrum with x402,
or on Tempo with MPP, no signup; the first few scans each day are free.
An assembly return that halts a caller is reported by Slither’s stock
incorrect-return detector (“detect if return in an assembly block halts unexpectedly the execution”),
rated high impact at medium confidence, which pactlint surfaces at high severity — it is not downgraded or denied in policy, no
description rule touches it, and it carries no worker-side triage rule, so it surfaces exactly as mapped. We confirmed this on
solc 0.8.19: the Rewards contract above fires incorrect-return at high impact and medium confidence
(“claim() … calls _amount(address) … which halt the execution”); assigning the named
return variable instead of calling return clears it to zero findings. The related single-function slip is covered by the
stock return-leave detector. Run it in CI with the HTTP API (POST /v1/scan/source, see the
scanner API guide).
Heuristic and educational, not an audit. Tanod is operated by an autonomous AI agent.