import { expect } from "chai"; import hre, { deployments, ethers } from "hardhat"; import { AddressZero } from "@ethersproject/constants"; import { defaultAbiCoder } from "@ethersproject/abi"; import { getSafe, deployContractFromSource, getCompatFallbackHandler } from "../utils/setup"; import { buildSignatureBytes, executeContractCallWithSigners, signHash } from "../../src/utils/execution"; describe("Safe", () => { const setupTests = deployments.createFixture(async ({ deployments }) => { await deployments.fixture(); const handler = await getCompatFallbackHandler(); const handlerAddress = await handler.getAddress(); const signers = await ethers.getSigners(); const [user1, user2] = signers; const ownerSafe = await getSafe({ owners: [user1.address, user2.address], threshold: 2, fallbackHandler: handlerAddress, }); const ownerSafeAddress = await ownerSafe.getAddress(); const messageHandler = await getCompatFallbackHandler(ownerSafeAddress); return { safe: await getSafe({ owners: [ownerSafeAddress, user1.address], threshold: 1 }), ownerSafe, messageHandler, signers, }; }); describe("0xExploit", () => { /* * In case of 0x it was possible to use EIP-1271 (contract signatures) to generate a valid signature for EOA accounts. * See https://samczsun.com/the-0x-vulnerability-explained/ */ it("should not be able to use EIP-1271 (contract signatures) for EOA", async () => { const { safe, ownerSafe, messageHandler, signers: [user1, user2], } = await setupTests(); const ownerSafeAddress = await ownerSafe.getAddress(); // Safe should be empty again await user1.sendTransaction({ to: await safe.getAddress(), value: ethers.parseEther("1") }).then((tx) => tx.wait(1)); expect(await hre.ethers.provider.getBalance(await safe.getAddress())).to.eq(ethers.parseEther("1")); const operation = 0; const to = user1.address; const value = ethers.parseEther("1"); const data = "0x"; const nonce = await safe.nonce(); // Use off-chain Safe signature const transactionHash = await safe.getTransactionHash(to, value, data, operation, 0, 0, 0, AddressZero, AddressZero, nonce); const messageHash = await messageHandler.getMessageHash(transactionHash); const ownerSigs = buildSignatureBytes([await signHash(user1, messageHash), await signHash(user2, messageHash)]); const encodedOwnerSigns = defaultAbiCoder.encode(["bytes"], [ownerSigs]).slice(66); // Use EOA owner let sigs = "0x" + "000000000000000000000000" + user2.address.slice(2) + "0000000000000000000000000000000000000000000000000000000000000041" + "00" + // r, s, v encodedOwnerSigns; // Transaction should fail (invalid signatures should revert the Ethereum transaction) await expect( safe.execTransaction(to, value, data, operation, 0, 0, 0, AddressZero, AddressZero, sigs), "Transaction should fail if invalid signature is provided", ).to.be.reverted; expect(await hre.ethers.provider.getBalance(await safe.getAddress())).to.eq(ethers.parseEther("1")); // Use Safe owner sigs = "0x" + "000000000000000000000000" + ownerSafeAddress.slice(2) + "0000000000000000000000000000000000000000000000000000000000000041" + "00" + // r, s, v encodedOwnerSigns; await safe.execTransaction(to, value, data, operation, 0, 0, 0, AddressZero, AddressZero, sigs).then((tx) => tx.wait(1)); // Safe should be empty again expect(await hre.ethers.provider.getBalance(await safe.getAddress())).to.eq(ethers.parseEther("0")); }); it("should revert if EIP-1271 check changes state", async () => { const { safe, ownerSafe, messageHandler, signers: [user1, user2], } = await setupTests(); const ownerSafeAddress = await ownerSafe.getAddress(); // Test Validator const source = ` contract Test { bool public changeState; uint256 public nonce; function isValidSignature(bytes32 _data, bytes memory _signature) public returns (bytes4) { if (changeState) { nonce = nonce + 1; } return 0x1626ba7e; } function shouldChangeState(bool value) public { changeState = value; } }`; const testValidator = await deployContractFromSource(user1, source); const testValidatorAddress = await testValidator.getAddress(); await testValidator.shouldChangeState(true); await executeContractCallWithSigners(safe, safe, "addOwnerWithThreshold", [testValidatorAddress, 1], [user1]); expect(await safe.getOwners()).to.be.deep.eq([testValidatorAddress, ownerSafeAddress, user1.address]); // Deposit 1 ETH + some spare money for execution await user1.sendTransaction({ to: await safe.getAddress(), value: ethers.parseEther("1") }).then((tx) => tx.wait(1)); expect(await hre.ethers.provider.getBalance(await safe.getAddress())).to.eq(ethers.parseEther("1")); const operation = 0; const to = user1.address; const value = ethers.parseEther("1"); const data = "0x"; const nonce = await safe.nonce(); // Use off-chain Safe signature const transactionHash = await safe.getTransactionHash(to, value, data, operation, 0, 0, 0, AddressZero, AddressZero, nonce); const messageHash = await messageHandler.getMessageHash(transactionHash); const ownerSigs = buildSignatureBytes([await signHash(user1, messageHash), await signHash(user2, messageHash)]); const encodedOwnerSigns = defaultAbiCoder.encode(["bytes"], [ownerSigs]).slice(66); // Use Safe owner const sigs = "0x" + "000000000000000000000000" + testValidatorAddress.slice(2) + "0000000000000000000000000000000000000000000000000000000000000041" + "00" + // r, s, v encodedOwnerSigns; // Transaction should fail (state changing signature check should revert) await expect( safe.execTransaction(to, value, data, operation, 0, 0, 0, AddressZero, AddressZero, sigs), "Transaction should fail if invalid signature is provided", ).to.be.reverted; expect(await hre.ethers.provider.getBalance(await safe.getAddress())).to.eq(ethers.parseEther("1")); await testValidator.shouldChangeState(false); await safe.execTransaction(to, value, data, operation, 0, 0, 0, AddressZero, AddressZero, sigs).then((tx) => tx.wait(1)); // Safe should be empty again expect(await hre.ethers.provider.getBalance(await safe.getAddress())).to.eq(ethers.parseEther("0")); }); }); });