fix: detect assembly delegatecall in controlled-delegatecall - #3088
Open
BhariGowda wants to merge 1 commit into
Open
fix: detect assembly delegatecall in controlled-delegatecall#3088BhariGowda wants to merge 1 commit into
BhariGowda wants to merge 1 commit into
Conversation
controlled_delegatecall() only walked function.low_level_calls. Inline assembly calls are parsed as SolidityCall, not LowLevelCall, so a delegatecall or callcode written in assembly was never considered, no matter how the destination was derived. Slither reported the source form and stayed silent on the assembly form of the same contract. Walk function.solidity_calls as well and taint-check the callee address, which is the second argument of both the assembly delegatecall and the assembly callcode. locked_ether.py already matches assembly calls this way. This makes assembly consistent with the existing source-level behaviour, including its false positives: a proxy that keeps its implementation in a plain state variable is already reported today when the fallback uses addr.delegatecall(msg.data), and will now also be reported when the fallback uses assembly. Proxies that load the implementation from a fixed EIP-1967 slot are not affected, since the slot is a constant and the loaded value is not tainted. Assembly is only parsed into Yul nodes from solc 0.6.0 on, so the new fixture covers 0.6.11, 0.7.6 and 0.8.15. It pairs a tainted destination with a constant one to pin that the constant case stays unreported.
|
Reviewed this and it looks correct to me. Verified the three new cases pass, the full detector suite is 387 passed with no existing snapshot modified, and it merges cleanly with #3087 in either order (391 pass with both applied). I also reproduced your false-positive table — EIP-1967 and hardcoded-address proxies stay clean, the naive Tagging @smonicas for the remaining issues: whether to keep the new |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
controlled-delegatecallreports adelegatecallorcallcodewhose destination is tainted by user input. It only ever looked at source-level calls, so the same contract written with inline assembly produced no finding at all.Root cause
controlled_delegatecall()walkedfunction.low_level_callsonly:Inline assembly
delegatecallandcallcodeare parsed into Yul nodes and becomeSolidityCalloperations, notLowLevelCall(slither/solc_parsing/yul/parse_yul.py:46). They are therefore absent fromfunction.low_level_callsentirely, so the loop never saw them regardless of how the destination was derived. There was no taint decision being made and rejected, the call simply was not in the collection being iterated.Measured on the same contract written both ways with solc 0.8.15: the source-level form is reported on master, the assembly
delegatecalland the assemblycallcodeforms are both silent.slither/detectors/attributes/locked_ether.py:71-86already matches assembly calls by checkingSolidityCallagainst the Yul signatures alongside theLowLevelCallnames. This PR does the same here.Changes
slither/detectors/statements/controlled_delegatecall.py: add a second loop overfunction.solidity_callsmatching the two Yul signatures, taint-checking the callee address. That address is argument index 1 in both, since Yuldelegatecall(g, a, in, insize, out, outsize)andcallcode(g, a, v, in, insize, out, outsize)agree on the position. The existing low-level loop and the reporting are unchanged, and the existing name list is pulled out as a constant for symmetry with the new one.Scope decision on proxy fallbacks, stated up front
Assembly
delegatecallis how essentially every proxy is written, so I measured the false-positive surface before proposing this. Five shapes on solc 0.8.15:delegatecall, destination from a function parametercallcode, destination from a function parameterdelegatecallto a hardcoded addresssloadof a constant slotaddress public implementationplussload(implementation.slot)in the fallbackThe last row is the one that matters. OpenZeppelin-style proxies and anything else reading a fixed EIP-1967 slot are unaffected, because the slot is a constant and the loaded value is not tainted. A proxy holding its implementation in an ordinary state variable is reported.
That is not new noise introduced by this PR. I checked the equivalent contract on unmodified master, with an admin-gated
upgradeToand a fallback doingimplementation.delegatecall(msg.data)at source level, and master already reports it today. This change makes the assembly form behave the same as the source form rather than creating a new class of finding, which is also consistent with the detector'sMEDIUMconfidence.I deliberately did not extend the existing
if contract.is_upgradeable_proxy and f.is_protected(): continueguard with anf.is_fallbackcase. A proxy's fallback is unprotected by construction, so that suppression would fire on every proxy, including one whoseupgradeTohas no access control at all, which is a real and serious bug this detector should keep reporting. Narrowing the fallback case is a separate judgement call about the detector's existing source-level behaviour and belongs in its own change, not smuggled in behind a parsing fix.Fixtures
tests/e2e/detectors/test_data/controlled-delegatecall/{0.6.11,0.7.6,0.8.15}/assembly_delegatecall.sol, following the existing fixture's good/bad shape: a state variable tainted frommsg.senderused as the destination of both an assemblydelegatecalland an assemblycallcode, plus a constant-initialised state variable used the same way. The snapshots contain the two tainted cases and not the constant one, so the fixture pins that the taint check is still doing work and the arm is not matching unconditionally.Same pre-0.6 limitation as the sibling change in #3087: with solc below 0.6.0 the whole assembly block arrives as a single node holding the source as one opaque string in
node.inline_asm, so there are no Yul nodes and noSolidityCallto match. That parsing gap is tracked in #1775. The fixtures therefore start at 0.6.11.This detector had no 0.8.x fixture at all, stopping at 0.7.6, so a
0.8.15/directory is added. Happy to drop that and keep the fixture to the two existing versions if you would rather not widen the matrix in a bug fix.Test plan
Run with
uvper CONTRIBUTING.md.pytest tests/e2e/detectors/test_detectors.py -k ControlledDelegateCallon master: 4 passed.pytest tests/e2e/detectors/test_detectors.py: 384 passed on master, 387 passed with the change. No existing snapshot modified.ruff check slither tests: clean.ruff format --checkon both touched Python files: already formatted.pytest testson both master and this branch produces an identical set of 6 failures and 10 errors, all vyper cases with no vyper compiler installed plus the tworead-storagetests that need a node.Sibling PR: #3087 fixes the same
SolidityCallversusLowLevelCallgap inunprotected-upgrade. The two are independent and can be reviewed in either order.