Description
The MultiSigWallet::submitTransaction function doesn't have any check for the maximum amount (_value) that an owner can withdraw from the contract, and also there is no limit on the number of transactions that an owner can submit. Although, the transaction can't be executed until the MultiSigWallet::approveTransaction function is being called by both the owners, so the owner can optionally decide not to approve the transaction submitted by the other owner, but it would still impose the problem of either a lot of unapproved transactions in the queue (i.e., transactions array) or the owner accidentally approving the transaction without inspecting the values of the underlying fields manually.
Impact
Either of the two owners can drain the MultiSigWallet contract, causing the other owner losing their funds.
Proof of Concept
Once a match is being made and the MultiSigWallet contract is deployed and funded with ether.
Either of the two owners (i.e., users corresponding to the match) of the MultiSigWallet contract can call MultiSigWallet::submitTransaction function any number of times and by passing any _value greater than zero.
If the other owner accidentally calls the MultiSigWallet::approveTransaction function without inspecting the values of the underlying fields manually, then it could result in the draining of funds from the MultiSigWallet contract.
Proof of Code
Since there isn't a way to track MultiSigWallet contract address corresponding to the match, therefore I've added the return statements in the LikeRegistry::likeUser and LikeRegistry::matchRewards functions, basically a kind of mutation testing.
So, the revised LikeRegistry::likeUser and LikeRegistry::matchRewards functions would be:
function likeUser(address liked) external payable returns(address matchToMultiSigWallet) {
require(msg.value >= 1 ether, "Must send at least 1 ETH");
require(!likes[msg.sender][liked], "Already liked");
require(msg.sender != liked, "Cannot like yourself");
require(profileNFT.profileToToken(msg.sender) != 0, "Must have a profile NFT");
require(profileNFT.profileToToken(liked) != 0, "Liked user must have a profile NFT");
likes[msg.sender][liked] = true;
userBalances[liked] += 1 ether;
emit Liked(msg.sender, liked);
if (likes[liked][msg.sender]) {
matches[msg.sender].push(liked);
matches[liked].push(msg.sender);
emit Matched(msg.sender, liked);
matchToMultiSigWallet = matchRewards(liked, msg.sender);
}
}
function matchRewards(address from, address to) internal returns(address) {
uint256 matchUserOne = userBalances[from];
uint256 matchUserTwo = userBalances[to];
userBalances[from] = 0;
userBalances[to] = 0;
uint256 totalRewards = matchUserOne + matchUserTwo;
uint256 matchingFees = (totalRewards * FIXEDFEE) / 100;
uint256 rewards = totalRewards - matchingFees;
totalFees += matchingFees;
MultiSigWallet multiSigWallet = new MultiSigWallet(from, to);
(bool success,) = payable(address(multiSigWallet)).call{value: rewards}("");
require(success, "Transfer failed");
return address(multiSigWallet);
}
Then, create a test file named as DatingDappTest.t.sol inside the test folder with the following content:
pragma solidity 0.8.20;
import {Test} from "forge-std/Test.sol";
import {LikeRegistry} from "../src/LikeRegistry.sol";
import {SoulboundProfileNFT} from "../src/SoulboundProfileNFT.sol";
import {MultiSigWallet} from "../src/MultiSig.sol";
contract DatingDappTest is Test {
SoulboundProfileNFT soulboundNFT;
LikeRegistry likeRegistry;
address owner = makeAddr("owner");
address bob = makeAddr("bob");
address alice = makeAddr("alice");
function setUp() public {
vm.startPrank(owner);
soulboundNFT = new SoulboundProfileNFT();
likeRegistry = new LikeRegistry(address(soulboundNFT));
vm.stopPrank();
}
function testEitherOwnerCanDrainMultiSig() public {
vm.prank(bob);
soulboundNFT.mintProfile("Bob", 25, "ipfs://profileImage");
vm.prank(alice);
soulboundNFT.mintProfile("Alice", 24, "ipfs://profileImage");
hoax(bob, 1 ether);
address multiSigForMatch = likeRegistry.likeUser{value: 1 ether}(alice);
assertEq(multiSigForMatch, address(0), "There is no match because it's only one-sided like till now");
hoax(alice, 1 ether);
multiSigForMatch = likeRegistry.likeUser{value: 1 ether}(bob);
assertNotEq(multiSigForMatch, address(0), "There is a match because it's a mutual like now");
uint256 totalAmountInMultiSigWallet = multiSigForMatch.balance;
assertEq(totalAmountInMultiSigWallet, 1.8 ether);
MultiSigWallet multiSigWallet = MultiSigWallet(payable(multiSigForMatch));
vm.startPrank(bob);
multiSigWallet.submitTransaction(bob, totalAmountInMultiSigWallet);
multiSigWallet.approveTransaction(0);
vm.stopPrank();
vm.prank(alice);
multiSigWallet.approveTransaction(0);
vm.prank(bob);
multiSigWallet.executeTransaction(0);
assertEq(multiSigForMatch.balance, 0);
assertEq(bob.balance, totalAmountInMultiSigWallet);
}
}
Recommended Mitigation
Add the following checks in the MultiSigWallet::submitTransaction function:
+ error ValueExceedsTheMaxAllowedAmount(uint256 maxAllowedAmount);
+ error Owner1HasAlreadySubmittedTransaction();
+ error Owner2HasAlreadySubmittedTransaction();
/// @notice Submit a transaction for approval
function submitTransaction(address _to, uint256 _value) external onlyOwners {
if (_to == address(0)) revert InvalidRecipient();
if (_value == 0) revert InvalidAmount();
+ if (_value > address(this).balance / 2) revert ValueExceedsTheMaxAllowedAmount(address(this).balance / 2);
+ if (submittedTransactionByOwner1) revert Owner1HasAlreadySubmittedTransaction();
+ if (submittedTransactionByOwner2) revert Owner2HasAlreadySubmittedTransaction();
+ if (msg.sender == owner1) {
+ submittedTransactionByOwner1 = true;
+ } else {
+ submittedTransactionByOwner2 = true;
+ }
transactions.push(Transaction(_to, _value, false, false, false));
uint256 txId = transactions.length - 1;
emit TransactionCreated(txId, _to, _value);
}