Prepared by:
HALBORN
Last Updated 04/25/2024
Date of Engagement by: October 1st, 2022 - October 17th, 2022
100% of all REPORTED Findings have been addressed
All findings
13
Critical
0
High
1
Medium
0
Low
5
Informational
7
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.
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.
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
)
\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}
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 analysis | Risk level | Remediation Date |
---|---|---|
VULNERABLE ECDSA LIBRARY | High | Solved - 10/28/2022 |
MISSING REENTRANCY GUARD | Low | Partially Solved |
POSSIBLE INTEGER OVERFLOWS DUE TO IMPROPER USE OF UNCHECKED KEYWORD | Low | Solved - 10/28/2022 |
SINGLE-STEP OWNERSHIP CHANGE | Low | Risk Accepted |
MISSING ZERO ADDRESS CHECKS | Low | Solved - 10/28/2022 |
IGNORED RETURN VALUES | Low | Solved - 11/01/2022 |
FLOATING PRAGMA | Informational | Solved - 10/28/2022 |
FOR LOOPS CAN BE OPTIMIZED | Informational | Solved - 11/01/2022 |
USE DECLARED FUNCTION INSTEAD OF MSG.SENDER | Informational | Solved - 11/02/2022 |
IMMUTABLE KEYWORD COSTS LESS GAS FOR CONSTANT VARIABLES | Informational | Solved - 10/28/2022 |
LONG REVERT MESSAGES USE ADDITIONAL PUSH32 INSTRUCTION | Informational | Acknowledged |
UNUSED VARIABLES, COMMENTS AND IMPORTS | Informational | Acknowledged |
OPEN TODOS | Informational | Solved - 10/28/2022 |
// High
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.
"@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.
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)
}
SOLVED: The Biconomy team
solved this finding by upgrading the @openzeppelin/contracts
package version to 4.7.3
.
Commit ID:
b813a60a8475672c2a2d00b7ef5d1839461eaa3e
// Low
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.
SmartWallet:handlePayment()
SmartWallet:transfer()
SmartWalletNoAuth:handlePayment()
SmartWalletNoAuth:transfer()
EntryPoint:innerHandleOp()
StakeManager:withdrawStake()
StakeManager:withdrawTo()
SimpleWallet:addDeposit()
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
// Low
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.
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);
}
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");
}
}
}
}
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
// Low
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.
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);
}
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);
}
function _transferOwnership(address newOwner) internal virtual {
address oldOwner = owner;
owner = newOwner;
emit OwnershipTransferred(oldOwner, newOwner);
}
RISK ACCEPTED: The Biconomy team
accepted the risk of this finding. It has been decided to continue with the one-step ownership change pattern.
// Low
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.
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
SOLVED: The Biconomy team
solved this finding by adding require
checks to prevent the use of zero addresses.
Commit ID:
10d3933d5b9a461b85aebb9343e92ed94ac74970
// Low
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.
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()));
}
SOLVED: The Biconomy team
solved this finding by removing the return variable from the requiredTxGas
function.
Commit ID:
508c44ed749f7c4b06e704d6f6b4e13db9099634
// Informational
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.
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
// Informational
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:
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.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.index
variables with 0
, Solidity already initializes these uint
variables as zero.Check the Recommendation
section for further details.
core/EntryPoint.sol:#L81
core/EntryPoint.sol:#L87
core/EntryPoint.sol:#L107
core/EntryPoint.sol:#L119
core/EntryPoint.sol:#L140
SOLVED: The Biconomy team
solved the issue by optimizing these for loops as per the suggestion above.
Commit ID:
0dc8ee23591fd54318abdd8d892ab84958847cd0
// Informational
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.
function _requireFromEntryPoint() internal virtual {
require(msg.sender == address(entryPoint));
}
SOLVED: The Biconomy team
solved this finding by replacing the msg.sender
to _msgSender()
function.
Commit ID:
f44e1592bbaa90569fd10f4418a4534fa7ff2acd
// Informational
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.
address internal _defaultImpl;
address internal payMasterImp;
SOLVED: The Biconomy team
solved this finding by adding immutable
keyword for constant variables.
Commit ID:
d7501353283b9a98295d58e49e051f14f4b4be1b
// Informational
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.
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
ACKNOWLEDGED: The Biconomy team
acknowledged this finding.
// Informational
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.
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
ACKNOWLEDGED: The Biconomy team
acknowledged this finding.
// Informational
Open TODOs can hint at programming or architectural errors that still need to be fixed.
SmartWallet.sol:#L140
SmartWalletNoAuth.sol:#L140
EntryPoint.sol:#L266
SOLVED: The Biconomy team
solved this finding by removing open TODOs from their contracts.
Commit ID:
5c22c474c90737611cd99d280eb69464cb235d2e
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.
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.
// Download the full report
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed