Biconomy - Smart Contract Wallet contest - gogo's results

One-Stop solution to enable an effortless experience in your dApp to onboard new users and abstract away transaction complexities.

General Information

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

Biconomy

Findings Distribution

Researcher Performance

Rank: 5/105

Findings: 5

Award: $1,294.30

🌟 Selected for report: 1

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: V_B

Also found by: 0xdeadbeef0x, HE1M, Koolex, Matin, adriro, chaduke, gogo, hihen, jonatascm, kankodu, ro, smit_rajput, spacelord47, taek

Awards

125.5131 USDC - $125.51

Labels

bug
3 (High Risk)
satisfactory
sponsor confirmed
duplicate-496

External Links

Lines of code

https://github.com/code-423n4/2023-01-biconomy/blob/main/scw-contracts/contracts/smart-contract-wallet/SmartAccountFactory.sol#L17

Vulnerability details

SmartAccount implementation contract can be destroyed by owner

Impact

Locking users' funds forever due to DoS for all deployed smart account proxies. Neither implementation upgrade will be possible nor withdrawing funds.

Proof of Concept

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 delegatecalls 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.

Tools Used

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.

Findings Information

🌟 Selected for report: V_B

Also found by: 0xdeadbeef0x, HE1M, Koolex, Matin, adriro, chaduke, gogo, hihen, jonatascm, kankodu, ro, smit_rajput, spacelord47, taek

Awards

125.5131 USDC - $125.51

Labels

bug
3 (High Risk)
satisfactory
sponsor confirmed
duplicate-496

External Links

Lines of code

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

Vulnerability details

Uninialized or front-runnable .init function in proxy implementation contract

Impact

DoS for all users' smart account proxies leading to locked funds forever.

Proof of Concept

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.

Tools Used

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

Awards

22.7235 USDC - $22.72

Labels

bug
3 (High Risk)
satisfactory
sponsor confirmed
edited-by-warden
duplicate-175

External Links

Lines of code

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

Vulnerability details

SmartAccount implementation contract can be destroyed by anyone

Impact

Locking all user's funds forever due to DoS for all functions.

Proof of Concept

There are 2 main reasons for this vulnerability:

  1. 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.

  2. 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 delegatecalls 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):

  1. Create an attacker contract that
  • mocks isValidSignature(bytes,bytes) and returns EIP1271_MAGIC_VALUE
  • has a function (f.e. kill()) with selfdestruct operation
  1. Call .execTransaction on the SmartAccount implementation contract with tx.to = attacker contract address and tx.operation = 1
  2. Place the attacker contract address at the expected position in signatures (in the R value) and set the V to 0
  3. line 342 will check that the attacker contract returns EIP1271_MAGIC_VALUE and will not check whether the _signer (attacker contract) is the owner
  4. .execTransaction will continue and call .execute on the Executor (which is inherited Executor -> ModuleManager -> SmartAccount)
  5. The SmartAccount implementation contract will be destroyed forever and all funds locked in smart account proxies will be stuck forever as well

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.

Tools Used

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

Awards

22.7235 USDC - $22.72

Labels

bug
3 (High Risk)
satisfactory
sponsor confirmed
edited-by-warden
duplicate-175

External Links

Lines of code

https://github.com/code-423n4/2023-01-biconomy/blob/main/scw-contracts/contracts/smart-contract-wallet/SmartAccount.sol#L342

Vulnerability details

Attacker can take control over each SmartAccount proxy and steal all users' funds

Impact

All users' funds can be stolen by a single attacker (tx gas cost only)

Proof of Concept

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):

  1. Create an attacker contract that
  • mocks isValidSignature(bytes,bytes) and returns EIP1271_MAGIC_VALUE
  1. Call .execTransaction on any user's smart account proxy with tx.to = attacker contract address, tx.operation = 0 and tx.value = proxy contract's balance
  2. Place the attacker contract address at the expected position in signatures (in the R value) and set the V to 0
  3. line 342 will check that the attacker contract returns EIP1271_MAGIC_VALUE and will not check whether the _signer (attacker contract) is the owner
  4. .execTransaction will continue and call .execute on the Executor (which is inherited Executor -> ModuleManager -> SmartAccount)
  5. All user's funds will be transfered to the attacker contract

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.

Tools Used

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

Findings Information

🌟 Selected for report: Franfran

Also found by: Koolex, MalfurionWhitehat, gogo, immeas, zaskoh

Labels

bug
2 (Med Risk)
satisfactory
sponsor confirmed
duplicate-498

Awards

242.9785 USDC - $242.98

External Links

Lines of code

https://github.com/code-423n4/2023-01-biconomy/blob/main/scw-contracts/contracts/smart-contract-wallet/SmartAccount.sol#L511

Vulnerability details

Impact

Results in unexpected behavior in the EntryPoint contract.

Proof of Concept

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.

Tools Used

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

Findings Information

🌟 Selected for report: gogo

Also found by: V_B, taek

Labels

bug
2 (Med Risk)
downgraded by judge
primary issue
selected for report
sponsor confirmed
M-03

Awards

866.5899 USDC - $866.59

External Links

Lines of code

https://github.com/code-423n4/2023-01-biconomy/blob/main/scw-contracts/contracts/smart-contract-wallet/paymasters/verifying/singleton/VerifyingSingletonPaymaster.sol#L77-L90

Vulnerability details

Cross-Chain Signature Replay Attack

Impact

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.

Proof of Concept

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).

Tools Used

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

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter