Summary
The MultiSigWallet contract uses a low-level call to transfer ETH in the executeTransaction function. Although protected by the executed flag, this approach is not optimal in terms of security.
Vulnerability Details
In MultiSig.sol, the executeTransaction function:
Uses a low-level call to transfer ETH
Relies solely on the executed flag to prevent reentrancy
Does not implement an explicit ReentrancyGuard
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
Implicit Protection : The protection against reentrancy relies on business logic rather than a dedicated mechanism
Future Risk : The addition of new features could introduce vulnerabilities if the pattern is not maintained
Non-Compliance : Does not follow recommended security best practices
Severity: LOW - The vulnerability is mitigated by the onlyOwners modifier and the executed flag
Tools Used
Proof of Concept
contract AttackerContract {
MultiSigWallet public multiSig;
uint256 public attackCount;
uint256 public txId;
constructor(address _multiSig) {
multiSig = MultiSigWallet(payable(_multiSig));
}
receive() external payable {
if (attackCount < 3) {
attackCount++;
multiSig.executeTransaction(txId);
}
}
}
function testReentrancyAttack() public {
vm.expectRevert("Transaction already executed");
multiSig.executeTransaction(txId);
}
Recommendations
Implement OpenZeppelin's ReentrancyGuard:
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract MultiSigWallet is ReentrancyGuard {
function executeTransaction(uint256 _txId) external nonReentrant onlyOwners {
}
}
Use a more explicit security pattern:
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");
address to = txn.to;
uint256 value = txn.value;
txn.executed = true;
emit TransactionExecuted(_txId, to, value);
(bool success,) = payable(to).call{value: value}("");
require(success, "Transfer failed");
}
Consider using a pull-over-push pattern:
mapping(address => uint256) public pendingWithdrawals;
function executeTransaction(uint256 _txId) external onlyOwners {
txn.executed = true;
pendingWithdrawals[txn.to] += txn.value;
emit TransactionExecuted(_txId, txn.to, txn.value);
}
function withdraw() external {
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0, "No funds to withdraw");
pendingWithdrawals[msg.sender] = 0;
(bool success,) = payable(msg.sender).call{value: amount}("");
require(success, "Transfer failed");
}