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: 16/105
Findings: 3
Award: $741.87
๐ Selected for report: 0
๐ Solo Findings: 0
666.6076 USDC - $666.61
https://github.com/code-423n4/2023-01-biconomy/blob/main/scw-contracts/contracts/smart-contract-wallet/interfaces/ISignatureValidator.sol#L5-L6 https://github.com/code-423n4/2023-01-biconomy/blob/main/scw-contracts/contracts/smart-contract-wallet/SmartAccount.sol#L342
On verifying signatures, the outdated EIP1271_MAGIC_VALUE is used EIP1271_MAGIC_VALUE = 0x20c13b0b
for verifying EIP1271 signed signatures.
Link to code
File: https://github.com/code-423n4/2023-01-biconomy/blob/main/scw-contracts/contracts/smart-contract-wallet/interfaces/ISignatureValidator.sol 4: contract ISignatureValidatorConstants { 5: // bytes4(keccak256("isValidSignature(bytes,bytes)") 6: bytes4 internal constant EIP1271_MAGIC_VALUE = 0x20c13b0b; abstract contract ISignatureValidator is ISignatureValidatorConstants { /** * @dev Should return whether the signature provided is valid for the provided data * @param _data Arbitrary length data signed on the behalf of address(this) * @param _signature Signature byte array associated with _data * * MUST return the bytes4 magic value 0x20c13b0b when function passes. * MUST NOT modify state (using STATICCALL for solc < 0.5, view modifier for solc > 0.5) * MUST allow external calls */ 19: function isValidSignature(bytes memory _data, bytes memory _signature) public view virtual returns (bytes4); } }
As current EIP1271 requires function isValidSignature
MUST return the bytes4 magic value 0x1626ba7e when function passes
, using outdated magic value will result in failures of all transactions signed by contract following EIP1271.
The magic value change is due to function isValidSignature
signature update. The previous signature is isValidSignature(bytes,bytes)
, while current one is isValidSignature(bytes32,bytes)
.
The EIP1271: Link to EIP1271
File link: https://eips.ethereum.org/EIPS/eip-1271 // bytes4(keccak256("isValidSignature(bytes32,bytes)") bytes4 constant internal MAGICVALUE = 0x1626ba7e; /** * @dev Should return whether the signature provided is valid for the provided hash * @param _hash Hash of the data to be signed * @param _signature Signature byte array associated with _hash * * MUST return the bytes4 magic value 0x1626ba7e when function passes. * MUST NOT modify state (using STATICCALL for solc < 0.5, view modifier for solc > 0.5) * MUST allow external calls */ function isValidSignature( bytes32 _hash, bytes memory _signature) public view returns (bytes4 magicValue); }
Manual code review.
To make changes to relevant functions following the current EIP2171 specification.
#0 - c4-judge
2023-01-17T07:16:51Z
gzeon-c4 marked the issue as primary issue
#1 - c4-judge
2023-01-17T07:16:59Z
gzeon-c4 marked the issue as satisfactory
#2 - c4-sponsor
2023-01-26T00:25:49Z
livingrockrises marked the issue as sponsor acknowledged
#3 - c4-judge
2023-02-10T11:39:52Z
gzeon-c4 marked issue #288 as primary and marked this issue as a duplicate of 288
๐ 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
36.5015 USDC - $36.50
C4 checked:
Issue | Instances | |
---|---|---|
NC-1 | Emit event when receiving ETH | 1 |
NC-2 | Missing checks for address(0) in low-level call | 1 |
NC-3 | Redundant verification | 2 |
NC-4 | Versioning | 1 |
It's a good practice to emit an event whenever a state variable changes especially when receiving ETH. Function SmartAccount.receive()
can emit an event to indicate receiving ETH, e.g. emit ReceivedETH(from, to, amount)
. The event can be defined as:
The receive function can be:
File: https://github.com/code-423n4/2023-01-biconomy/blob/main/scw-contracts/contracts/smart-contract-wallet/SmartAccount.sol event ReceivedETH(address indexed from, address indexed to, uint256 indexed amount); receive() external payable { emit ReceivedETH(msg.sender, address(this), msg.value); }
address(0)
in low-level callFile: https://github.com/code-423n4/2023-01-biconomy/blob/main/scw-contracts/contracts/smart-contract-wallet/SmartAccount.sol 477: function _call(address target, uint256 value, bytes memory data) internal { 478: (bool success, bytes memory result) = target.call{value : value}(data); if (!success) { assembly { revert(add(result, 32), mload(result)) } } }
In the above function, target
is not verified. So, if there is no code at target
address, the low-levell call target.call()
will always return true
. This has some implications:
target
is mistakenly set to address(0)
. In this case, it's a good practice to verify: target != address(0)
;target
is supposed to ba an address of a smart contract, and if target
is mistakenly set to a non-existing address (i.e. there is no code at target
address), the low-level call target.call()
will always return true
, this breaks the function's logic. In this case, it is recommended to verify that the code size at target
is larger than 0
.Instances (2)
In SmartAccount
contract, there are two function protected by onlyOwner
. In the function body, they further restrict access by call _requireFromEntryPointOrOwner()
on Line 461 and 466. Since onlyOwner
has stricter restriction than _requireFromEntryPointOrOwner()
. The latter can be omitted.
File: https://github.com/code-423n4/2023-01-biconomy/blob/main/scw-contracts/contracts/smart-contract-wallet/SmartAccount.sol function execute(address dest, uint value, bytes calldata func) external onlyOwner{ 461: _requireFromEntryPointOrOwner(); //@audit This line can be remove _call(dest, value, func); } function executeBatch(address[] calldata dest, bytes[] calldata func) external onlyOwner{ 466: _requireFromEntryPointOrOwner(); //@audit This line can be remove require(dest.length == func.length, "wrong array lengths"); for (uint i = 0; i < dest.length;) { _call(dest[i], 0, func[i]); unchecked { ++i; } } }
L461 and L466 can be removed.
File: https://github.com/code-423n4/2023-01-biconomy/blob/main/scw-contracts/contracts/smart-contract-wallet/SmartAccount.sol 36: string public constant VERSION = "1.0.2"; // using AA 0.3.0
In Contract SmartAccount
, VERSION
is defined as a constant, which means the version number is fixed. It's alright if it is intended.
However, there is a updateImplementation
function which upgrades functionality of the contract. It also emits an event: emit ImplementationUpdated(address(this), VERSION, _implementation);
. If VERSION
is a constant, it does not make much sense to include a constant in an event.
It is a good practice to change the VERSION
information accordingly while upgrading. It might be a good choice to offer an option to change the VERSION
number in updateImplementation
function so that VERSION
reflects current base wallet implementation status.
Issue | Instances | |
---|---|---|
L-1 | Unspecific compiler version pragma | 1 |
L-2 | mixedAuth modifier may have potential issues | 3 |
Donโt useย pragma solidity ^0.8.12
ย when you set the compiler version yourself. This makes it ambiguous to verifiers which version of solidity was used. Only use this pattern for library code where you are not the one to compile it.
From security perspective there's always a trade-off between new code fixing known issues and potentially introducing new bugs in new releases. It is recommended to use a version that is released for a while instead of a nightly released version, i.e., to avoid from using pragma solidity ^0.x.x;
or pragma solidity >=0.8.12;
.
In this project, it is recommended to use version 0.8.17. So the recommended compiler version pragma is:
pragma solidity 0.8.17;
File: https://github.com/code-423n4/2023-01-biconomy/blob/main/scw-contracts/contracts/smart-contract-wallet/aa-4337/core/BaseAccount.sol 2: pragma solidity ^0.8.12;
All other solidity files (except for Libraries) should make the same change.
mixedAuth
modifier may have potential issuesContract SmartAccount
uses mixedAuth
to protect privileged functions like setOwner, updateImplementation, updateEntryPoint
. The modifier mixedAuth
allows either the owner of the contract or the contract itself can access to these protected functions. However, in the current contract, there is no way for the contract itself to call these functions since there is no pre-coded code that can call these functions yet.
If there is no plan to implement new code to enable the contract itself to call these privileged functions, it is a good practice to change mixedAuth
to onlyOwner
. This is because mixedAuth
relaxes the restriction. It may unintentionally introduce risks to allow access to privileged functions.
For example, if there is a need for a specific group of users to call an internal function _call()
which makes low level call on provided address. The function may look like this:
function someFunction(address target, bytes memory calldata) external onlySpecificGroup { ...some code... _call(target, 0, calldata); }
Note: the above function is protected by onlySpecificGroup
. Function _call()
is an existing function in SmartAccount
contract.
The above function will open doors for onlySpecificGroup
to access to all privileged functions protected by mixedAuth
since target
can be set to the address of SmartAccount
contract, and calldata
can be encoded to call privileged functions. And the msg.sender
received by the privileged function is the address of the calling contract, i.e. contract SmartAccount
.
Suggestions for mitigation:
mixedAuth
to onlyOwner
if there is no necessary to implement extra code. Don't leave potential risks in the code.mixedAuth
.#0 - c4-judge
2023-01-22T15:40:46Z
gzeon-c4 marked the issue as grade-b
#1 - c4-sponsor
2023-02-09T12:29:18Z
livingrockrises marked the issue as sponsor confirmed
๐ Selected for report: 0xSmartContract
Also found by: 0x1f8b, 0xhacksmithh, Aymen0909, Bnke0x0, IllIllI, Rageur, RaymondFam, Rickard, Rolezn, Secureverse, arialblack14, chaduke, chrisdior4, cthulhu_cult, giovannidisiena, gz627, lukris02, oyc_109, pavankv, privateconstant, shark
38.7634 USDC - $38.76
Issue | Instances | |
---|---|---|
GAS-1 | Applying unchecked operations where no overflow/underflow possible | 13 |
GAS-2 | Gas optimizatin for SmartAccount.execTransaction | 1 |
GAS-3 | Gas optimizatin for SmartAccount.requiredTxGas | 2 |
GAS-4 | Gas optimizatin for SmartAccount._validateSignature | 1 |
GAS-5 | Using named variable returns saves gas | 2 |
unchecked
operations where no overflow/underflow possibleInstances (13)
unchecked
operations on the index increment in for-loops: Instances (7)In addtion to the for-loop gas optimization in the C4audit output, a further gas optimization can be achieved to save another 63/120 gas per loop.
For stack variables like uint256 index
, each unchecked{++index;}
saves some gas compared with ++index;
. The saved gas amount varies under different situations - 120 without compiler optimization, and 63 with compiler optimization (200 runs). The index
of a for-loop won't overflow so it is safe to apply unchecked increment. A typical optimized for-loop looks like:
... uint256 length = someArray.length; for (uint256 index; index<length;){ ...do sth with someArray[index]... unchecked { ++index; } }
File: https://github.com/code-423n4/2023-01-biconomy/blob/main/scw-contracts/contracts/smart-contract-wallet/aa-4337/core/EntryPoint.sol 74: for (uint256 i = 0; i < opslen; i++) { 80: for (uint256 i = 0; i < opslen; i++) { 100: for (uint256 i = 0; i < opasLen; i++) { 107: for (uint256 a = 0; a < opasLen; a++) { 112: for (uint256 i = 0; i < opslen; i++) { 128: for (uint256 a = 0; a < opasLen; a++) { 134: for (uint256 i = 0; i < opslen; i++) {
unchecked
operations where no overflow/underflow: Instances (6)File: https://github.com/code-423n4/2023-01-biconomy/blob/main/scw-contracts/contracts/smart-contract-wallet/aa-4337/core/EntryPoint.sol //@audit Refer to L106 (opIndex = 0); `opIndex` increments in a limited for-loop 114: opIndex++; //@audit Refer to L127 136: opIndex++;
File: https://github.com/code-423n4/2023-01-biconomy/blob/main/scw-contracts/contracts/smart-contract-wallet/SmartAccount.sol //@audit Won't overflow: `gasleft()` is the amount of gas available for execution, `msg.data.length` is the calldata length 200: uint256 startGas = gasleft() + 21000 + msg.data.length * 8; //@audit Each Nonce incremented from zero, won't overflow 216: nonces[batchId]++;
FIle: https://github.com/code-423n4/2023-01-biconomy/blob/main/scw-contracts/contracts/smart-contract-wallet/base/ModuleManager.sol //@audit L121 restricts `moduleCount < pageSize` 124: moduleCount++;
File: https://github.com/code-423n4/2023-01-biconomy/blob/main/scw-contracts/contracts/smart-contract-wallet/aa-4337/core/StakeManager.sol //@audit see L117: `require(withdrawAmount <= info.deposit, "Withdraw amount too large");`` 118: info.deposit = uint112(info.deposit - withdrawAmount);
SmartAccount.execTransaction
Function SmartAccount.execTransaction
is a key one that is frequently called. So, gas optimization for this function is important. Statements of a code block in this function can be optimized to save over 7000 gas, which is significant:
File: https://github.com/code-423n4/2023-01-biconomy/blob/main/scw-contracts/contracts/smart-contract-wallet/SmartAccount.sol { bytes memory txHashData = encodeTransactionData( // Transaction info _tx, // Payment info refundInfo, // Signature info 212: nonces[batchId] //@audit This line can combine with L216 here to `nonces[batchId]++` ); // Increase nonce and execute transaction. // Default space aka batchId is 0 216: nonces[batchId]++; txHash = keccak256(txHashData); checkSignatures(txHash, txHashData, signatures); }
Combing L212 wieh L216 can save over 7000 gas. Optimized code:
File: https://github.com/code-423n4/2023-01-biconomy/blob/main/scw-contracts/contracts/smart-contract-wallet/SmartAccount.sol { bytes memory txHashData = encodeTransactionData( // Transaction info _tx, // Payment info refundInfo, // Signature info 212: nonces[batchId]++ //@audit Optimized by combining with L216 ); // Increase nonce and execute transaction. // Default space aka batchId is 0 //@audit L216 is removed txHash = keccak256(txHashData); checkSignatures(txHash, txHashData, signatures); }
SmartAccount.requiredTxGas
File: https://github.com/code-423n4/2023-01-biconomy/blob/main/scw-contracts/contracts/smart-contract-wallet/SmartAccount.sol 363: function requiredTxGas( address to, uint256 value, bytes calldata data, Enum.Operation operation ) external returns (uint256) { uint256 startGas = gasleft(); // We don't provide an error message here, as we use it to return the estimate require(execute(to, value, data, operation, gasleft())); 372: uint256 requiredGas = startGas - gasleft(); // Convert response to string and return via error message 374: revert(string(abi.encodePacked(requiredGas))); 375: }
Two optimizations can be applied to the above function:
unchecked
math operation since startGas >= gasleft()
.
Optimized code:File: https://github.com/code-423n4/2023-01-biconomy/blob/main/scw-contracts/contracts/smart-contract-wallet/SmartAccount.sol 363: function requiredTxGas( address to, uint256 value, bytes calldata data, Enum.Operation operation ) external returns (uint256) { uint256 startGas = gasleft(); // We don't provide an error message here, as we use it to return the estimate require(execute(to, value, data, operation, gasleft())); // Convert response to string and return via error message unchecked { revert(string(abi.encodePacked(startGas - gasleft()))); } 375: }
SmartAccount._validateSignature
File: https://github.com/code-423n4/2023-01-biconomy/blob/main/scw-contracts/contracts/smart-contract-wallet/SmartAccount.sol 506: 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"); 512: return 0; //@audit Remove this line }
Remove L512 in the above function saves gas. This line is redundant since the default value of deadline
is 0
.
Instances (2)
Using named variable returns reduces code size, saves deployment cost, and is less error prone (missing the return statement ).
File: https://github.com/code-423n4/2023-01-biconomy/blob/main/scw-contracts/contracts/smart-contract-wallet/SmartAccount.sol 139 /// @dev Returns the chain id used by this contract. 140 function getChainId() public view returns (uint256) { //@audit Specify returning variables uint256 id; //@audit This line can be removed when using named variable returns // solhint-disable-next-line no-inline-assembly assembly { id := chainid() } return id; //@audit This line can be removed when using named variable returns }
Optimized code:
File: https://github.com/code-423n4/2023-01-biconomy/blob/main/scw-contracts/contracts/smart-contract-wallet/SmartAccount.sol 139 /// @dev Returns the chain id used by this contract. 140 function getChainId() public view returns (uint256 id) { //@audit Specify returning variable: `id` // solhint-disable-next-line no-inline-assembly assembly { id := chainid() } }
Other instances:
#0 - c4-judge
2023-01-22T16:17:47Z
gzeon-c4 marked the issue as grade-b
#1 - c4-sponsor
2023-02-09T12:29:00Z
livingrockrises marked the issue as sponsor confirmed