Single-transaction value-based delay logic can be bypassed via transaction splitting
Description
The _executeTransaction logic only validates the timelock for individual transfers and fails to track the cumulative value of multiple transactions over time. This allows an attacker to bypass long delay requirements by splitting one large high-risk transfer into several small transactions. Because each small transaction stays below the value threshold, the security window is never triggered, allowing significant funds to be drained much faster than intended.
Risk
Likelihood:
Although only the Owner can propose, the risk becomes reality if the Owner turns malicious or their private key is compromised.
Impact:
completely nullifies the Timelock protection, allowing the treasury to be drained instantly and depriving the protocol of the intended emergency response window.
Proof of Concept
This test splits a single large 118.8 ETH transfer into 12 small transactions to bypass the 7-day delay. By keeping each transaction under the value threshold, it forces the contract to apply a 1-day delay instead, allowing the total funds to be drained 6 days earlier than intended.
place the following code in MultiSigTimelockTest.t.sol:
Proof of Code
function testproposeSplitTransactions() public grantSigningRoles {
vm.deal(address(multiSigTimelock), 150 ether);
address recipient = makeAddr("recipient");
uint256 amountToSend = 9.9 ether;
uint256 timesToSend = 12;
uint256 BalanceRecipientBefore = recipient.balance;
vm.prank(OWNER);
for(uint256 i=0;i<timesToSend;i++){
multiSigTimelock.proposeTransaction(recipient,amountToSend,"i");
}
address[3] memory signersToConfirm = [OWNER, SIGNER_TWO, SIGNER_THREE];
for(uint256 s=0; s < signersToConfirm.length; s++) {
vm.startPrank(signersToConfirm[s]);
for(uint256 i=0; i < timesToSend; i++) {
multiSigTimelock.confirmTransaction(i);
}
vm.stopPrank();
}
vm.warp(block.timestamp + 1 days);
vm.prank(OWNER);
for(uint256 i=0;i<timesToSend;i++){
multiSigTimelock.executeTransaction(i);
}
uint256 BalanceRecipientAfter = recipient.balance;
assertEq(BalanceRecipientAfter,BalanceRecipientBefore + amountToSend*timesToSend);
console2.log("BalanceRecipientAfter:", BalanceRecipientAfter);
}
## Recommended Mitigation
Implement a global s_totalPendingAmount to track cumulative outflow within a rolling 24-hour window. The logic ensures that the delay is determined by the total volume of all pending and recent transactions, preventing attackers from bypassing long security delays by splitting large transfers into multiple small ones.
+ uint256 public s_totalPendingAmount;
+ uint256 public s_lastResetTime;
- function _getTimelockDelay(uint256 value) public pure returns (uint256) {
+ function _getTimelockDelay(uint256 value) public returns (uint256) {
+ if (block.timestamp >= s_lastResetTime + 24 hours) {
+ s_totalPendingAmount = 0;
+ s_lastResetTime = block.timestamp;
+ }
+ s_totalPendingAmount += value;
uint256 sevenDaysTimeDelayAmount = 100 ether;
uint256 twoDaysTimeDelayAmount = 10 ether;
uint256 oneDayTimeDelayAmount = 1 ether;
- if (value >= sevenDaysTimeDelayAmount) {
+ if (s_totalPendingAmount >= sevenDaysTimeDelayAmount) {
return SEVEN_DAYS_TIME_DELAY;
- } else if (value >= twoDaysTimeDelayAmount) {
+ } else if (s_totalPendingAmount >= twoDaysTimeDelayAmount) {
return TWO_DAYS_TIME_DELAY;
- } else if (value >= oneDayTimeDelayAmount) {
+ } else if (s_totalPendingAmount >= oneDayTimeDelayAmount) {
return ONE_DAY_TIME_DELAY;
} else {
return NO_TIME_DELAY;
}
}
}