Halborn Logo

Smart Wallet Contracts V2 - Biconomy


Prepared by:

Halborn Logo

HALBORN

Last Updated 04/25/2024

Date of Engagement by: October 1st, 2022 - October 17th, 2022

Summary

100% of all REPORTED Findings have been addressed

All findings

13

Critical

0

High

1

Medium

0

Low

5

Informational

7


1. INTRODUCTION

Biconomy engaged Halborn to conduct a security audit on their smart contracts beginning on 2022-10-01 and ending on 2022-10-17. The security assessment was scoped to the smart contracts provided to the Halborn team.

2. AUDIT SUMMARY

The team at Halborn was provided nearly three weeks for the engagement and assigned a full-time security engineer to audit the security of the smart contract. The security engineer is a blockchain and smart-contract security expert with advanced penetration testing, smart-contract hacking, and deep knowledge of multiple blockchain protocols.

The purpose of this audit is to:

    • Ensure that smart contract functions operate as intended

    • Identify potential security issues with the smart contracts

In summary, Halborn identified some security risks that were mostly addressed by the Biconomy team.

3. TEST APPROACH & METHODOLOGY

Halborn performed a combination of manual and automated security testing to balance efficiency, timeliness, practicality, and accuracy regarding the scope of the smart contract audit. While manual testing is recommended to uncover flaws in logic, process, and implementation; automated testing techniques help enhance coverage of smart contracts and can quickly identify items that do not follow security best practices. The following phases and associated tools were used throughout the term of the audit:

    • Research into architecture and purpose.

    • Smart Contract manual code review and walkthrough.

    • Graphing out functionality and contract logic/connectivity/functions(solgraph)

    • Manual Assessment of use and safety for the critical Solidity variables and functions in scope to identify any arithmetic related vulnerability classes.

    • Dynamic Analysis (foundry)

    • Static Analysis(slither)

4. SCOPE

\begin{enumerate} \item Biconomy Smart Wallet Security Audit Test Scope \begin{enumerate} \item Repository: \href{https://github.com/bcnmy/scw-contracts/tree/827839c1920e104637dc19060570984e43056633}{Smart Wallet Contracts} \item Commit ID: \href{https://github.com/bcnmy/scw-contracts/tree/827839c1920e104637dc19060570984e43056633}{827839c1920e104637dc19060570984e43056633} \end{enumerate} \item Out-of-Scope \begin{enumerate} \item contracts/Greeter.sol \item contracts/test/ \item contracts/modules/test/ \end{enumerate} \end{enumerate}

5. RISK METHODOLOGY

Vulnerabilities or issues observed by Halborn are ranked based on the risk assessment methodology by measuring the LIKELIHOOD of a security incident and the IMPACT should an incident occur. This framework works for communicating the characteristics and impacts of technology vulnerabilities. The quantitative model ensures repeatable and accurate measurement while enabling users to see the underlying vulnerability characteristics that were used to generate the Risk scores. For every vulnerability, a risk level will be calculated on a scale of 5 to 1 with 5 being the highest likelihood or impact.
RISK SCALE - LIKELIHOOD
  • 5 - Almost certain an incident will occur.
  • 4 - High probability of an incident occurring.
  • 3 - Potential of a security incident in the long term.
  • 2 - Low probability of an incident occurring.
  • 1 - Very unlikely issue will cause an incident.
RISK SCALE - IMPACT
  • 5 - May cause devastating and unrecoverable impact or loss.
  • 4 - May cause a significant level of impact or loss.
  • 3 - May cause a partial impact or loss to many.
  • 2 - May cause temporary impact or loss.
  • 1 - May cause minimal or un-noticeable impact.
The risk level is then calculated using a sum of these two values, creating a value of 10 to 1 with 10 being the highest level of security risk.
Critical
High
Medium
Low
Informational
  • 10 - CRITICAL
  • 9 - 8 - HIGH
  • 7 - 6 - MEDIUM
  • 5 - 4 - LOW
  • 3 - 1 - VERY LOW AND INFORMATIONAL

6. SCOPE

Out-of-Scope: New features/implementations after the remediation commit IDs.

7. Assessment Summary & Findings Overview

Critical

0

High

1

Medium

0

Low

5

Informational

7

Impact x Likelihood

HAL-01

HAL-03

HAL-04

HAL-05

HAL-06

HAL-02

HAL-08

HAL-09

HAL-10

HAL-11

HAL-12

HAL-13

HAL-07

Security analysisRisk levelRemediation Date
VULNERABLE ECDSA LIBRARYHighSolved - 10/28/2022
MISSING REENTRANCY GUARDLowPartially Solved
POSSIBLE INTEGER OVERFLOWS DUE TO IMPROPER USE OF UNCHECKED KEYWORDLowSolved - 10/28/2022
SINGLE-STEP OWNERSHIP CHANGELowRisk Accepted
MISSING ZERO ADDRESS CHECKSLowSolved - 10/28/2022
IGNORED RETURN VALUESLowSolved - 11/01/2022
FLOATING PRAGMAInformationalSolved - 10/28/2022
FOR LOOPS CAN BE OPTIMIZEDInformationalSolved - 11/01/2022
USE DECLARED FUNCTION INSTEAD OF MSG.SENDERInformationalSolved - 11/02/2022
IMMUTABLE KEYWORD COSTS LESS GAS FOR CONSTANT VARIABLESInformationalSolved - 10/28/2022
LONG REVERT MESSAGES USE ADDITIONAL PUSH32 INSTRUCTIONInformationalAcknowledged
UNUSED VARIABLES, COMMENTS AND IMPORTSInformationalAcknowledged
OPEN TODOSInformationalSolved - 10/28/2022

8. Findings & Tech Details

8.1 VULNERABLE ECDSA LIBRARY

// High

Description

A vulnerability found in the ECDSA library developed by the OpenZeppelin team. OpenZeppelin team published a security advisory on GitHub on August 22nd, 2022. According to the vulnerability, recover and tryRecover functions are vulnerable, as those functions accept the standard signature format and compact signature format. (EIP-2098)

The functions ECDSA.recover and ECDSA.tryRecover are vulnerable to some sort of signature malleability because they accept compact EIP-2098 signatures in addition to the traditional 65-byte signature format. This is only an issue for the functions that take a single byte argument, and not the functions that take r, v, s or r, vs as separate arguments.

Potentially affected contracts are those that implement signature reuse or replay protection by marking the signature itself as used, rather than the signed message or a nonce included in it. A user can take a signature that has already been submitted, submit it again in a different form, and bypass this protection.

It was observed that Biconomy contracts were using the vulnerable ECDSA library to recover signatures.

ECDSA Signature Malleability

Code Location

package.json

"@openzeppelin/contracts": "^4.2.0",
"@openzeppelin/contracts-upgradeable": "^4.7.3",

The PoC code below proofs that both signatures addresses the same address, as they are identical but in different formats.

ECDSA vulnerability test case - PoC

function testValidateUserOpPaymaster() public {

        UserOperation memory userOp = UserOperation({
            sender: user1,
            nonce: 0,
            initCode:  new bytes(0x0),
            callData:  new bytes(0x0),
            callGasLimit: 0,
            verificationGasLimit: 0,
            preVerificationGas: 0,
            maxFeePerGas: 0,
            maxPriorityFeePerGas: 0,
            paymasterAndData: new bytes(0x0),
            signature: new bytes(0x0)
        });

        bytes32 hash = paymentMasterNew.getHash(userOp);

        (uint8 v, bytes32 r, bytes32 s) = vm.sign(101, hash.toEthSignedMessageHash());
        bytes memory signature = abi.encodePacked(r, s, v);
        bytes memory paymasterAndData = abi.encodePacked(user1, signature);

        userOp.paymasterAndData = paymasterAndData;

        paymentMasterNew.validatePaymasterUserOp(userOp, bytes32(uint256(1)), 0); // standart signature

        // standart sig: 0xe34...b2a0a094...6c1c
        // short sig(to2098format): 0xe34...baa0a094...6c

        bytes memory shortSig2098 = hex"e3402d57abf681838763887091f7d4fbb52cd03073a375dd010a1dd0e251f87baa0a0948e6a3579f7b88876286d372aaaf46f2af8e4db9a2a59845130d174a6c";

        paymasterAndData = abi.encodePacked(user1, shortSig2098);

        userOp.paymasterAndData = paymasterAndData;

        paymentMasterNew.validatePaymasterUserOp(userOp, bytes32(uint256(1)), 0); // same signature(eip2098 format)
    }
ecdsa_result.jpg
Score
Impact: 4
Likelihood: 4
Recommendation

SOLVED: The Biconomy team solved this finding by upgrading the @openzeppelin/contracts package version to 4.7.3.

Commit ID: b813a60a8475672c2a2d00b7ef5d1839461eaa3e

8.2 MISSING REENTRANCY GUARD

// Low

Description

To protect against cross-function re-entrancy attacks, it may be necessary to use a mutex. By using this lock, an attacker can no longer exploit the withdrawal function with a recursive call. OpenZeppelin has its own mutex implementation called ReentrancyGuard which provides a modifier to any function called nonReentrant that guards the function with a mutex against re-entrancy attacks.

It is also suggested to use call.value method with any Reentrancy protection.

Code Location

Functions with missing Reentrancy Guard

SmartWallet:handlePayment()
SmartWallet:transfer()
SmartWalletNoAuth:handlePayment()
SmartWalletNoAuth:transfer()
EntryPoint:innerHandleOp()
StakeManager:withdrawStake()
StakeManager:withdrawTo()
SimpleWallet:addDeposit()
Score
Impact: 2
Likelihood: 3
Recommendation

PARTIALLY SOLVED: The Biconomy team solved this finding by implementing nonReentrant modifiers to functions in the Code Location section. This finding will not be fixed for Account Abstraction Core contracts, as they are templates.

Commit ID: 8624fcf9a92a006a2fc6bad7bb9f834ab3641ffe

8.3 POSSIBLE INTEGER OVERFLOWS DUE TO IMPROPER USE OF UNCHECKED KEYWORD

// Low

Description

As of version 0.8.0 in Solidity, a control mechanism was implemented to prevent overflow and underflow issues for mathematical operations. Since this costs extra gas, the unchecked keyword was also implemented to remove this extra control mechanism for specific code locations.

This keyword should be used with caution to benefit from gas consumption. Otherwise, overflows/underflows for mathematical operations can occur.

Code Location

EntryPoint.sol

function _getRequiredPrefund(MemoryUserOp memory mUserOp) internal view returns (uint256 requiredPrefund) {
    unchecked { 
        //when using a Paymaster, the verificationGasLimit is used also to as a limit for the postOp call.
        // our security model might call postOp eventually twice
        uint256 mul = mUserOp.paymaster != address(0) ? 3 : 1;
        uint256 requiredGas = mUserOp.callGasLimit + mUserOp.verificationGasLimit * mul + mUserOp.preVerificationGas;

        // TODO: copy logic of gasPrice?
        requiredPrefund = requiredGas * getUserOpGasPrice(mUserOp);
    }

EntryPoint.sol

function _handlePostOp(uint256 opIndex, IPaymaster.PostOpMode mode, UserOpInfo memory opInfo, bytes memory context, uint256 actualGas) private returns (uint256 actualGasCost) {
        uint256 preGas = gasleft();
    unchecked {
        address refundAddress;
        MemoryUserOp memory mUserOp = opInfo.mUserOp;
        uint256 gasPrice = getUserOpGasPrice(mUserOp);

        address paymaster = mUserOp.paymaster;
        if (paymaster == address(0)) {
            refundAddress = mUserOp.sender;
        } else {
            refundAddress = paymaster;
            if (context.length > 0) {
                actualGasCost = actualGas * gasPrice;
                if (mode != IPaymaster.PostOpMode.postOpReverted) {
                    IPaymaster(paymaster).postOp{gas : mUserOp.verificationGasLimit}(mode, context, actualGasCost);
                } else {
                    // solhint-disable-next-line no-empty-blocks
                    try IPaymaster(paymaster).postOp{gas : mUserOp.verificationGasLimit}(mode, context, actualGasCost) {}
                    catch Error(string memory reason) {
                        revert FailedOp(opIndex, paymaster, reason);
                    }
                    catch {
                        revert FailedOp(opIndex, paymaster, "postOp revert");
                    }
                }
            }
        }
Score
Impact: 3
Likelihood: 1
Recommendation

SOLVED: The Biconomy team solved this finding by removing the unchecked keywords directly from the above functions. That eliminates the risk of overflow.

Commit ID: e3080c259a580b3ddc205776a2aeca999850b0c0

8.4 SINGLE-STEP OWNERSHIP CHANGE

// Low

Description

A two-step procedure for changing roles in the contract is missing. If the address is incorrect, the new address will assume the functionality of the new role directly. There is no way to recover these roles back after these types of errors.

Code Location

SmartWallet.sol

function setOwner(address _newOwner) external mixedAuth { 
        require(_newOwner != address(0), "Smart Account:: new Signatory address cannot be zero"); 
        address oldOwner = owner;
        owner = _newOwner;
        emit EOAChanged(address(this), oldOwner, _newOwner);
    }

SmartWalletNoAuth.sol

function setOwner(address _newOwner) external mixedAuth { 
        require(_newOwner != address(0), "Smart Account:: new Signatory address cannot be zero"); 
        address oldOwner = owner;
        owner = _newOwner;
        emit EOAChanged(address(this), oldOwner, _newOwner);
    }

BasePaymaster.sol

function _transferOwnership(address newOwner) internal virtual { 
        address oldOwner = owner;
        owner = newOwner;
        emit OwnershipTransferred(oldOwner, newOwner);
    }
Score
Impact: 2
Likelihood: 2
Recommendation

RISK ACCEPTED: The Biconomy team accepted the risk of this finding. It has been decided to continue with the one-step ownership change pattern.

8.5 MISSING ZERO ADDRESS CHECKS

// Low

Description

Biconomy Smart Wallet contracts have address fields in multiple functions. These functions are missing address validations. Each address should be validated and checked to be non-zero. This is also considered a best practice.

During testing, it has been found that some of these inputs are not protected against using address(0) as the destination address.

Code Location

Missing zero address checks - Variables

StakeManager.withdrawStake(address).withdrawAddress
StakeManager.withdrawTo(address,uint256).withdrawAddress
VerifyingPaymasterFactory.deployVerifyingPaymaster(address,address,IEntryPoint).owner
VerifyingPaymasterFactory.deployVerifyingPaymaster(address,address,IEntryPoint).verifyingSigner
VerifyingPaymaster.init(IEntryPoint,address,address)._verifyingSigner
VerifyingPaymaster.init(IEntryPoint,address,address)._owner
VerifyingPaymaster.setSigner(address)._newVerifyingSigner
VerifyingPaymaster.init(IEntryPoint,address,address)._verifyingSigner
VerifyingPaymaster.init(IEntryPoint,address,address)._owner
VerifyingPaymaster.setSigner(address)._newVerifyingSigner
StakeManager.withdrawStake(address).withdrawAddress
StakeManager.withdrawTo(address,uint256).withdrawAddress
BasePaymaster.setEntryPoint(IEntryPoint)._entryPoint
Score
Impact: 2
Likelihood: 2
Recommendation

SOLVED: The Biconomy team solved this finding by adding require checks to prevent the use of zero addresses.

Commit ID: 10d3933d5b9a461b85aebb9343e92ed94ac74970

8.6 IGNORED RETURN VALUES

// Low

Description

The requiredTxGas function was declared to return uint256 as a return variable after a successful call. That function calls execute() function, and that function was designed to return bool, not uint256. Therefore, it does not return any variables at all. It is important to validate these return variables. In this case, calling these functions can break any integrations or composability.

Code Location

SmartWalletNoAuth.sol

function requiredTxGas(
        address to,
        uint256 value,
        bytes calldata data,
        Enum.Operation operation
    ) external returns (uint256) { 
        // We don't provide an error message here, as we use it to return the estimate
        require(execute(to, value, data, operation, gasleft()));
    }
Score
Impact: 2
Likelihood: 2
Recommendation

SOLVED: The Biconomy team solved this finding by removing the return variable from the requiredTxGas function.

Commit ID: 508c44ed749f7c4b06e704d6f6b4e13db9099634

8.7 FLOATING PRAGMA

// Informational

Description

The project contains many instances of floating pragma. Contracts should be deployed with the same compiler version and flags that they have been tested with thoroughly. Locking the pragma helps to ensure that contracts do not accidentally get deployed using, for example, either an outdated compiler version that might introduce bugs that affect the contract system negatively or a pragma version too recent which has not been extensively tested.

During the audit, it has seen all contracts use ^0.8.0 as pragma version.

Score
Impact: 1
Likelihood: 2
Recommendation

SOLVED: This finding was solved by updating the Solidity version from ^0.8.0 to 0.8.12. All caret symbols have been removed from these contracts.

Commit ID: 3b882439d3981db3b1c992fefa18e4b0f80746d4

8.8 FOR LOOPS CAN BE OPTIMIZED

// Informational

Description

It has been observed all for loops in the protocol were not optimized properly. Having not optimized for loops can cost too much gas usage.

These for loops can be optimized with suggestions above:

  1. In Solidity (pragma 0.8.0 and later), adding unchecked keyword for arithmetical operations can reduce gas usage on contracts where underflow/underflow is unrealistic. It is possible to save gas by using this keyword on multiple code locations.
  2. In all for loops, the index variable is incremented using +=. It is known that, in loops, using ++i costs less gas per iteration than +=. This also affects incremented variables within the loop code block.
  3. Do not initialize index variables with 0, Solidity already initializes these uint variables as zero.

Check the Recommendation section for further details.

Code Location

Optimizable For Loops - LoC list

core/EntryPoint.sol:#L81
core/EntryPoint.sol:#L87
core/EntryPoint.sol:#L107
core/EntryPoint.sol:#L119
core/EntryPoint.sol:#L140
Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: The Biconomy team solved the issue by optimizing these for loops as per the suggestion above.

Commit ID: 0dc8ee23591fd54318abdd8d892ab84958847cd0

8.9 USE DECLARED FUNCTION INSTEAD OF MSG.SENDER

// Informational

Description

The BasePaymaster contract uses a modifier to control functions are called by EntryPoint address. The other onlyOwner modifier uses _msgSender() function to return msg.sender address. However, the _requireFromEntryPoint modifier uses msg.sender directly. Only one of these pattern should be used to increase readability.

Code Location

BasePaymaster.sol

function _requireFromEntryPoint() internal virtual {
        require(msg.sender == address(entryPoint)); 
    }
Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: The Biconomy team solved this finding by replacing the msg.sender to _msgSender() function.

Commit ID: f44e1592bbaa90569fd10f4418a4534fa7ff2acd

8.10 IMMUTABLE KEYWORD COSTS LESS GAS FOR CONSTANT VARIABLES

// Informational

Description

Some variables in Smart Wallet contracts are only set for once. The immutable keyword consumes less gas usage. It might be useful to declare these variables as immutable to optimize gas usage in the protocol.

Code Location

WalletFactory.sol

address internal _defaultImpl;

VerifyingPaymasterFactory.sol

address internal payMasterImp;
Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: The Biconomy team solved this finding by adding immutable keyword for constant variables.

Commit ID: d7501353283b9a98295d58e49e051f14f4b4be1b

8.11 LONG REVERT MESSAGES USE ADDITIONAL PUSH32 INSTRUCTION

// Informational

Description

Revert messages longer than 32 bytes use extra PUSH32 instruction to push strings. Since this instruction use extra gas cost, it is not recommended to use revert messages longer than 32 bytes.

Shortening revert strings to fit in 32 bytes will decrease deployment time gas and will decrease runtime gas usage when the revert condition has been met.

Code Location

Long revert messages - LoC list

SmartWallet.sol:#L72
SmartWallet.sol:#L101
SmartWallet.sol:#L119
WalletFactory.sol:#L18
SmartWalletNoAuth.sol:#L72
SmartWalletNoAuth.sol:#L101
SmartWalletNoAuth.sol:#L119
MultiSend.sol:#L27
VerifyingPaymasterFactory.sol:#L14
VerifyingPaymaster.sol:#L81
BasePaymaster.sol:#L53
WhitelistModule.sol:#L20
WhitelistModule.sol:#L26
WhitelistModule.sol:#L46
WhitelistModule.sol:#L49
EntryPoint.sol:#L256
EntryPoint.sol:#L276
EntryPoint.sol:#L277
EntryPoint.sol:#L304
Score
Impact: 1
Likelihood: 1
Recommendation

ACKNOWLEDGED: The Biconomy team acknowledged this finding.

8.12 UNUSED VARIABLES, COMMENTS AND IMPORTS

// Informational

Description

There are numerous unused variables, events, and imports on the code base. These variables should be cleaned up from the code if they have no purpose. Clearing these variables can reduce gas usage during deployment of contracts. Also, clearing unneeded comments will increase readability of the code.

Code Location

Unused comments, contracts and imports - LoC list

Greeter.sol:#L0 - Unused Contract
BaseSmartWallet.sol:#L18 - Unneeded comment line
WalletFactory.sol:#L4 - Unneeded comment line
IAggregatedWallet.sol:#L4,L6 - Unused import
BasePaymaster.sol:#L88 - Unused variables
Score
Impact: 1
Likelihood: 1
Recommendation

ACKNOWLEDGED: The Biconomy team acknowledged this finding.

8.13 OPEN TODOS

// Informational

Description

Open TODOs can hint at programming or architectural errors that still need to be fixed.

Code Location

Open TODOs - LoC list

SmartWallet.sol:#L140
SmartWalletNoAuth.sol:#L140
EntryPoint.sol:#L266
Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: The Biconomy team solved this finding by removing open TODOs from their contracts.

Commit ID: 5c22c474c90737611cd99d280eb69464cb235d2e

9. Automated Testing

STATIC ANALYSIS REPORT

Description

Halborn used automated testing techniques to enhance coverage of certain areas of the scoped contract. Slither, a Solidity static analysis framework, was used for static analysis. After Halborn verified all the contracts in the repository and was able to compile them correctly into their ABI and binary formats. This tool can statically verify mathematical relationships between Solidity variables to detect invalid or inconsistent usage of the contracts' APIs across the entire code-base.

Results

slither1.jpgslither2.jpgslither3.jpgslither4.jpg

As a result of the tests carried out with the Slither tool, some results were obtained and these results were reviewed by Halborn. Based on the results reviewed, some vulnerabilities were determined to be false positives and these results were not included in the report. The actual vulnerabilities found by Slither are already included in the report findings.

Halborn strongly recommends conducting a follow-up assessment of the project either within six months or immediately following any material changes to the codebase, whichever comes first. This approach is crucial for maintaining the project’s integrity and addressing potential vulnerabilities introduced by code modifications.