Coinbase Smart Wallet - lsaudit's results

Smart Wallet from Coinbase Wallet

General Information

Platform: Code4rena

Start Date: 14/03/2024

Pot Size: $49,000 USDC

Total HM: 3

Participants: 51

Period: 7 days

Judge: 3docSec

Id: 350

League: ETH

Coinbase

Findings Distribution

Researcher Performance

Rank: 2/51

Findings: 2

Award: $6,761.88

๐ŸŒŸ Selected for report: 0

๐Ÿš€ Solo Findings: 0

Findings Information

๐ŸŒŸ Selected for report: Circolors

Also found by: lsaudit

Labels

bug
3 (High Risk)
satisfactory
sufficient quality report
:robot:_32_group
duplicate-114

Awards

6725.5435 USDC - $6,725.54

External Links

Lines of code

https://github.com/code-423n4/2024-03-coinbase/blob/e0573369b865d47fed778de00a7b6df65ab1744e/src/SmartWallet/CoinbaseSmartWallet.sol#L252-L262

Vulnerability details

Impact

Function canSkipChainIdValidation allows to skip the chain ID validation for some predefined selectors. However, the list of selectors which can skip the chain ID validation is too huge. E.g., it's possible to perform replay attacks on other chains (because of the skipped chain ID validation) for adding or removing owners.

Proof of Concept

File: CoinbaseSmartWallet.sol

function executeWithoutChainIdValidation(bytes calldata data) public payable virtual onlyEntryPoint { bytes4 selector = bytes4(data[0:4]); if (!canSkipChainIdValidation(selector)) { revert SelectorNotAllowed(selector); } _call(address(this), 0, data); }

Function executeWithoutChainIdValidation() executes canSkipChainIdValidation() to perform validation which checks if function can be executed without chain ID validation.

canSkipChainIdValidation() allows to skip chain ID validation for below selectors:

File: CoinbaseSMartWallet.sol

function canSkipChainIdValidation(bytes4 functionSelector) public pure returns (bool) { if ( functionSelector == MultiOwnable.addOwnerPublicKey.selector || functionSelector == MultiOwnable.addOwnerAddress.selector || functionSelector == MultiOwnable.removeOwnerAtIndex.selector || functionSelector == UUPSUpgradeable.upgradeToAndCall.selector ) { return true; } return false; }
  • MultiOwnable.addOwnerPublicKey.selector, MultiOwnable.addOwnerAddress.selector allows to replay the transaction on other chains. Since the chaind ID validation is skipped - the attacker can replay the signature designed for adding new owner of the wallet address.

Let's consider a scenario:

  1. Wallet is deployed on chain X
  2. Users A, B, C were added as owners of the wallet
  3. Users B and C decides to deploy similar wallet, but on chain Y. However, they do not want user A as the owner of that wallet.
  4. Since chain ID validation is skipped for MultiOwnable.addOwnerAddress.selector, user A replays the signature from chain X on chain Y - adding himself as the owner of the wallet.
  • MultiOwnable.removeOwnerAtIndex.selector allows to replay the signature on other chains Since the chain ID validation is skipped - the attacker can replay the signature designed for removing owner at index X on another chain.
  1. Two wallets are deployed on chain X and chain Y. Those wallets can have different owners.
  2. At chain X, owners decides to remove the owner at index 1
  3. Owner at index 1 is removed
  4. Malicious user replays that transaction with signature on chain Y
  5. Since no chain ID validation takes place for removing owner - it's possible to remove owner at index 1 on chain Y
  6. Since MultiOwnable.removeOwnerAtIndex takes just the index, the owner on chain X and Y does not be the same address
  7. E.g. let's say that on chain X, at index 1 there's 0xAAA and on chain Y, at index 1, there's 0xBBB
  8. On chain X, we remove owner at index 1. 0xAAA is not the owner anymore
  9. We replay on chain Y. Since we replay removing owner at index 1, we remove 0xBBB on chain Y
  10. 0xBBB is not the owner anymore on chain Y.

Please notice that in this scenario, anyone who observes the mempool and sees the signature can re-use it on another chain to remove wallet's owner on the specific index (if user has signature to remove owner at index A on chain X, he can reuse it to remove owner at index A on chain Y).

Tools Used

Manual code review

Remove MultiOwnable.addOwnerPublicKey.selector, MultiOwnable.addOwnerAddress.selector, MultiOwnable.removeOwnerAtIndex.selector from canSkipChainIdValidation() function.

Assessed type

Invalid Validation

#0 - c4-pre-sort

2024-03-22T02:58:58Z

raymondfam marked the issue as insufficient quality report

#1 - c4-pre-sort

2024-03-22T02:59:05Z

raymondfam marked the issue as duplicate of #9

#2 - raymondfam

2024-03-22T02:59:16Z

See #57. The mitigation step doesn't seem feasible though.

#3 - c4-pre-sort

2024-03-24T14:53:07Z

raymondfam marked the issue as sufficient quality report

#4 - c4-pre-sort

2024-03-24T14:53:11Z

raymondfam marked the issue as not a duplicate

#5 - c4-pre-sort

2024-03-24T14:53:19Z

raymondfam marked the issue as duplicate of #57

#6 - c4-judge

2024-03-27T05:26:44Z

3docSec marked the issue as not a duplicate

#7 - c4-judge

2024-03-27T05:35:20Z

3docSec marked the issue as primary issue

#8 - c4-judge

2024-03-27T05:46:09Z

3docSec marked the issue as duplicate of #114

#9 - c4-judge

2024-03-27T09:30:10Z

3docSec marked the issue as satisfactory

#10 - c4-judge

2024-03-27T18:04:55Z

3docSec changed the severity to 3 (High Risk)

#11 - c4-judge

2024-03-28T08:16:52Z

3docSec changed the severity to QA (Quality Assurance)

#12 - c4-judge

2024-03-28T08:42:21Z

This previously downgraded issue has been upgraded by 3docSec

Awards

36.3397 USDC - $36.34

Labels

bug
grade-a
QA (Quality Assurance)
sufficient quality report
Q-14

External Links

[1] MultiOwnable.sol misses functionality which allows to return the current number of owners

File: MultiOwnable.sol

File: src/SmartWallet/MultiOwnable.sol

179:     function _addOwner(bytes memory owner) internal virtual {
180:         _addOwnerAtIndex(owner, _getMultiOwnableStorage().nextOwnerIndex++);
181:     }

Whenever new owner is being added, _getMultiOwnableStorage().nextOwnerIndex is being increased (line 180). However, removing the owner - does not decrease _getMultiOwnableStorage().nextOwnerIndex. While this issue is not a security per se (removing the owner should not decrease _getMultiOwnableStorage().nextOwnerIndex) - it's important to notice, that MultiOwnable.sol misses a functionality which returns the number of owners.

_getMultiOwnableStorage().nextOwnerIndex is being increases every time new owner is being added - however, it cannot be used to estimate how many owners are currently set (because removing the owner does not decrease _getMultiOwnableStorage().nextOwnerIndex). Protocol does not implement any other function which might return the current number of owners.

Our recommendation is to implement additional counter - which will allow to follow how many current owners are being set. This counter needs to be increased every time new owner is being added - and decreased - whenever owner is being removed.

[2] Incorrect comment in ERC1271.sol

File: ERC1271.sol

File: src/SmartWallet/ERC1271.sol

154:     /// @return `true` is the signature is valid, else `false`.
155:     function _validateSignature(bytes32 message, bytes calldata signature) internal view virtual returns (bool);

According to NatSpec, function should return true when the signature is valid - or false - otherwise. Function _validateSignature is being overridden in CoinbaseSmartWallet.sol - which is ERC-4337. According to 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 MUST revert. Let's focus on the last part of this sentence: Any other error MUST revert. This sentence straightforwardly implies, that _validateSignature returns true when the signature is valid, returns false when signature is not valid and reverts on any other error. The NatSpec of _validSignature misses the information about the revert. When we examine the _validateSignature in CoinbaseSmartWallet.sol - we can indeed notice, that this function might revert with either InvalidEthereumAddressOwner() or InvalidOwnerBytesLength() errors. This case should be mentioned in the NatSpec.

Our recommendation is to change above NatSpec to:

/// @return `true` is the signature is valid, return `false` when the signature is not valid, revert at any other error.

[3] Change += operator to avoid potential revert

File: MagicSpend.sol

File: src/MagicSpend/MagicSpend.sol

138:         _withdrawableETH[userOp.sender] += withdrawAmount - maxCost;

When maxCost < withdrawAmount, function will revert due to underflow. The underflow might occurs, because Solidity firstly tries to calculate the withdrawAmount - maxCost (which might underflow) and then adds and assigns the result to _withdrawableETH[userOp.sender]. To avoid potential revert, it's better to avoid using += operator.

_withdrawableETH[userOp.sender] = _withdrawableETH[userOp.sender] + withdrawAmount - maxCost;

In the above scenario, Solidity will firstly add withdrawAmount to _withdrawableETH[userOp.sender] and then subtract maxCost.

[4] Function nonceUsed() should be changed to isNonceUsed()

File: MagicSpend.sol

File: src/MagicSpend/MagicSpend.sol

299:     function nonceUsed(address account, uint256 nonce) external view returns (bool) {
300:         return _nonceUsed[nonce][account];
301:     }

Function nonceUsed() verifies if the nonce has been already used (true - when nonce is used, false - otherwise). This suggests, that the more appropriate function name would be isNonceUsed().

[5] Create additional comment in createAccount() to fully describe function behavior

File: CoinbaseSmartWalletFactory.sol

According to EIP-4337:

itโ€™s expected to return the wallet address even if the wallet has already been created. This is to make it easier for clients to query the address without knowing if the wallet has already been deployed, by simulating a call to entryPoint.getSenderAddress(), which calls the factory under the hood.

File: src/SmartWallet/CoinbaseSmartWalletFactory.sol

48:         (bool alreadyDeployed, address accountAddress) =
49:             LibClone.createDeterministicERC1967(msg.value, implementation, _getSalt(owners, nonce));

Since function createAccount() uses external library from Solady: LibClone.createDeterministicERC1967() - it's a good coding practice to explicitly mention in the comment section - that LibClone.createDeterministicERC1967() won't revert. This might be confirmed when we'll take a look at the LibClone.createDeterministicERC1967() implementation:

source: https://github.com/Vectorized/solady/blob/c6738e40225288842ce890cd265a305684e52c3d/src/utils/LibClone.sol#L829 /// Note: This method is intended for use in ERC4337 factories, /// which are expected to NOT revert if the proxy is already deployed. function createDeterministicERC1967(uint256 value, address implementation, bytes32 salt)

Our recommendation is to add additional comment, before calling LibClone.createDeterministicERC1967() - that this function is expected to NOT revert if the proxy is already deployed.

[6] Protocol does not support the latest Entry Point

Files: MagicSpend.sol, CoinbaseSmartWallet.sol

File: src/SmartWallet/CoinbaseSmartWallet.sol

217:     function entryPoint() public view virtual returns (address) {
218:         return 0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789; 
219:     }

File: src/MagicSpend/MagicSpend.sol

304:     function entryPoint() public pure returns (address) {
305:         return 0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789;
306:     }

The 0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789 address points to Entry Point 0.6.0 - while the latest Entry Point version is 0.7.0.

[7] Implement ability to update Entry Point address

Files: MagicSpend.sol, CoinbaseSmartWallet.sol

File: src/SmartWallet/CoinbaseSmartWallet.sol

217:     function entryPoint() public view virtual returns (address) {
218:         return 0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789; 
219:     }

File: src/MagicSpend/MagicSpend.sol

304:     function entryPoint() public pure returns (address) {
305:         return 0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789;
306:     }

The Entry Point address points to the constant value (0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789) and cannot be changed. If Entry Point address changes (e.g., because of a bug) - it won't be possible to update it without upgrading the contract. Our recommendation is to store Entry Point address in a state variable and create an onlyOwner function which would allow to update it when needed.

[8] Improve the code readability by using getAddress() in createAccount()

File: CoinbaseSmartWalletFactory.sol

According to EIP-4337:

itโ€™s expected to return the wallet address even if the wallet has already been created. This is to make it easier for clients to query the address without knowing if the wallet has already been deployed, by simulating a call to entryPoint.getSenderAddress(), which calls the factory under the hood.

File: src/SmartWallet/CoinbaseSmartWalletFactory.sol

48:         (bool alreadyDeployed, address accountAddress) =
49:             LibClone.createDeterministicERC1967(msg.value, implementation, _getSalt(owners, nonce)); 
50: 
51:         account = CoinbaseSmartWallet(payable(accountAddress));

Even though this requirement is fulfilled, the code readability would be much more improved when function getAddress() would be utilized. For the reference, please take a look how it's done in eth-infinitism/account-abstraction:

source: https://github.com/eth-infinitism/account-abstraction/blob/7af70c8993a6f42973f520ae0752386a5032abe7/contracts/samples/SimpleAccountFactory.sol#L28 function createAccount(address owner,uint256 salt) public returns (SimpleAccount ret) { address addr = getAddress(owner, salt); uint256 codeSize = addr.code.length; if (codeSize > 0) { return SimpleAccount(payable(addr)); } ret = SimpleAccount(payable(new ERC1967Proxy{salt : bytes32(salt)}( address(accountImplementation), abi.encodeCall(SimpleAccount.initialize, (owner)) ))); }

Function utilizes getAddress() to get the address of the contract, and then - it checks if the contract is indeed deployed. Separating this logic will improve the code readability, thus it's highly recommended.

[9] Typos

Files: MultiOwnable.sol, MagicSpend.sol, CoinbaseSmartWallet.sol, ERC1271.sol

File: src/SmartWallet/MultiOwnable.sol

10:     /// @dev Mapping of indices to raw owner bytes, used to idenfitied owners by their

idenfitied should be changed to identified.

File: src/SmartWallet/MultiOwnable.sol

52:     /// @notice Thrown when trying to intialize the contracts owners if a provided owner is neither

File: src/SmartWallet/MultiOwnable.sol

58:     /// @notice Thrown when trying to intialize the contracts owners if a provided owner is 32 bytes

intialize should be changed to initialize.

File: src/SmartWallet/MultiOwnable.sol

161:     /// @param owners The intiial list of owners to register.

intial should be changed to initial.

File: src/SmartWallet/MultiOwnable.sol

200:     /// @dev Revert if the sender is not an owner fo the contract itself.

fo should be changed to of.

File: src/SmartWallet/ERC1271.sol

9: /// @dev To prevent the same signature from being validated on different accounts owned by the samer signer,

samer should be changed to same.

File: src/SmartWallet/ERC1271.sol

78:     /// @notice Wrapper around `_eip712Hash()` to produce a replay-safe hash fron the given `hash`.

fron should be changed to from.

File: src/SmartWallet/CoinbaseSmartWallet.sol

122:     /// @notice Custom implemenentation of the ERC-4337 `validateUserOp` method. The EntryPoint will

implemenentation should be changed to implementation.

File: src/SmartWallet/CoinbaseSmartWallet.sol

130:     /// @dev Reverts if the signature verification fails (except for the case mentionned earlier).

mentionned should be changed to mentioned.

File: src/SmartWallet/CoinbaseSmartWallet.sol

173:     /// @dev Reverts if the given call is not authorized to skip the chain ID validtion.
174:     /// @dev `validateUserOp()` will recompute the `userOpHash` without the chain ID befor validatin

validtion should be changed to validation. befor validatin should be changed to before validating.

File: src/MagicSpend/MagicSpend.sol

55:     /// @notice Thrown when trying to use a withdraw request after its expiry has been reched.

reched should be changed to reached.

File: src/MagicSpend/MagicSpend.sol

63:     /// @notice Thrown during validation in the context of ERC4337, when the withraw reques amount is insufficient

withraw reques should be changed to withdraw request.

File: src/MagicSpend/MagicSpend.sol

76:     ///         requested amount (exluding the `maxGasCost` set by the Entrypoint).

exluding should be changed to excluding.

File: src/MagicSpend/MagicSpend.sol

87:     /// @dev This should only really occur if for unknown reasons the transfer of the withdrwable

withdrwable should be changed to withdrawable.

File: src/MagicSpend/MagicSpend.sol

154:         // Compute the total remaining funds available for the user accout.
155:         // NOTE: Take into account the user operation gas that was not consummed.

accout should be changed to account. consummed should be changed to consumed.

[10] Incorrect punctuation

Files: MagicSpend.sol, CoinbaseSmartWallet.sol, ERC1271.sol

According to American style guides, i.e. and e.g. should be followed by comma, while according to British style guides, there's no need to follow i.e. and e.g. by comma. The current code-base mixes those styles in the comments, thus it's recommended to stick to one. Since there are more instances of the style without a comma:

./SmartWallet/CoinbaseSmartWallet.sol: /// @notice Sends to the EntryPoint (i.e. `msg.sender`) the missing funds for this transaction. ./MagicSpend/MagicSpend.sol: /// funds to the user account failed (i.e. this contract's ETH balance is insufficient or ./WebAuthnSol/WebAuthn.sol: /// - Verifies that the client JSON is of type "webauthn.get", i.e. the client was responding to a request to ./WebAuthnSol/WebAuthn.sol: /// i.e. the RP does not intend to verify an attestation. ./SmartWallet/CoinbaseSmartWallet.sol: /// @dev Subclass MAY override this modifier for better funds management (e.g. send to the ./SmartWallet/CoinbaseSmartWallet.sol: /// allows making a "simulation call" without a valid signature. Other failures (e.g. nonce ./SmartWallet/CoinbaseSmartWallet.sol: /// to be replayed for all accounts sharing the same address across chains. E.g. This may be

our recommendation is to implement a fix (change e.g., to e.g. and i.e., to i.e) in the following instances:

File: src/MagicSpend/MagicSpend.sol

168:     ///      use cases (e.g., swap or mint).

File: src/SmartWallet/CoinbaseSmartWallet.sol

170:     /// @notice Execute the given call from this account to this account (i.e., self call).

File: src/SmartWallet/ERC1271.sol

62:     ///      cross-account-replay layer on the given `hash` (i.e., verification is run on the replay-safe

[11] Incorrect grammar

Files: MultiOwnable.sol, CoinbaseSmartWallet.sol

File: src/SmartWallet/MultiOwnable.sol

13:     ///      Some uses โ€”-such as signature validation for secp256r1 public key ownersโ€”-
14:     ///      requires the caller to assert which owner signed. To economize calldata,

Some uses (...) requires should be changed to Some uses (...) require.

File: src/SmartWallet/CoinbaseSmartWallet.sol

176:     ///      to be replayed for all accounts sharing the same address across chains. E.g. This may be

E.g. This should be changed to E.g., this (lower-cased letter).

#0 - raymondfam

2024-03-24T02:09:57Z

1: The contract is using mappings to tract ownerships. There're no arrays entailed. 2: This will make "simulation call" to break when validateUserOp is dependent on _validateSignature. 3: (withdrawAmount < maxCost) would have made the call revert in the earlier check. 4: NC 5: NC 7: Same as in 6. 9: They have mostly been reported by the bot. 10, 11: NC

2 L and 4 NC.

#1 - c4-pre-sort

2024-03-24T02:10:02Z

raymondfam marked the issue as sufficient quality report

#2 - c4-judge

2024-03-27T12:55:23Z

3docSec marked the issue as grade-b

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