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
Rank: 2/51
Findings: 2
Award: $6,761.88
๐ Selected for report: 0
๐ Solo Findings: 0
6725.5435 USDC - $6,725.54
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.
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:
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:
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.MultiOwnable.removeOwnerAtIndex
takes just the index, the owner on chain X and Y does not be the same addressPlease 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).
Manual code review
Remove MultiOwnable.addOwnerPublicKey.selector
, MultiOwnable.addOwnerAddress.selector
, MultiOwnable.removeOwnerAtIndex.selector
from canSkipChainIdValidation()
function.
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
๐ Selected for report: 0xmystery
Also found by: 0xbrett8571, 0xhacksmithh, 7ashraf, Bigsam, Circolors, IceBear, Jorgect, Koala, Limbooo, SBSecurity, Tigerfrake, ZanyBonzy, aycozynfada, cheatc0d3, cryptphi, d3e4, doublespending, foxb868, gpersoon, imare, jesjupyter, lsaudit, robriks, shealtielanz, y4y
36.3397 USDC - $36.34
MultiOwnable.sol
misses functionality which allows to return the current number of ownersFile: 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.
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.
+=
operator to avoid potential revertFile: 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
.
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()
.
createAccount()
to fully describe function behaviorFile: 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.
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.
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.
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.
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
.
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
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