Summary
The MultiSigWallet
contract allows any owner to create transactions to arbitrary contract addresses, which could be exploited by a malicious owner to steal funds by redirecting them through a malicious contract.
Vulnerability Details
In MultiSig.sol
, the executeTransaction
function:
Allows any owner to create and execute transactions to any address
Does not verify if the destination is a contract
Does not implement any verification of the contract's code
Relies solely on the other owner's due diligence
function executeTransaction(uint256 _txId) external onlyOwners {
require(_txId < transactions.length, "Invalid transaction ID");
Transaction storage txn = transactions[_txId];
require(!txn.executed, "Transaction already executed");
require(txn.approvedByOwner1 && txn.approvedByOwner2, "Not enough approvals");
txn.executed = true;
(bool success,) = payable(txn.to).call{value: txn.value}("");
require(success, "Transaction failed");
emit TransactionExecuted(_txId, txn.to, txn.value);
}
Impact
Direct Fund Theft : A malicious owner could redirect funds to themselves through a malicious contract
Social Engineering Risk : The other owner might approve transactions without proper verification
No Recovery Mechanism : Once funds are redirected, they cannot be recovered
Severity: MEDIUM - Requires one malicious owner and the other owner's approval, but leads to direct fund theft
Tools Used
Proof of Concept
contract FundStealerContract {
address public maliciousOwner;
constructor(address _maliciousOwner) {
maliciousOwner = _maliciousOwner;
}
receive() external payable {
payable(maliciousOwner).transfer(msg.value);
}
}
function testMaliciousOwnerFundStealing() public {
FundStealerContract stealer = new FundStealerContract(owner1);
uint256 initialOwner1Balance = owner1.balance;
vm.prank(owner1);
multiSig.submitTransaction(address(stealer), 5 ether);
vm.prank(owner1);
multiSig.approveTransaction(0);
vm.prank(owner2);
multiSig.approveTransaction(0);
vm.prank(owner1);
multiSig.executeTransaction(0);
assertEq(owner1.balance, initialOwner1Balance + 5 ether, "Funds should be redirected to owner1");
assertEq(address(stealer).balance, 0, "Malicious contract should not keep funds");
}
Recommendations
Implement contract verification and timelock:
mapping(uint256 => uint256) public contractTransactionTimelocks;
function submitTransaction(address _to, uint256 _value) external onlyOwners {
uint256 size;
assembly {
size := extcodesize(_to)
}
if (size > 0) {
uint256 txId = transactions.length;
contractTransactionTimelocks[txId] = block.timestamp + 24 hours;
emit ContractTransactionSubmitted(txId, _to, _value);
}
transactions.push(Transaction(_to, _value, false, false, false));
}
function executeTransaction(uint256 _txId) external onlyOwners {
if (contractTransactionTimelocks[_txId] > 0) {
require(
block.timestamp >= contractTransactionTimelocks[_txId],
"Contract transaction is timelocked"
);
}
}
Add contract verification requirements:
interface IVerifiableContract {
function verifyContract() external pure returns (bytes32);
}
mapping(bytes32 => bool) public approvedContractTypes;
function executeTransaction(uint256 _txId) external onlyOwners {
uint256 size;
assembly {
size := extcodesize(txn.to)
}
if (size > 0) {
try IVerifiableContract(txn.to).verifyContract() returns (bytes32 contractType) {
require(approvedContractTypes[contractType], "Contract type not approved");
} catch {
revert("Contract verification failed");
}
}
}
Implement a whitelist for contract destinations:
mapping(address => bool) public whitelistedContracts;
function addToWhitelist(address _contract) external {
require(msg.sender == owner1 && msg.sender == owner2, "Both owners must approve");
whitelistedContracts[_contract] = true;
}
function executeTransaction(uint256 _txId) external onlyOwners {
uint256 size;
assembly {
size := extcodesize(txn.to)
}
if (size > 0) {
require(whitelistedContracts[txn.to], "Contract not whitelisted");
}
}