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: 5/105
Findings: 5
Award: $1,294.30
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: V_B
Also found by: 0xdeadbeef0x, HE1M, Koolex, Matin, adriro, chaduke, gogo, hihen, jonatascm, kankodu, ro, smit_rajput, spacelord47, taek
125.5131 USDC - $125.51
Locking users' funds forever due to DoS for all deployed smart account proxies. Neither implementation upgrade will be possible nor withdrawing funds.
The expected behaviour of interacting with the SmartAccount.sol contract is that all of its function will be called by proxies via delegatecall
meaning that the execution context will always be the proxy contracts. But there is no modifier nor check (require
/if
) that functions are not called directly on the SmartAccount
implementation contract via call
such as this one.
This opens the opportunity for the implementation contract owner to call .execTransaction
directly on the SmartAccount
contract via call
and pass a transaction that delegatecall
s to a malicious contract that implements a selfdestruct
operation.
The SmartAccount
implementation contract address is passed to the SmartAccountFactory
through the constructor
which means that the implementation contract is deployed before that and the owner is unknown.
Manual review
The standard way a factory pattern works is that the factory deploys the implementation contract it its constructor and then calls init
to become the owner
of this implementation contract. As the factory does not implement a function such as f.e. executeArbitraryTransaction
there is no way the .execTransaction
is called directly on the SmartAccount
by the owner. Even more that way there can not be a valid signature to pass the checkSignatures
validation.
Create the implementation contract in the SmartAccountFactory.constructor instead of externally passing it. Then call the .init
function on the newly deployed implementation contract and set the owner to be the SmartAccountFactory
address (address(this)
)
- constructor(address _baseImpl) { - require(_baseImpl != address(0), "base wallet address can not be zero"); - _defaultImpl = _baseImpl; - } + constructor(address _entryPoint, address _handler) { + _defaultImpl = address(new SmartAccount()); + SmartAccount(_defaultImpl).init(address(this), _entryPoint, _handler) + }
#0 - c4-judge
2023-01-17T06:47:15Z
gzeon-c4 marked the issue as duplicate of #496
#1 - c4-sponsor
2023-01-25T23:39:36Z
livingrockrises marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-10T12:29:49Z
gzeon-c4 marked the issue as satisfactory
#3 - GeorgeHNTR
2023-02-11T20:00:17Z
@gzeon-c4 I'm not sure if this is an exact duplicate of 496 as this one talks about a centralisation issue - the owner
of the SmartAccount implementation contract can selfdestruct
the contract by following the steps above.
Issue 496 and its duplicates talk about an attacker being able to exploit this by calling the .init
function to steal ownership if contract is not initialized and then execute the selfdestruct
exploit. The key point is that their recommendations are that protocol devs call .init
in the implementation contract and set the owner themselves.
This will not solve the above problem because the owner
can still perform the exploit even though it is not a random malicious user, it can still be a malicious protocol account or the private key can be compromised.
That's why I reported it as a separate issue for an owner
who destroys the implementation contract, not for someone who steals the ownership and then destroys the contract.
So even if .init
is called on the implementation contract, and even if the owner is set by the protocol, whoever controls the owner
address can still destroy the implementation contract and cause DoS.
In my opinion, there should be an explicit validation in the contract that the owner cannot call functions on the implementation contract. This can be achieved by adding a modifier like this one or as stated in the above problem - by setting the owner
of the implementation contract to the address of the factory in the factory itself, because the factory cannot perform any transactions to the implementation contract once it is deployed.
#4 - gzeon-c4
2023-02-12T16:09:28Z
I am aware of you have submitted #491 and decided to consider this as a duplicate. The owner of the implementation contract should be renounced instead of having a centralized owner.
🌟 Selected for report: V_B
Also found by: 0xdeadbeef0x, HE1M, Koolex, Matin, adriro, chaduke, gogo, hihen, jonatascm, kankodu, ro, smit_rajput, spacelord47, taek
125.5131 USDC - $125.51
https://github.com/code-423n4/2023-01-biconomy/blob/main/scw-contracts/contracts/smart-contract-wallet/SmartAccount.sol#L166 https://github.com/code-423n4/2023-01-biconomy/blob/main/scw-contracts/contracts/smart-contract-wallet/SmartAccountFactory.sol#L17
.init
function in proxy implementation contractDoS for all users' smart account proxies leading to locked funds forever.
Nowhere in the code the SmartAccount.sol implementation contract is initialized by calling the .init
function. This means that the owner of the SmartAccount.sol implementation contract is unknown (as it is set in the .init
function) or the .init
function may not even be called -
can be forgotten by the team and later called or front-runned by an attacker. This will give an external account control over the implementation control such as executing any transaction from it via call
and delegatecall
.
If that happens, the attacker can immediately sign and execute a transaction directly on the SmartAccount.sol implementation contract (via call
) using .execTransaction
and make a delegatecall
by setting the tx.operation to 1 and tx.to to a contract containing a selfdestruct
operation. This will block all proxies and stuck all users's funds forever due to DoS and non-upgradeability as the updateImplementation
mechanism is in the destroyed SmartAccount.sol implementation contract.
Manual review
The standard way a factory pattern works is that the factory deploys the implementation contract it its constructor and then calls init
to become the owner
of this implementation contract. As the factory does not implement a function such as f.e. executeArbitraryTransaction
there is no way the .execTransaction
is called directly on the SmartAccount
by the owner. Even more that way there can not be a valid signature to pass the checkSignatures
validation.
Create the implementation contract in the SmartAccountFactory.constructor instead of externally passing it. Then call the .init
function on the newly deployed implementation contract and set the owner to be the SmartAccountFactory
address (address(this)
)
- constructor(address _baseImpl) { - require(_baseImpl != address(0), "base wallet address can not be zero"); - _defaultImpl = _baseImpl; - } + constructor(address _entryPoint, address _handler) { + _defaultImpl = address(new SmartAccount()); + SmartAccount(_defaultImpl).init(address(this), _entryPoint, _handler) + }
#0 - c4-judge
2023-01-17T06:46:56Z
gzeon-c4 marked the issue as duplicate of #496
#1 - c4-sponsor
2023-01-25T23:39:57Z
livingrockrises marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-10T12:29:46Z
gzeon-c4 marked the issue as satisfactory
22.7235 USDC - $22.72
https://github.com/code-423n4/2023-01-biconomy/blob/main/scw-contracts/contracts/smart-contract-wallet/SmartAccount.sol#L192 https://github.com/code-423n4/2023-01-biconomy/blob/main/scw-contracts/contracts/smart-contract-wallet/SmartAccount.sol#L302-L353 https://github.com/code-423n4/2023-01-biconomy/blob/main/scw-contracts/contracts/smart-contract-wallet/SmartAccount.sol#L342
Locking all user's funds forever due to DoS for all functions.
There are 2 main reasons for this vulnerability:
The expected behaviour of interacting with the SmartAccount.sol contract is that all of its function will be called by proxies via delegatecall
meaning that the execution context will always be the proxy contracts. But there is no modifier nor check (require
/if
) that functions are not called directly on the SmartAccount
implementation contract via call
such as this one.
The .checkSignatures
in SmartAccount.sol has a missing require(_signer == owner, "INVALID_SIGNATURE");
check in the first if
. What the _signer
actually does on line 342 is to verify that ITS OWNERS (not the owner
of the SmartAccount implementation contract, but the owners of the _signer
in the case where the _signer
is another smart contract wallet f.e. gnosis safe) have signed/approved the passed data
with the contractSignature
. But its never checked the _signer
is actually the owner
of the biconomy smart account implementation contract. Note that the _signer
address is also an externally passed variable and has no validation on it so there is basically a call to an arbitrary (untrusted) contract.
This opens the opportunity for an attacker to call .execTransaction
directly on the SmartAccount
contract via call
and pass a transaction that delegatecall
s to a malicious contract that implements a selfdestruct
operation. Because of the missing validation attacker can pass any mock contract address in the expected position in the signature (the r). The contract will return EIP1271_MAGIC_VALUE
on .isValidSignature
and since there is no check that the _signer
on line 342 is the owner
of the implementation contract, the execution will continue without reverting. The SmartAccount contract that is used by all proxies will be destroyed forever and since it also holds the implementation logic, all funds deposited to proxies will become stuck forever.
To sum up, any attacker can execute the following steps for no cost (except tx gas cost):
isValidSignature(bytes,bytes)
and returns EIP1271_MAGIC_VALUE
kill()
) with selfdestruct
operation.execTransaction
on the SmartAccount implementation contract with tx.to = attacker contract address and tx.operation = 1signatures
(in the R value) and set the V to 0EIP1271_MAGIC_VALUE
and will not check whether the _signer
(attacker contract) is the owner
.execTransaction
will continue and call .execute
on the Executor
(which is inherited Executor
-> ModuleManager
-> SmartAccount
)As a reference you can see how Gnosis Safe implement the same function. Notice how after all the if
blocks there is this require
check. In biconomy's SmartAccount.sol this check is not applied when v=0.
Manual review
Add the following check in SmartAccount.checkSignatures
before calling ISignatureValidator(_signer).isValidSignature
:
require(_signer == owner, "INVALID_SIGNATURE");
#0 - c4-judge
2023-01-17T06:59:48Z
gzeon-c4 marked the issue as duplicate of #175
#1 - c4-sponsor
2023-01-25T23:13:34Z
livingrockrises marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-10T12:28:20Z
gzeon-c4 marked the issue as satisfactory
22.7235 USDC - $22.72
All users' funds can be stolen by a single attacker (tx gas cost only)
There are 2 main reasons for this vulnerability:
The .checkSignatures
in SmartAccount.sol has a missing require(_signer == owner, "INVALID_SIGNATURE");
check in the first if
. What the _signer
actually does on line 342 is to verify that ITS OWNERS (not the owner
of the SmartAccount proxy, but the owners of the _signer
in the case where the _signer
is another smart contract wallet f.e. gnosis safe) have signed/approved the passed data
with the contractSignature
. But its never checked the _signer
is actually the owner
of the user's smart account proxy. Note that the _signer
address is also an externally passed variable and has no validation on it so there is basically a call to an arbitrary (untrusted) contract.
This opens the opportunity for an attacker to call .execTransaction
directly on each user's smart account proxy thorugh the fallback function via delegatecall
and pass a transaction that either calls setOwner
or simply transfer funds to a wallet of the attacker. Because of the missing validation attacker can pass any mock contract address in the expected position in the signature (the r). The attacker contract will return EIP1271_MAGIC_VALUE
on .isValidSignature
and since there is no check that the _signer
on line 342 is the owner
of the user's smart contract proxy, the execution will continue without reverting.
To sum up, any attacker can execute the following steps for no cost (except tx gas cost):
isValidSignature(bytes,bytes)
and returns EIP1271_MAGIC_VALUE
.execTransaction
on any user's smart account proxy with tx.to = attacker contract address, tx.operation = 0 and tx.value = proxy contract's balancesignatures
(in the R value) and set the V to 0EIP1271_MAGIC_VALUE
and will not check whether the _signer
(attacker contract) is the owner
.execTransaction
will continue and call .execute
on the Executor
(which is inherited Executor
-> ModuleManager
-> SmartAccount
)Note: attacker can iterate though all user's wallet and perform this attack on each Note: attacker can take also take ownership, withdraw ERC20 tokens and basically do everything on user's behalf
As a reference you can see how Gnosis Safe implement the same function. Notice how after all the if
blocks there is this require
check. In biconomy's SmartAccount.sol this check is not applied when v=0.
Manual review
Add the following check in SmartAccount.checkSignatures
before calling ISignatureValidator(_signer).isValidSignature
:
require(_signer == owner, "INVALID_SIGNATURE");
#0 - c4-judge
2023-01-17T06:59:04Z
gzeon-c4 marked the issue as duplicate of #175
#1 - c4-sponsor
2023-01-25T23:48:47Z
livingrockrises marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-10T12:28:20Z
gzeon-c4 marked the issue as satisfactory
242.9785 USDC - $242.98
Results in unexpected behavior in the EntryPoint contract.
As said in the official specification of EIP-4337: "If the account does not support signature aggregation, it MUST validate the signature is a valid signature of the userOpHash, and SHOULD return SIG_VALIDATION_FAILED (and not revert) on signature mismatch. Any other error should revert.". SmartAccount._validateSignature does the opposite and reverts if the signature is invalid which will be wrong interpret by the EntryPoint contract.
Manual review
Return SIG_VALIDATION_FAILED instead of revert in SmartAccount._validateSignature:
function _validateSignature(UserOperation calldata userOp, bytes32 userOpHash, address) internal override virtual returns (uint256 deadline) { bytes32 hash = userOpHash.toEthSignedMessageHash(); //ignore signature mismatch of from==ZERO_ADDRESS (for eth_callUserOp validation purposes) // solhint-disable-next-line avoid-tx-origin - require(owner == hash.recover(userOp.signature) || tx.origin == address(0), "account: wrong signature"); + if(owner != hash.recover(userOp.signature) && tx.origin != address(0)) return SIG_VALIDATION_FAILED; return 0; }
#0 - c4-judge
2023-01-17T16:37:14Z
gzeon-c4 marked the issue as duplicate of #318
#1 - livingrockrises
2023-01-26T07:16:03Z
you're right. it has to be rebased with the latest code (0.4.0).
#2 - c4-sponsor
2023-01-26T07:17:50Z
livingrockrises marked the issue as sponsor confirmed
#3 - c4-judge
2023-02-10T12:17:04Z
gzeon-c4 marked the issue as satisfactory
#4 - c4-judge
2023-02-14T08:09:39Z
gzeon-c4 marked the issue as duplicate of #498
866.5899 USDC - $866.59
User operations can be replayed on smart accounts accross different chains. This can lead to user's loosing funds or any unexpected behaviour that transaction replay attacks usually lead to.
As specified by the EIP4337 standard to prevent replay attacks ... the signature should depend on chainid
. In VerifyingSingletonPaymaster.sol#getHash the chainId is missing which means that the same UserOperation can be replayed on a different chain for the same smart contract account if the verifyingSigner
is the same (and most likely this will be the case).
Manual review
Add the chainId in the calculation of the UserOperation hash in VerifyingSingletonPaymaster.sol#getHash
function getHash(UserOperation calldata userOp) public view returns (bytes32) { // @audit change to view //can't use userOp.hash(), since it contains also the paymasterAndData itself. return keccak256(abi.encode( userOp.getSender(), userOp.nonce, keccak256(userOp.initCode), keccak256(userOp.callData), userOp.callGasLimit, userOp.verificationGasLimit, userOp.preVerificationGas, userOp.maxFeePerGas, userOp.maxPriorityFeePerGas block.chainid // @audit add chain id )); }
#0 - gzeoneth
2023-01-17T07:58:17Z
might consider as dupe of #469
#1 - c4-judge
2023-01-17T07:58:21Z
gzeon-c4 marked the issue as primary issue
#2 - c4-sponsor
2023-02-07T17:01:40Z
livingrockrises marked the issue as sponsor confirmed
#3 - c4-judge
2023-02-10T12:19:44Z
gzeon-c4 changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-02-10T12:19:58Z
gzeon-c4 marked the issue as selected for report
#5 - vladbochok
2023-02-11T09:30:39Z
@gzeoneth @livingrockrises
User operations can be replayed on smart accounts accross different chains
The author refers that the operation may be replayed on a different chain. That is not true. The "getHash" function derives the hash of UserOp specifically for the paymaster's internal usage. While the paymaster doesn't sign the chainId, the UserOp may not be relayed on a different chain. So, the only paymaster may get hurt. In all other respects, the bug is valid.
The real use case of this cross-chan replayability is described in issue #504 (which, I believe, was mistakenly downgraded).
#6 - livingrockrises
2023-02-11T10:14:23Z
true. besides chainId , address(this) should be hashed and contract must maintain it's own nonces per wallet otherwise wallet can replay the signature and use paymaster to sponsor! we're also planning to hash paymasterId as add-on on top of our off-chain validation for it.
I have't seen an issue which covers all above. either cross chain replay or suggested paymaster nonce