DatingDapp

AI First Flight #6
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: medium
Likelihood: high
Invalid

executeTransaction emits an event after an external call, enabling log manipulation via reentrancy

Root + Impact

Description

  • MultiSigWallet.executeTransaction is designed to send ETH to a target address only after both owners have approved the transaction, marking it as executed to prevent replay.

  • While txn.executed = true is correctly set before the external .call, the TransactionExecuted event is emitted after the call returns. A malicious recipient contract can re-enter wallet functions during the call, causing the event to fire at an unexpected point in the call stack.

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}(""); // external call
require(success, "Transaction failed");
@> emit TransactionExecuted(_txId, txn.to, txn.value); // emitted AFTER external call
}

Risk

Likelihood:

  • Occurs whenever the recipient address (txn.to) is a smart contract with a receive or fallback that calls back into the MultiSig.

Impact:

  • Event logs are emitted in incorrect order, breaking off-chain monitoring, subgraph indexing, and audit tooling that relies on event ordering to reconstruct on-chain state.

  • Although the state guard (txn.executed = true) prevents double-spending, corrupted event sequencing misleads integrations and users.

Proof of Concept

This test demonstrates that a TransactionCreated event (emitted from inside the reentrant receive()) appears in the log array before the TransactionExecuted event for the original transaction — even though logically the execution should be logged first:

Setup — A ReentrantRecipient contract is deployed with a reference to the MultiSigWallet. The wallet is funded with 5 ETH. A transaction targeting ReentrantRecipient for 1 ETH is submitted and approved by both owners.

Execution starts — executeTransaction(0) is called. txn.executed is set to true. Then .call{value: 1 ether} is made to ReentrantRecipient.

Reentrancy inside receive() — ReentrantRecipient.receive() calls wallet.submitTransaction(address(this), 1). This succeeds (it doesn't re-enter executeTransaction), emitting TransactionCreated(txId=1) from inside the external call of executeTransaction(0).

Call returns — Control returns to executeTransaction, which then emits TransactionExecuted(txId=0).

Corrupted log order — The recorded logs show TransactionCreated(1) before TransactionExecuted(0). Any indexer replaying these events in order will process a new transaction creation before the originating execution is finalized.

To run: forge test --match-test test_eventEmittedAfterExternalCall -vvvv

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../src/MultiSig.sol";
/// @dev Recipient that re-enters submitTransaction from receive()
contract ReentrantRecipient {
MultiSigWallet public wallet;
bool public reentered;
constructor(address _wallet) {
wallet = MultiSigWallet(_wallet);
}
receive() external payable {
// During the external call, before TransactionExecuted is emitted,
// submit a new transaction — this emits TransactionCreated out-of-order
if (!reentered) {
reentered = true;
wallet.submitTransaction(address(this), 1 wei);
}
}
}
contract M1_EventOrderTest is Test {
MultiSigWallet wallet;
ReentrantRecipient recipient;
address owner1 = makeAddr("owner1");
address owner2 = makeAddr("owner2");
function setUp() public {
wallet = new MultiSigWallet(owner1, owner2);
recipient = new ReentrantRecipient(address(wallet));
vm.deal(address(wallet), 5 ether);
}
function test_eventEmittedAfterExternalCall() public {
// Submit and approve a transaction to the reentrant recipient
vm.prank(owner1);
wallet.submitTransaction(address(recipient), 1 ether);
vm.prank(owner1);
wallet.approveTransaction(0);
vm.prank(owner2);
wallet.approveTransaction(0);
vm.recordLogs();
vm.prank(owner1);
wallet.executeTransaction(0);
Vm.Log[] memory logs = vm.getRecordedLogs();
// logs[0] = TransactionCreated(txId=1) — emitted from INSIDE the external call
// logs[1] = TransactionExecuted(txId=0) — emitted AFTER the external call returns
// Correct order should be: TransactionExecuted(0) then TransactionCreated(1)
assertTrue(recipient.reentered(), "Reentrancy did occur");
// Verify the first event is TransactionCreated, not TransactionExecuted
// TransactionCreated selector: keccak256("TransactionCreated(uint256,address,uint256)")
bytes32 createdSig = keccak256("TransactionCreated(uint256,address,uint256)");
bytes32 executedSig = keccak256("TransactionExecuted(uint256,address,uint256)");
assertEq(logs[0].topics[0], createdSig, "First log is TransactionCreated — out of order");
assertEq(logs[1].topics[0], executedSig, "Second log is TransactionExecuted — out of order");
console.log("Event ordering corrupted: TransactionCreated fired before TransactionExecuted");
}
}

Recommended Mitigation

Emit the event before the external call:

txn.executed = true;
+ emit TransactionExecuted(_txId, txn.to, txn.value);
(bool success,) = payable(txn.to).call{value: txn.value}("");
require(success, "Transaction failed");
- emit TransactionExecuted(_txId, txn.to, txn.value);
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 4 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!