Description
-
When revoking a signer, the contract should locate the signer’s index in s_signers and remove that specific address, keeping the array/mapping consistent. If the signer is not present in the array, the operation must revert.
-
revokeSigningRole uses indexToRemove = type(uint256).max as a sentinel during the search loop but never checks that the index was found. If s_isSigner[_account] == true while _account is not present in s_signers (array/mapping desynchronization), the function skips the swap and still “pops” the last element, clearing a different signer and decrementing s_signerCount. This produces a silent, wrong removal.
function revokeSigningRole(address _account) external nonReentrant onlyOwner noneZeroAddress(_account) {
if (!s_isSigner[_account]) {
revert MultiSigTimelock__AccountIsNotASigner();
}
uint256 indexToRemove = type(uint256).max;
for (uint256 i = 0; i < s_signerCount; i++) {
if (s_signers[i] == _account) {
indexToRemove = i;
break;
}
}
if (indexToRemove < s_signerCount - 1) {
s_signers[indexToRemove] = s_signers[s_signerCount - 1];
}
s_signers[s_signerCount - 1] = address(0);
s_signerCount -= 1;
s_isSigner[_account] = false;
_revokeRole(SIGNING_ROLE, _account);
}
Risk
Likelihood: Low
-
Whenever state becomes inconsistent (e.g., due to prior bugs, manual storage edits in tests, migrations, or a future refactor that forgets to update both structures), calling revokeSigningRole on the “phantom” signer will silently remove the wrong (last) signer.
-
This can also occur in edge cases during incident response or tooling that directly manipulates storage.
Impact: Medium
-
ncorrect signer removal & governance disruption: A valid signer is removed unexpectedly, reducing s_signerCount and potentially dropping the set below quorum (see prior deadlock bug).
-
Stealth failure: No revert or event indicates the wrong target was removed, making forensics and recovery harder.
Proof of Concept
The inconsistent state (mapping says true, array lacks the address) cannot be reached via the public API alone, but it can be reproduced with a Foundry cheatcode (vm.store) or any migration script that accidentally desynchronizes storage. Once desynchronized, calling revokeSigningRole(phantomSigner) will “pop” the last signer rather than the target, without reverting.
pragma solidity ^0.8.19;
import {Test, console2} from "forge-std/Test.sol";
import {MultiSigTimelock} from "src/MultiSigTimelock.sol";
* PoC: "Swap-and-pop without index validation removes unintended signer".
*
* Strategy:
* - Add four signers so the fixed array has: [owner, S2, S3, PHANTOM, S4].
* - Find the base storage slot of s_signers[] by scanning for the contiguous pattern.
* - Zero-out only the PHANTOM array slot (keep mapping/role TRUE via grantSigningRole).
* - Call revokeSigningRole(PHANTOM); since PHANTOM is not in the array, the code
* will clear the LAST slot and decrement count — removing S4 instead (wrong signer).
*/
contract POC1_SilentWrongRemoval is Test {
MultiSigTimelock private ms;
address private OWNER = address(this);
address private SIGNER_TWO = address(0xB0B);
address private SIGNER_THREE = address(0xC0C);
address private PHANTOM = address(0xD00D);
address private SIGNER_FOUR = address(0xE0E);
function setUp() public {
ms = new MultiSigTimelock();
assertEq(ms.getSignerCount(), 1);
ms.grantSigningRole(SIGNER_TWO);
ms.grantSigningRole(SIGNER_THREE);
ms.grantSigningRole(PHANTOM);
ms.grantSigningRole(SIGNER_FOUR);
assertEq(ms.getSignerCount(), 5, "expect max 5 signers");
address[5] memory signers = ms.getSigners();
assertEq(signers[0], OWNER);
assertEq(signers[1], SIGNER_TWO);
assertEq(signers[2], SIGNER_THREE);
assertEq(signers[3], PHANTOM);
assertEq(signers[4], SIGNER_FOUR);
}
* Find the base storage slot of the fixed array s_signers[5] by scanning for
* a contiguous pattern of the five known addresses across storage slots.
* This avoids relying on inheritance layout / base contract slots.
*/
function _findSignersBaseSlot() internal view returns (uint256 base) {
for (uint256 s = 0; s < 2048; s++) {
if (_slotHasAddress(s, OWNER) &&
_slotHasAddress(s + 1, SIGNER_TWO) &&
_slotHasAddress(s + 2, SIGNER_THREE) &&
_slotHasAddress(s + 3, PHANTOM) &&
_slotHasAddress(s + 4, SIGNER_FOUR))
{
return s;
}
}
revert("signers base slot not found");
}
function _slotHasAddress(uint256 slot, address addr) internal view returns (bool) {
bytes32 raw = vm.load(address(ms), bytes32(slot));
address decoded = address(uint160(uint256(raw)));
return decoded == addr;
}
function test_poc1_SilentWrongRemoval() public {
console2.log("Pre signerCount:", ms.getSignerCount());
uint256 base = _findSignersBaseSlot();
console2.log("Found s_signers base slot:", base);
vm.store(address(ms), bytes32(base + 3), bytes32(uint256(0)));
{
address[5] memory signers = ms.getSigners();
assertEq(signers[0], OWNER, "index0 owner");
assertEq(signers[1], SIGNER_TWO, "index1 S2");
assertEq(signers[2], SIGNER_THREE, "index2 S3");
assertEq(signers[3], address(0), "index3 zeroed PHANTOM");
assertEq(signers[4], SIGNER_FOUR, "index4 S4");
}
bytes32 SIGNING_ROLE = ms.getSigningRole();
assertTrue(ms.hasRole(SIGNING_ROLE, PHANTOM), "PHANTOM still has role (mapping true)");
ms.revokeSigningRole(PHANTOM);
uint256 afterCount = ms.getSignerCount();
console2.log("Post signerCount:", afterCount);
assertEq(afterCount, 4, "count decreased by 1");
address[5] memory afterSigners = ms.getSigners();
assertEq(afterSigners[0], OWNER, "owner intact");
assertEq(afterSigners[1], SIGNER_TWO, "S2 intact");
assertEq(afterSigners[2], SIGNER_THREE, "S3 intact");
assertEq(afterSigners[3], address(0), "index3 remains zero");
assertEq(afterSigners[4], address(0), "index4 cleared (popped last signer)");
assertTrue(ms.hasRole(SIGNING_ROLE, SIGNER_FOUR), "S4 still has role (state inconsistency)");
assertFalse(ms.hasRole(SIGNING_ROLE, PHANTOM), "PHANTOM role revoked");
}
}
Output:
[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/unit/poc1.t.sol:POC1_SilentWrongRemoval
[PASS] test_poc1_SilentWrongRemoval() (gas: 90285)
Logs:
Pre signerCount: 5
Found s_signers base slot: 3
Post signerCount: 4
Traces:
[107485] POC1_SilentWrongRemoval::test_poc1_SilentWrongRemoval()
├─ [2543] MultiSigTimelock::getSignerCount() [staticcall]
│ └─ ← [Return] 5
├─ [0] console::log("Pre signerCount:", 5) [staticcall]
│ └─ ← [Stop]
├─ [0] VM::load(MultiSigTimelock: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], 0x0000000000000000000000000000000000000000000000000000000000000000) [staticcall]
│ └─ ← [Return] 0x0000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496
├─ [0] VM::load(MultiSigTimelock: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], 0x0000000000000000000000000000000000000000000000000000000000000001) [staticcall]
│ └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000000
├─ [0] VM::load(MultiSigTimelock: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], 0x0000000000000000000000000000000000000000000000000000000000000001) [staticcall]
│ └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000000
├─ [0] VM::load(MultiSigTimelock: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], 0x0000000000000000000000000000000000000000000000000000000000000002) [staticcall]
│ └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000005
├─ [0] VM::load(MultiSigTimelock: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], 0x0000000000000000000000000000000000000000000000000000000000000003) [staticcall]
│ └─ ← [Return] 0x0000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496
├─ [0] VM::load(MultiSigTimelock: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], 0x0000000000000000000000000000000000000000000000000000000000000004) [staticcall]
│ └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000b0b
├─ [0] VM::load(MultiSigTimelock: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], 0x0000000000000000000000000000000000000000000000000000000000000005) [staticcall]
│ └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000c0c
├─ [0] VM::load(MultiSigTimelock: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], 0x0000000000000000000000000000000000000000000000000000000000000006) [staticcall]
│ └─ ← [Return] 0x000000000000000000000000000000000000000000000000000000000000d00d
├─ [0] VM::load(MultiSigTimelock: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], 0x0000000000000000000000000000000000000000000000000000000000000007) [staticcall]
│ └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000e0e
├─ [0] console::log("Found s_signers base slot:", 3) [staticcall]
│ └─ ← [Stop]
├─ [0] VM::store(MultiSigTimelock: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], 0x0000000000000000000000000000000000000000000000000000000000000006, 0x0000000000000000000000000000000000000000000000000000000000000000)
│ └─ ← [Return]
├─ [3290] MultiSigTimelock::getSigners() [staticcall]
│ └─ ← [Return] [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496, 0x0000000000000000000000000000000000000B0b, 0x0000000000000000000000000000000000000C0C, 0x0000000000000000000000000000000000000000, 0x0000000000000000000000000000000000000e0E]
├─ [0] VM::assertEq(POC1_SilentWrongRemoval: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], POC1_SilentWrongRemoval: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], "index0 owner") [staticcall]
│ └─ ← [Return]
├─ [0] VM::assertEq(0x0000000000000000000000000000000000000B0b, 0x0000000000000000000000000000000000000B0b, "index1 S2") [staticcall]
│ └─ ← [Return]
├─ [0] VM::assertEq(0x0000000000000000000000000000000000000C0C, 0x0000000000000000000000000000000000000C0C, "index2 S3") [staticcall]
│ └─ ← [Return]
├─ [0] VM::assertEq(0x0000000000000000000000000000000000000000, 0x0000000000000000000000000000000000000000, "index3 zeroed PHANTOM") [staticcall]
│ └─ ← [Return]
├─ [0] VM::assertEq(0x0000000000000000000000000000000000000e0E, 0x0000000000000000000000000000000000000e0E, "index4 S4") [staticcall]
│ └─ ← [Return]
├─ [465] MultiSigTimelock::getSigningRole() [staticcall]
│ └─ ← [Return] 0x8d12c52fe7c286acb23e8b6fad43ba0a7b1bdee59ad6818c044e478cc487c15b
├─ [3166] MultiSigTimelock::hasRole(0x8d12c52fe7c286acb23e8b6fad43ba0a7b1bdee59ad6818c044e478cc487c15b, 0x000000000000000000000000000000000000D00d) [staticcall]
│ └─ ← [Return] true
├─ [0] VM::assertTrue(true, "PHANTOM still has role (mapping true)") [staticcall]
│ └─ ← [Return]
├─ [26276] MultiSigTimelock::revokeSigningRole(0x000000000000000000000000000000000000D00d)
│ ├─ emit RoleRevoked(role: 0x8d12c52fe7c286acb23e8b6fad43ba0a7b1bdee59ad6818c044e478cc487c15b, account: 0x000000000000000000000000000000000000D00d, sender: POC1_SilentWrongRemoval: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496])
│ └─ ← [Stop]
├─ [543] MultiSigTimelock::getSignerCount() [staticcall]
│ └─ ← [Return] 4
├─ [0] console::log("Post signerCount:", 4) [staticcall]
│ └─ ← [Stop]
├─ [0] VM::assertEq(4, 4, "count decreased by 1") [staticcall]
│ └─ ← [Return]
├─ [3290] MultiSigTimelock::getSigners() [staticcall]
│ └─ ← [Return] [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496, 0x0000000000000000000000000000000000000B0b, 0x0000000000000000000000000000000000000C0C, 0x0000000000000000000000000000000000000000, 0x0000000000000000000000000000000000000000]
├─ [0] VM::assertEq(POC1_SilentWrongRemoval: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], POC1_SilentWrongRemoval: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], "owner intact") [staticcall]
│ └─ ← [Return]
├─ [0] VM::assertEq(0x0000000000000000000000000000000000000B0b, 0x0000000000000000000000000000000000000B0b, "S2 intact") [staticcall]
│ └─ ← [Return]
├─ [0] VM::assertEq(0x0000000000000000000000000000000000000C0C, 0x0000000000000000000000000000000000000C0C, "S3 intact") [staticcall]
│ └─ ← [Return]
├─ [0] VM::assertEq(0x0000000000000000000000000000000000000000, 0x0000000000000000000000000000000000000000, "index3 remains zero") [staticcall]
│ └─ ← [Return]
├─ [0] VM::assertEq(0x0000000000000000000000000000000000000000, 0x0000000000000000000000000000000000000000, "index4 cleared (popped last signer)") [staticcall]
│ └─ ← [Return]
├─ [3166] MultiSigTimelock::hasRole(0x8d12c52fe7c286acb23e8b6fad43ba0a7b1bdee59ad6818c044e478cc487c15b, 0x0000000000000000000000000000000000000e0E) [staticcall]
│ └─ ← [Return] true
├─ [0] VM::assertTrue(true, "S4 still has role (state inconsistency)") [staticcall]
│ └─ ← [Return]
├─ [1166] MultiSigTimelock::hasRole(0x8d12c52fe7c286acb23e8b6fad43ba0a7b1bdee59ad6818c044e478cc487c15b, 0x000000000000000000000000000000000000D00d) [staticcall]
│ └─ ← [Return] false
├─ [0] VM::assertFalse(false, "PHANTOM role revoked") [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.59ms (610.70µs CPU time)
Ran 1 test suite in 17.76ms (1.59ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Recommended Mitigation
+ error MultiSigTimelock__AccountNotFoundInArray(address account);
function revokeSigningRole(address _account) external nonReentrant onlyOwner noneZeroAddress(_account) {
if (!s_isSigner[_account]) {
revert MultiSigTimelock__AccountIsNotASigner();
}
uint256 indexToRemove = type(uint256).max;
for (uint256 i = 0; i < s_signerCount; i++) {
if (s_signers[i] == _account) {
indexToRemove = i;
break;
}
}
+ // Require that the signer exists in the array to avoid popping the wrong element
+ if (indexToRemove == type(uint256).max) {
+ revert MultiSigTimelock__AccountNotFoundInArray(_account);
+ }
if (indexToRemove < s_signerCount - 1) {
s_signers[indexToRemove] = s_signers[s_signerCount - 1];
}
s_signers[s_signerCount - 1] = address(0);
s_signerCount -= 1;
s_isSigner[_account] = false;
_revokeRole(SIGNING_ROLE, _account);
}