Project

One World
NFTDeFi
15,000 USDC
View results
Submission Details
Severity: high
Invalid

Non-Standard Domain Separator in EIP-712 Implementation Causes Signature Verification Failure

Summary

The contract EIP712Base deviates from the EIP-712 standard by omitting the chainId field in the domain separator. This creates compatibility issues with clients that expect chainId, such as MetaMask. This non-standard implementation results in interoperability issues, where signatures generated by clients adhering to EIP-712 specifications cannot be verified by the contract, breaking core protocol functionality.

Vulnerability Details

This vulnerability arises because the protocol fails to include the chainId field in the domain separator. EIP-712 specifies the inclusion of chainId to uniquely identify signatures across different chains and prevent replay attacks. Clients like MetaMask and other standard-compliant wallets sign messages with chainId included, following the EIP-712 specification. Due to this mismatch, signatures signed by clients with chainId will not match the contract’s calculated domain separator, resulting in failed signature verifications.

The following PoC showcases the described issue. To run it, create a new Hardhat JavaScript project, copy-paste the code below, run npm install --save-dev @nomicfoundation/hardhat-toolbox ethers chai & npm install @openzeppelin/contracts, and run the test with npx hardhat test.

PoC

  • Contract which omitts chainId in its domain separator:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract EIP712Base {
using ECDSA for bytes32;
bytes32 public immutable _CACHED_DOMAIN_SEPARATOR;
bytes32 public immutable _TYPE_HASH;
string private constant SIGNING_DOMAIN = "MyApp";
string private constant SIGNATURE_VERSION = "1";
struct MyMessage {
address user;
uint256 amount;
string message;
}
bytes32 private constant MESSAGE_TYPEHASH = keccak256("MyMessage(address user,uint256 amount,string message)");
constructor() {
bytes32 hashedName = keccak256(bytes(SIGNING_DOMAIN));
bytes32 hashedVersion = keccak256(bytes(SIGNATURE_VERSION));
bytes32 typeHash = keccak256("EIP712Domain(string name,string version,address verifyingContract,bytes32 salt)");
_CACHED_DOMAIN_SEPARATOR = _buildDomainSeparator(typeHash, hashedName, hashedVersion);
_TYPE_HASH = typeHash;
}
function hashMessage(MyMessage calldata message) internal view returns (bytes32) {
return _hashTypedDataV4(
keccak256(abi.encode(MESSAGE_TYPEHASH, message.user, message.amount, keccak256(bytes(message.message))))
);
}
function verify(MyMessage calldata message, bytes calldata signature) external view returns (bool) {
bytes32 digest = hashMessage(message);
address signer = digest.recover(signature);
return signer == message.user;
}
function _domainSeparatorV4() internal view returns (bytes32) {
return _CACHED_DOMAIN_SEPARATOR;
}
function _buildDomainSeparator(bytes32 typeHash, bytes32 name, bytes32 version) private view returns (bytes32) {
return keccak256(abi.encode(typeHash, name, version, address(this), bytes32(getChainId())));
}
function _hashTypedDataV4(bytes32 structHash) internal view virtual returns (bytes32) {
return keccak256(abi.encodePacked("\x19\x01", _domainSeparatorV4(), structHash));
}
function getChainId() public view returns (uint256) {
uint256 id;
assembly {
id := chainid()
}
return id;
}
}
  • The same contract as the above one, but it includes chainId in its domain separator

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract EIP712ChainId {
using ECDSA for bytes32;
bytes32 public immutable _CACHED_DOMAIN_SEPARATOR;
bytes32 public immutable _TYPE_HASH;
string private constant SIGNING_DOMAIN = "MyApp";
string private constant SIGNATURE_VERSION = "1";
struct MyMessage {
address user;
uint256 amount;
string message;
}
bytes32 private constant MESSAGE_TYPEHASH = keccak256("MyMessage(address user,uint256 amount,string message)");
constructor() {
bytes32 hashedName = keccak256(bytes(SIGNING_DOMAIN));
bytes32 hashedVersion = keccak256(bytes(SIGNATURE_VERSION));
bytes32 typeHash =
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract,bytes32 salt)");
_CACHED_DOMAIN_SEPARATOR = _buildDomainSeparator(typeHash, hashedName, hashedVersion);
_TYPE_HASH = typeHash;
}
function hashMessage(MyMessage calldata message) internal view returns (bytes32) {
return _hashTypedDataV4(
keccak256(abi.encode(MESSAGE_TYPEHASH, message.user, message.amount, keccak256(bytes(message.message))))
);
}
function verify(MyMessage calldata message, bytes calldata signature) external view returns (bool) {
bytes32 digest = hashMessage(message);
address signer = digest.recover(signature);
return signer == message.user;
}
function _domainSeparatorV4() internal view returns (bytes32) {
return _CACHED_DOMAIN_SEPARATOR;
}
function _buildDomainSeparator(bytes32 typeHash, bytes32 name, bytes32 version) private view returns (bytes32) {
return keccak256(abi.encode(typeHash, name, version, getChainId(), address(this), bytes32(getChainId())));
}
function _hashTypedDataV4(bytes32 structHash) internal view virtual returns (bytes32) {
return keccak256(abi.encodePacked("\x19\x01", _domainSeparatorV4(), structHash));
}
function getChainId() public view returns (uint256) {
uint256 id;
assembly {
id := chainid()
}
return id;
}
}
  • The test which shows that when contract fails to include the chainId verification isn not successful and when it includes it, the verification is successful

const { ethers } = require("hardhat");
const { expect } = require("chai");
describe("EIP712", function () {
it("should fail to verify the signature when chainId is missing", async function () {
// Deploy the contract with incorrect bytes32 chainId representation
const EIP712Bytes32 = await ethers.getContractFactory("EIP712Bytes32");
const contractBytes32 = await EIP712Bytes32.deploy();
const pk = "1fa74b7ab62483c591ddae0447619d8cf10fa7e88b0fca68937847862f593059";
const signer = new ethers.Wallet(pk);
const domain = {
name: "MyApp",
version: "1",
chainId: 31337, // ChainId clients following EIP712 will add while signing messages.
verifyingContract: await contractBytes32.target,
salt: "0x0000000000000000000000000000000000000000000000000000000000007a69", // bytes32(uint256(31337));
};
const types = {
MyMessage: [
{ name: "user", type: "address" },
{ name: "amount", type: "uint256" },
{ name: "message", type: "string" },
],
};
const message = {
user: signer.address,
amount: 100,
message: "Hello, EIP-712!",
};
const signature = await signer.signTypedData(domain, types, message);
const isValid = await contractBytes32.verify(message, signature);
expect(isValid).to.be.false;
});
it("should pass to verify the signature when chainId is present", async function () {
const EIP712 = await ethers.getContractFactory("EIP712");
const contractUint256 = await EIP712.deploy();
const privateKey = "1fa74b7ab62483c591ddae0447619d8cf10fa7e88b0fca68937847862f593059";
const wallet = new ethers.Wallet(privateKey);
const domain = {
name: "MyApp",
version: "1",
chainId: 31337,
verifyingContract: await contractUint256.target,
salt: "0x0000000000000000000000000000000000000000000000000000000000007a69",
};
const types = {
MyMessage: [
{ name: "user", type: "address" },
{ name: "amount", type: "uint256" },
{ name: "message", type: "string" }
]
};
const message = {
user: wallet.address,
amount: 100,
message: "Hello, EIP-712!"
};
const signature = await wallet.signTypedData(domain, types, message);
const isValid = await contractUint256.verify(message, signature);
expect(isValid).to.be.true;
});
});

console output

➜ EIP712Test1 npx hardhat test
EIP712
✔ should fail to verify the signature when chainId is missing (923ms)
✔ should pass to verify the signature when chainId is present
2 passing (945ms)

Impact

This issue directly impacts core protocol functionality, as the EIP712Base contract is inherited by NativeMetaTransaction and subsequently by MembershipFactory. The lack of interoperability caused by omitting chainId renders the contract unable to verify valid signatures signed by standard EIP-712 clients, effectively breaking the NativeMetaTransaction feature and any dependent functionality within the protocol.

Tools Used

VSCode, ethers.js

Recommendations

Update the domainSeparator in the EIP712Base contract to include the chainId field.

Updates

Lead Judging Commences

0xbrivan2 Lead Judge 8 months ago
Submission Judgement Published
Invalidated
Reason: Known issue

Appeal created

0xsecuri Submitter
8 months ago
0xbrivan2 Lead Judge
8 months ago
0xbrivan2 Lead Judge 8 months ago
Submission Judgement Published
Invalidated
Reason: Known issue

Support

FAQs

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