[#3448] Harden MultiSignatureWallet and two-step the beacon - #46
Conversation
There was a problem hiding this comment.
Ran forge test --match-contract MultiSignatureWalletTest at dec1f069: 94/94 pass. The epoch-stamping design is sound — the membership-first check in _isOwnerConfirmationValid, the remove/re-add and threshold-decrease coverage, and the cancel-self test all hold up.
Two items I'd like addressed before merge, plus smaller points inline.
Beacon renounceOwnership() (inline at MultisigBeacon.sol:56). Still reachable after the Ownable2Step switch; it is one of the items bluealloy#3448 lists, and this PR closes that issue.
Commit message. Two bullets in the body describe the behaviour the code no longer has ("… no longer counts if the address is later re-added", "rather than reusing confirmations gathered under a higher threshold"). Per smr-moonshot CONTRIBUTING.md → What Comments and Commit Messages May Say About a Vulnerability (which applies to this fork), commit messages state the rule now enforced and reference the issue for the rest — e.g. "Bind each confirmation to the owner incarnation and threshold under which it was cast", keeping the existing See #3448. This repo is public, so the message ships. The in-tree comments and test names are fine as written.
Not raised: the ExecutionFailed-only expectations on the nested admin-call tests (consistent with the nine existing tests of that shape, and mutation-sensitive), and views reverting on expiry (documented and tested as the intended behaviour).
| * - msg.sender must be the owner of the contract. | ||
| * - `newImplementation` must be a contract. | ||
| */ | ||
| function upgradeTo(address newImplementation) public virtual onlyOwner { |
There was a problem hiding this comment.
Ownable2Step overrides only transferOwnership, so Ownable.renounceOwnership() is still callable here and would leave the beacon with no owner and upgradeTo permanently unreachable — the outcome the two-step change is guarding against, and one bluealloy#3448 lists explicitly. A short override that reverts (plus a test) closes it:
function renounceOwnership() public view override onlyOwner {
revert OwnershipRenunciationDisabled();
}| * current owner keeps control of the beacon until that happens. | ||
| */ | ||
| contract MultisigBeacon is UpgradeableBeacon { | ||
| contract MultisigBeacon is IBeacon, Ownable2Step { |
There was a problem hiding this comment.
Non-blocking: this re-implements OZ's UpgradeableBeacon by hand (~50 lines) to get Ownable2Step. contract MultisigBeacon is UpgradeableBeacon, Ownable2Step with the two override(Ownable, Ownable2Step) forwarders for transferOwnership/_transferOwnership gives the same ABI in ~12 lines and tracks OZ on library bumps. I checked that form: compiles on 0.8.34 against the vendored OZ and the full suite passes 94/94 with only the two upgradeTo.selector references in the test switching back to UpgradeableBeacon.
| /// @param _txIndex Index of the transaction. | ||
| function revokeConfirmation(uint256 _txIndex) external; | ||
|
|
||
| /// @notice Removes an already-expired transaction from storage. Callable by anyone. |
There was a problem hiding this comment.
The interface NatSpec for confirmTransaction (L137), executeTransaction (L141) and revokeConfirmation (L146) still says the transaction is "removed if expired"; those now revert with TransactionAlreadyExpired and this function is the cleanup path. The contract's own @dev lines were updated — these should match. Likewise notExpired's @dev in the contract says it "reverts as though the transaction doesn't exist", but it uses a different error from txExists.
| } | ||
| if (!confirmations[_txIndex].contains(msg.sender)) revert TransactionNotConfirmed(); | ||
| notExpired(_txIndex); | ||
| if (!_isOwnerConfirmationValid(_txIndex, msg.sender)) revert TransactionNotConfirmed(); |
There was a problem hiding this comment.
Minor: gating revoke on the full validity predicate means an owner whose stamp was invalidated (threshold lowered, or removed and re-added) gets TransactionNotConfirmed and can never emit RevokeConfirmation, while staying a member of confirmations[_txIndex]. Not a safety issue — the stale member is never counted — but semantically off. Gating on confirmations[_txIndex].contains(msg.sender) and clearing the stamps would let them clean up.
| // Trim the array to the addresses actually written before emitting, so the event | ||
| // doesn't carry trailing address(0) entries for no-op inputs. | ||
| assembly { | ||
| mstore(ownersToUpdate, c) |
There was a problem hiding this comment.
Two small things on the event trimming (here and at L353):
- No test asserts the
OwnersAdded/OwnersRemovedpayload, so the trimming — the behavioural change this block exists for — is unguarded. AnexpectEmitwith the exact array in the add/remove tests would cover it. - Under
via_ir = truethese blocks should carryassembly ("memory-safe")(shrinking a Solidity-owned array's length is memory-safe). solc only honours the annotation if every block in the contract has it, so the existing block indeployContractneeds it too.
|
|
||
| confirmations[_txIndex].add(msg.sender); | ||
| ownerConfirmationEpoch[_txIndex][msg.sender] = ownerEpoch[msg.sender]; | ||
| confirmationTxEpoch[_txIndex][msg.sender] = currentEpoch; |
There was a problem hiding this comment.
Nit: this three-line stamping block is duplicated verbatim in submitTransaction. A private _recordConfirmation(uint256 _txIndex, address owner) mirroring removeTransaction keeps the two paths from drifting. Relatedly, executeTransaction calls the public hasValidNumberOfConfirmations, which re-runs the txExists/notExpired guards it just executed; validNumberOfConfirmations(_txIndex) >= numConfirmationsRequired directly would do.
…eanup - Disable MultisigBeacon.renounceOwnership, closing the freeze-forever path Ownable2Step alone didn't cover. - Reimplement MultisigBeacon on UpgradeableBeacon + Ownable2Step with override forwarders instead of hand-rolling the beacon, tracking upstream OZ. - Sync IMultiSignatureWallet's NatSpec for confirm/execute/revoke with the revert-on-expiry behaviour, and make notExpired's comment precise about its distinct error. - Gate revokeConfirmation on raw membership so an owner can still clear a stale confirmation instead of being permanently stuck as an unrevocable member. - Extract _recordConfirmation to stop submitTransaction and confirmTransaction's stamping logic from drifting apart, and call validNumberOfConfirmations directly in executeTransaction instead of re-running guards hasValidNumberOfConfirmations already repeats. - Mark the array-trim and CREATE assembly blocks memory-safe, required for correctness under this project's via_ir = true. - Add test coverage for all of the above, including exact OwnersAdded/OwnersRemoved event payloads. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…the beacon - Bind each confirmation to the owner incarnation and threshold under which it was cast. - Add a multisig-gated cancelTransaction and a permissionless removeExpiredTransaction for pending-transaction lifecycle management. - Bound submission timeouts via a new, multisig-settable maxTimeoutDuration. - Confirming, executing, or revoking an expired transaction now reverts. - Remove the dead numConfirmations field and trim the OwnersAdded/OwnersRemoved event payloads to actual entries. - Switch MultisigBeacon to two-step ownership transfer (Ownable2Step) instead of single-step Ownable. See bluealloy#3448. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…eanup - Disable MultisigBeacon.renounceOwnership, closing the freeze-forever path Ownable2Step alone didn't cover. - Reimplement MultisigBeacon on UpgradeableBeacon + Ownable2Step with override forwarders instead of hand-rolling the beacon, tracking upstream OZ. - Sync IMultiSignatureWallet's NatSpec for confirm/execute/revoke with the revert-on-expiry behaviour, and make notExpired's comment precise about its distinct error. - Gate revokeConfirmation on raw membership so an owner can still clear a stale confirmation instead of being permanently stuck as an unrevocable member. - Extract _recordConfirmation to stop submitTransaction and confirmTransaction's stamping logic from drifting apart, and call validNumberOfConfirmations directly in executeTransaction instead of re-running guards hasValidNumberOfConfirmations already repeats. - Mark the array-trim and CREATE assembly blocks memory-safe, required for correctness under this project's via_ir = true. - Add test coverage for all of the above, including exact OwnersAdded/OwnersRemoved event payloads. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
a140da1 to
c4af829
Compare
isaacdoidge
left a comment
There was a problem hiding this comment.
Re-reviewed at c4af829b. Every item from the first round is addressed:
MultisigBeacon.renounceOwnership()now reverts withOwnershipRenunciationDisabled, with a test driving it through the multisig.- Beacon is back on
UpgradeableBeacon+Ownable2Stepwith the twooverride(Ownable, Ownable2Step)forwarders; the two test selectors moved back toUpgradeableBeacon.upgradeTo. - Interface NatSpec for confirm/execute/revoke matches the revert-on-expiry behaviour, and
notExpired's@devnow says why it uses its own error. revokeConfirmationgates on raw set membership so a stale entry can be cleared; covered bytestRevokeConfirmationClearsStaleConfirmation._recordConfirmationshared by submit/confirm;executeTransactioncomparesvalidNumberOfConfirmationsdirectly.- All three
assemblyblocks (includingdeployContract) carry("memory-safe"); the trimmedOwnersAdded/OwnersRemovedpayloads are asserted exactly. - The first commit's message now states the enforced rule ("Bind each confirmation to the owner incarnation and threshold under which it was cast") and defers to
See #3448.
Verified locally: forge test at c4af829b → 453/453 across 12 suites, 99/99 in MultiSignatureWalletTest.
One optional wording nit, not worth another round: the follow-up commit's body still has two clauses phrased as what the code used to do ("…the freeze-forever path Ownable2Step alone didn't cover", "instead of being permanently stuck as an unrevocable member"). If the branch gets rebased before merge, trimming those to the rule keeps the public history clean; if it merges as-is, fine.
Summary
MultiSignatureWallet, plus five secondary issues from the same audit finding.cancelTransactionand a permissionlessremoveExpiredTransaction, bounds submission timeouts via a new multisig-settablemaxTimeoutDuration, makes touching an expired transaction revert instead of succeeding, removes the deadnumConfirmationsfield, trimsOwnersAdded/OwnersRemovedevent payloads, and switchesMultisigBeaconto two-step ownership (Ownable2Step).Motivation
Audit finding smr-moonshot#3448 (High, confirmed) found that
MultiSignatureWallet— the sole governance authority over every EVM system contract in this fork — had two ways a quorum could be satisfied unintentionally. See the issue for the full write-up.Test plan
forge build— compiles clean, no new warnings on the changed files.forge test— full repo suite green (459/459 across 13 suites), including 94/94 inMultiSignatureWalletTest(new tests added for both fixed exploit chains, cancel/expiry/timeout mechanics, and beacon two-step ownership; 7 existing tests updated for the new revert-on-expiry behavior).Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com