fix(crosschain): security hardening of the cross-chain module - #695
Closed
xavikh wants to merge 2 commits into
Closed
fix(crosschain): security hardening of the cross-chain module#695xavikh wants to merge 2 commits into
xavikh wants to merge 2 commits into
Conversation
Security hardening of the WIP cross-chain module, addressing a review carried out as part of the Aragon/Makina guardian-integration planning work. - receiveMessage() is now restricted to registered local adapters (refcounted registry maintained by updateConfig, so rotation clears stale entries). - BaseAdapter._forwardMessage() is internal, not public. - Send path switched from delegatecall to call: adapters keep their own storage, so the feeToken/_trustedRemotes storage collisions are gone and UPDATE_CONFIG_PERMISSION is no longer an arbitrary-code-execution primitive in the controller's context. - forwardMessage() validates the lane (both adapters set, local adapter has code) instead of silently succeeding against address(0). - CCIPAdapter maps EVM chain ids to CCIP chain selectors in both directions, reverting on unknown ids; the mapping is constructor seeded and updatable. - Fee handling: controller custodies pre-funded native/ERC20 fees, quotes via getFee and hands the adapter exactly the fee per send; adapter forceApproves the router for ERC20 and returns any remainder. Distinct INSUFFICIENT_FEE_BALANCE error plus a quoteFee() view for off-chain top-up monitoring, and a permissioned sweep(). - Defensive receive: execution is wrapped in try/catch, failed messages are stored and retryable under RETRY_MESSAGE_PERMISSION. - Traceable call ids derived from (originChainId, messageId), and real event payloads throughout. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The module previously had no tests. Adds 74 tests covering every critical and high finding as a regression test, plus the medium/low fixes. CrossChainController.t.sol (32 tests): unauthorized receiveMessage reverts; adapter rotation and refcount correctness; updateConfig validation and events; unconfigured/codeless-adapter sends revert instead of silently succeeding; native and ERC20 fee accounting; defensive receive capturing both reverting and malformed payloads; retry authorization and success; executeActions self-call guard; sweep. CCIPAdapter.t.sol (42 tests): _forwardMessage is not externally callable; ccipReceive router/trusted-remote matrix; chain-id to CCIP selector round trips, unmapped-id reverts, re-pointing and clearing; sendMessage caller and receiver guards; ERC20 fee approve/pull/reset and leftover return; native fee exact payment; permissioned setters; assertTrustedRemotesMatchController; ERC-165; end-to-end forwardMessage -> sendMessage -> router. Mocks are used rather than chainlink-local's CCIPLocalSimulator, which requires pragma ^0.8.19 while this repo pins solc 0.8.17. The router mock pulls the ERC20 fee via transferFrom, so a missing forceApprove fails the tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
|
Opened #696 as the requested A/B counterpart to this PR: same ten security fixes, The PR body includes an explicit "What Option 1 is WORSE at than #695" section — most importantly that finding 7's residual risk returns and is not mitigated there. |
Author
|
Superseded by #696, which contains both commits from this branch plus the |
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.
Security hardening of the WIP cross-chain module
This PR fixes the findings of a security review of
src/common/crosschain/ate49e055, carried out as part of the Aragon / Makina guardian-integrationplanning work. The module had no tests; this PR adds a Foundry suite covering
every critical and high finding as a regression test.
The motivating downstream use case is a mainnet OSx DAO passing a proposal that
must cancel a pending action on a remote chain before an on-chain timelock
expires. That shapes several decisions below: silent failures are
unacceptable, and a message stranded past its deadline is a real loss.
Finding 3 — the design fork:
delegatecall→callDecision: the controller now invokes adapters with a plain
call.Rationale:
delegatecallis not repairable here. Adapter storage reads land in thecontroller's slots:
feeToken(adapter slot 1) read the controller's unsetslot 1 and returned
address(0), and_trustedRemotes(slot 0) collidedwith
chainToAdapter. The suggested alternative — make all send-path stateimmutable— is impossible for finding 5: a bidirectional chain-id ⇄ CCIPselector map must be a
mapping, and mappings cannot beimmutable. Thesame applies to the updatable trusted-remote and fee-token setters in
finding 10. Keeping
delegatecallwould mean redeploying every adapter toadd a lane.
delegatecall,UPDATE_CONFIG_PERMISSIONwas arbitrary code execution in the controller's context, and the
controller holds
EXECUTE_PERMISSION— i.e. root. Undercall, a maliciousadapter can no longer touch controller storage or spend beyond the fee it is
handed. (It can still forge inbound
receiveMessagepayloads, so thepermission remains highly privileged — now documented loudly in NatSpec.)
call, CCIP sees the adapter asthe sender, so
_trustedRemotes[chainId]holds the remote adapteraddress — which is exactly what the controller already stores as
chainToAdapter[chainId].remoteAdapter. One address, one meaning, on bothsides.
BaseAdapter.assertTrustedRemotesMatchController(uint256[])is adeployment-time check that the two sides agree.
Fee custody. Per the deployment model (one OSx DAO per chain, fees paid from
the cross-chain contract's own pre-funded balance rather than per-proposal from
the treasury), the
CrossChainControllercustodies the fee funds, not theadapters:
receive()and a permissionedsweep(token, to, amount)(nativeand ERC20) so funds are never stranded and the fee token can be rotated.
getFee, checks its own balance, and hands the adapterexactly the quoted fee — native as
msg.value, ERC20 bysafeTransferimmediately before the call. The adapter never relies on a standing balance
and returns any remainder to the controller in the same transaction.
quoteFee(destChainId, gasLimit, message)returns(feeToken, fee, available)so off-chain monitoring can answer "can we still afford a send?"and top up ahead of a deadline. This matters: with a 72h+ voting window, a
fee quoted at proposal creation can be badly stale at execution. A dedicated
INSUFFICIENT_FEE_BALANCE(feeToken, required, available)error existsprecisely so ops can alert on that one condition.
Ops note: only the sending side needs a fee balance — destination gas is
bought by the source-side fee via
extraArgs.gasLimit. In the targetdeployment only the mainnet controller needs funding; spoke controllers are
receive-only and should not be over-funded.
Findings
receiveMessage()public, no access control → unauthenticated DAO takeoveronlyLocalAdapter, backed by a refcountedlocalAdapter → laneCountregistry maintained byupdateConfig. A refcount (not a bool) is required because one adapter serves many lanes: it stays authorised until its last lane is cleared, and a rotated-out adapter loses access immediately.BaseAdapter._forwardMessage()declaredpublic→ second takeover pathinternal.delegatecallcall; see above._validatedAdapters()revertsADAPTER_NOT_CONFIGUREDwhen either adapter is unset andADAPTER_HAS_NO_CODEwhen the local adapter is codeless.updateConfigadditionally rejects half-configured lanes (INCOMPLETE_ADAPTER_CONFIG) and chain id0.chainId ⇄ uint64 selectormaps, constructor-seeded and updatable viasetChainSelectors. Both directions revert (UNKNOWN_CHAIN_ID/UNKNOWN_NATIVE_CHAIN_ID) instead of returning0. Re-pointing a chain id clears the stale reverse entry.forceApprove(router, fee)beforeccipSend, reset to0after. Native fees supported viafeeToken == address(0)(thebalanceOfcheck is now branch-correct);UNEXPECTED_NATIVE_VALUEif value is sent while an ERC20 fee token is configured. Fee is quoted withgetFeeand paid exactly.UPDATE_CONFIG_PERMISSIONeffectively rootcall(no arbitrary code in the controller's context). Residual risk — a malicious adapter can still forge inbound messages — is documented in explicit NatSpec on the permission constant: grant to the DAO only, never an EOA.try this.executeActions(...) catch; the self-call also coversabi.decode, so a malformed payload is captured too. Failures are stored and emitted asMessageExecutionFailed(originChainId, messageId, callId, reason)and retried viaretryFailedMessage(callId)under a newRETRY_MESSAGE_PERMISSION. Delivery therefore never reverts on the bridge, and recovery does not depend on CCIP's ~8h manual-execution window.chainToAdapter[].remoteAdapter. Documented onIBaseAdapter,BaseAdapterand theAdapterByChainstruct;assertTrustedRemotesMatchController()added as a deployment check.callId = keccak256(originChainId, messageId)(also the failed-message key). Real event payloads:MessageForwarded,MessageReceived,MessageExecutionFailed,MessageRetried,ConfigUpdated,TrustedRemoteSet,FeeTokenSet,ChainSelectorSet,Swept. Permissioned setterssetTrustedRemotes,setFeeToken,setChainSelectorsunder a newUPDATE_ADAPTER_CONFIG_PERMISSION.Interface changes
IBaseAdapter.sendMessageis nowpayableand returnsbytes32(the bridgemessage id) instead of
uint256;quoteFeeadded; the view functions aremarked
view.CrossChainController.receiveMessage(bytes32 messageId, bytes payload, uint256 originChainId)— gained the message id, for traceability and forcall-id derivation.
BaseAdapteris nowDaoAuthorizable, adopting the DAO of its controller, soadapter administration reuses the OSx permission system rather than
introducing a second ownership model.
CCIPAdapter's constructor takes the chain-id ⇄ selector seed arrays.For human review
UPDATE_CONFIG_PERMISSION/UPDATE_ADAPTER_CONFIG_PERMISSIONgrants.Both must go to the DAO only. Consider an allowlist of vetted adapter
implementations if adapter deployment is ever delegated.
RETRY_MESSAGE_PERMISSIONgrantee. The payload was already authenticatedby the bridge, so an ops multisig is defensible and much faster than a second
governance round before a timelock deadline — but it is a policy call.
storage with a loud event rather than reverting the bridge delivery. This is
Chainlink's recommended pattern and the right trade-off for a deadline-bound
use case, but it does mean monitoring must watch
MessageExecutionFailed.contract only provides
quoteFeeandINSUFFICIENT_FEE_BALANCE.verified against Chainlink's directory at deployment.
forwardMessage; the adapter is aDAO-configured trusted component and the controller performs no state
mutation around the call. Worth a second opinion.
Attribution: review and fixes produced during the Aragon/Makina
guardian-integration planning work.
🤖 Generated with Claude Code