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");
}