Prepared by:
HALBORN
Last Updated 08/05/2024
Date of Engagement by: April 22nd, 2024 - April 29th, 2024
100% of all REPORTED Findings have been addressed
All findings
15
Critical
0
High
0
Medium
0
Low
5
Informational
10
Tagus Labs engaged Halborn
to conduct a security assessment on their smart contracts beginning on 04-22-2024 and ending on 04-29-2024. The security assessment was scoped to the smart contracts provided in the https://github.com/inceptionlrt/bridge/tree/feat/extend-bridge-with-xerc20 GitHub repository. Commit hashes and further details can be found in the Scope section of this report.
Halborn was provided 1 week for the engagement and assigned 1 full-time security engineer to review the security of the smart contracts in scope. The engineer is a blockchain and smart contract security expert with advanced penetration testing and smart contract hacking skills, and deep knowledge of multiple blockchain protocols.
The purpose of the assessment is to:
Identify potential security issues within the smart contracts.
Ensure that smart contract functionality operates as intended.
In summary, Halborn identified some improvements to reduce the likelihood and impact of risks, which were mostly addressed by the Tagus Labs team
. The main identified issues are:
Lack of checks for deposits and withdrawals recipients may allow for tokens lost in XERC20Lockbox
contract.
Low limit amount for a bridge will result in a rate per second equal to zero.
Possible denial of service for bridges when high limits are set.
Halborn performed a combination of manual and automated security testing to balance efficiency, timeliness, practicality, and accuracy in regard to the scope of this assessment. While manual testing is recommended to uncover flaws in logic, process, and implementation; automated testing techniques help enhance coverage of the contracts' solidity code 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 assessment:
Research into architecture and purpose.
Smart contract manual code review and walk-through.
Manual assessment of use and safety for the critical Solidity variables and functions in scope to identify any arithmetic-related vulnerability classes.
Manual testing with custom scripts (Foundry
).
Static Analysis of security for scoped contract, and imported functions.
External libraries and financial-related attacks.
New features/implementations after/within the remediation commit IDs.
The following files, while they affect the in-scope code, were not specifically reviewed as part of the scope and are assumed to not contain any issues:
lib/EthereumVerifier.sol
lib/ProofParser.sol
lib/CallDataRLPReader.sol
lib/Utils.sol
EXPLOITABILIY METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Attack Origin (AO) | Arbitrary (AO:A) Specific (AO:S) | 1 0.2 |
Attack Cost (AC) | Low (AC:L) Medium (AC:M) High (AC:H) | 1 0.67 0.33 |
Attack Complexity (AX) | Low (AX:L) Medium (AX:M) High (AX:H) | 1 0.67 0.33 |
IMPACT METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Confidentiality (C) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Integrity (I) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Availability (A) | None (A:N) Low (A:L) Medium (A:M) High (A:H) Critical (A:C) | 0 0.25 0.5 0.75 1 |
Deposit (D) | None (D:N) Low (D:L) Medium (D:M) High (D:H) Critical (D:C) | 0 0.25 0.5 0.75 1 |
Yield (Y) | None (Y:N) Low (Y:L) Medium (Y:M) High (Y:H) Critical (Y:C) | 0 0.25 0.5 0.75 1 |
SEVERITY COEFFICIENT () | COEFFICIENT VALUE | NUMERICAL VALUE |
---|---|---|
Reversibility () | None (R:N) Partial (R:P) Full (R:F) | 1 0.5 0.25 |
Scope () | Changed (S:C) Unchanged (S:U) | 1.25 1 |
Severity | Score Value Range |
---|---|
Critical | 9 - 10 |
High | 7 - 8.9 |
Medium | 4.5 - 6.9 |
Low | 2 - 4.4 |
Informational | 0 - 1.9 |
Critical
0
High
0
Medium
0
Low
5
Informational
10
Security analysis | Risk level | Remediation Date |
---|---|---|
Lack of checks for deposits and withdrawals recipients may allow for tokens lost in XERC20Lockbox contract | Low | Solved - 04/29/2024 |
Low limit amount for a bridge will result in a rate per second equal to zero | Low | Solved - 04/29/2024 |
Possible denial of service for bridges when high limits are set | Low | Risk Accepted |
Centralization risks due to privileged access by owner | Low | Risk Accepted |
Potential risks in single-step transfer of ownership | Low | Risk Accepted |
Lack of check for zero address when adding destination | Informational | Solved - 04/29/2024 |
Lack of check for deposits and withdrawals with zero amount | Informational | Acknowledged |
Public functions not called within the contract can be made external | Informational | Solved - 04/29/2024 |
Use of post-increment operator | Informational | Solved - 04/29/2024 |
Unused import | Informational | Solved - 04/29/2024 |
Missing error description | Informational | Solved - 04/29/2024 |
Unlocked pragma compilers | Informational | Acknowledged |
PUSH0 is not supported by all chains | Informational | Acknowledged |
InceptionBridge contract assumes uniform decimal places across tokens | Informational | Acknowledged |
Use of full file imports | Informational | Acknowledged |
// Low
In the XERC20Lockbox
contract, the functions that allow users to make deposits and withdrawals to an arbitrary recipient do not check if the recipient is the XERC20Lockbox
contract itself. This may end up in self-transfers that result in tokens being locked in the contract.
Consider the following scenarios:
Scenario A
The function depositNativeTo()
allows users to make native deposits and transfer the corresponding tokens to an arbitrary receiver. If the _to
address is set (intentionally or accidentally) to the XERC20Lockbox
address, it allows for XERC20
tokens to be deposited to the XERC20Lockbox
contract.
function depositNativeTo(address _to) public payable {
if (!IS_NATIVE) revert IXERC20Lockbox_NotNative();
_deposit(_to, msg.value);
}
function _deposit(address _to, uint256 _amount) internal {
if (!IS_NATIVE) ERC20.safeTransferFrom(msg.sender, address(this), _amount);
XERC20.mint(_to, _amount);
emit Deposit(_to, _amount);
}
Scenario B
Similarly, the function depositTo
allows for users to make token deposits to an arbitrary recipient. In this case, the result will be the same as the previous scenario, with additional ERC20
tokens being deposited into the contract.
function depositTo(address _to, uint256 _amount) external {
if (IS_NATIVE) revert IXERC20Lockbox_Native();
_deposit(_to, _amount);
}
Scenario C
Similarly, the withdrawTo
function may result in the same outcome with additional steps, leaving XERC20
tokens deposited to the contract.
function _withdraw(address _to, uint256 _amount) internal {
XERC20.burn(msg.sender, _amount);
if (IS_NATIVE) {
(bool _success, ) = payable(_to).call{value: _amount}("");
if (!_success) revert IXERC20Lockbox_WithdrawFailed();
} else {
ERC20.safeTransfer(_to, _amount);
}
emit Withdraw(_to, _amount);
}
If the _to
address is set to the XERC20Lockbox
address and the IS_NATIVE
condition is met, execution will reach the code to transfer native assets:
(bool _success, ) = payable(_to).call{value: _amount}("");
if (!_success) revert IXERC20Lockbox_WithdrawFailed();
This code will effectively make a native transfer to the lockbox contract, which in turn will activate the execution of the local receive()
function, executing depositNative()
. This will make deposits on behalf of the XERC20Lockbox
(being its address the msg.sender
) and contract and XERC20
tokens will be minted to it.
receive() external payable {
depositNative();
}
Given these scenarios, the XERC20
tokens will remain locked in the contract as there is no functionality in XERC20Lockbox
to retrieve them.
Scenario A:
function test_depositNativeToLockbox(address receiver, uint256 amount) public {
vm.assume(receiver != address(0));
vm.assume(amount != 0);
test_deployLockboxNative();
receiver = address(lockbox);
vm.deal(depositor, amount);
vm.startPrank(depositor);
uint256 balanceBefore = xerc20.balanceOf(receiver);
lockbox.depositNativeTo{value: amount}(receiver);
uint256 balanceAfter = xerc20.balanceOf(receiver);
assertEq(balanceBefore + amount, balanceAfter);
vm.stopPrank();
}
Scenario B:
function test_depositTokenToLockbox(address receiver, uint256 amount) public {
vm.assume(receiver != address(0));
vm.assume(amount != 0);
test_deployLockboxNotNative();
receiver = address(lockbox);
deal(address(mockToken), depositor, amount);
vm.startPrank(depositor);
mockToken.approve(address(lockbox), amount);
uint256 balanceBefore = mockToken.balanceOf(receiver);
lockbox.depositTo(receiver, amount);
uint256 balanceAfter = mockToken.balanceOf(receiver);
assertEq(balanceBefore + amount, balanceAfter);
vm.stopPrank();
}
function test_withdrawToLockboxNative(uint256 withdrawAmount) public {
test_deployLockboxNative();
withdrawAmount = bound(withdrawAmount, 1, type(uint).max);
address receiver = address(lockbox); //set lockbox as receiver
vm.deal(receiver, withdrawAmount);
vm.prank(address(lockbox));
xerc20.mint(depositor, withdrawAmount);
uint256 balanceXerc20Before = xerc20.balanceOf(receiver);
vm.startPrank(depositor);
lockbox.withdrawTo(receiver, withdrawAmount);
uint256 balanceXerc20After = xerc20.balanceOf(receiver);
assertEq(balanceXerc20Before + withdrawAmount, balanceXerc20After);
}
Consider adding checks on the aforementioned functions to ensure that in no case the recipient of the transfers is the XERC20Lockbox
contract itself.
SOLVED: The Tagus Labs team has addressed the finding in commit a98d3e2
by following the mentioned recommendation of adding a check to verify that the receiver is not the XERC20Lockbox
contract itself.
// Low
In the changeBurnerLimit
and changeMintingLimit
functions, which are executed via setBridgeLimits()
, any limit set below 86400
(equal to 1 days
in Solidity) will result in a zero rate per second, affecting the possibility of dynamic limit within the DURATION
threshold. This may occur because the rate per second is calculated as following in the aforementioned functions:
bridges[_bridge].minterParams.ratePerSecond = _limit / _DURATION;
bridges[_bridge].burnerParams.ratePerSecond = _limit / _DURATION;
where uint256 private constant _DURATION = 1 days
.
This may result in a static limit for the specified bridge, as with a rate per second with 0
value will not change the _calculatedLimit
value in the following code execution whenever the _getCurrentLimit()
function is called:
uint256 _timePassed = block.timestamp - _timestamp;
uint256 _calculatedLimit = _limit + (_timePassed * _ratePerSecond);
_limit = _calculatedLimit > _maxLimit ? _maxLimit : _calculatedLimit;
It is recommended to set a minimum threshold when invoking setBridgeLimits()
or, alternatively, incorporate a validation step to confirm that ratePerSecond
is not set to zero.
SOLVED: The Tagus Labs team has addressed the finding in commit a98d3e2
by following the mentioned recommendation of adding a check for a minimum threshold.
// Low
When using Solidity versions 0.8.0
and above, the compiler natively handles overflows and underflows by reverting the transaction if these issues occur. In the XERC20
contract, specifically in the _getCurrentLimit()
function, there's a potential for an unhandled overflow, which could cause the transaction to revert when calculating the current limit:
uint256 _calculatedLimit = _limit + (_timePassed * _ratePerSecond);
If the multiplication operation between _timePassed
and _ratePerSecond
results in a value greater than type(uint256).max
, the transaction will revert. Similarly, if the result of the addition _limit
and (_timePassed * _ratePerSecond)
is greater than type(uint256).max
, the transaction will also revert, effectively denying service for the bridge attempting to mint or burn XERC20
tokens under these circumstances.
For example, even though the limit set is very high, a bridge is not able to mint 2 tokens due to the overflow occuring when calculating the new limit.
function test_mintVeryHighLimitDOS(address _bridge, uint256 _burningLimit, uint256 amount) public {
vm.assume(_burningLimit != 0);
vm.assume(_bridge != address(0));
amount = bound(amount, 1, type(uint256).max);
test_setBridgeLimits(_bridge, type(uint256).max, _burningLimit);
vm.startPrank(_bridge);
uint256 currentLimitBefore = xerc20.mintingCurrentLimitOf(_bridge);
uint256 maxLimitBefore = xerc20.mintingMaxLimitOf(_bridge);
xerc20.mint(_bridge, 1);
assertEq(xerc20.balanceOf(_bridge), 1);
skip(1);
vm.expectRevert();
xerc20.mint(_bridge, 1);
}
It is recommended to add a threshold in the setBridgeLimits()
function to ensure that no calculated limit will exceed the maximum value of a uint256
variable, allowing bridges to mint and burn when needed.
RISK ACCEPTED: The Tagus Labs team made a business decision to accept the risk of this finding and not alter the contracts, stating:
This bug is next to impossible due to the range of values we’re working with in this case. In particular, we’re dealing with timestamps vs. uint256. It turns into impossible time limits.
// Low
Several contracts in scope have owners with privileged rights to perform administrative tasks and need to be trusted to not perform malicious updates.
The administrative functions enable the owner
to add and remove bridges, adjust their limits, set caps and their durations, designate token destinations, configure contracts, and pause or unpause the bridge.
It is recommended to implement a role-based access control mechanism to allow multiple entities to perform admin tasks and limit powers within the system to users according to their roles, effectively reducing centralization risks.
RISK ACCEPTED: The Tagus Labs team made a business decision to accept the risk of this finding and not alter the contracts, stating:
We’re aware of that, and therefore we use a 3-5 multisig address as the owner.
// Low
The current implementation of the InceptionBridge
contract employs a single-step ownership transfer mechanism, transferring ownership directly to another account in one transaction. This is achieved through the inheritance of OpenZeppelin's OwnableUpgradeable
contract. Similarly, the XERC20
contract inherits from OpenZeppelin's Ownable
contract.
This approach taken for both contracts mentioned does not align with best practices regarding security measures, as the address to which the ownership is transferred should be verified to be active or be willing to act as the owner.
It is recommended to use OpenZeppelin's Ownable2Step
or Ownable2StepUpgradeble
contracts over the Ownable
and OwnableUpgradeable
currently in use. Alternatively, consider implementing similar two-step ownership transfer logic into the contract.
RISK ACCEPTED: The Tagus Labs team made a business decision to accept the risk of this finding and not alter the contracts.
// Informational
The _addDestination()
function from the InceptionBridge
contract has a check to verify that the _bridgeAddressByChainId
mapping is properly set up and is not equivalent to address(0
. However, it does not perform the same verification for the fromToken
or the toToken
input values.
This may incur in possible denial of service for addresses attempting to execute deposit()
or withdraw()
.
Add a check to ensure that neither the fromToken
and toToken
addresses are address(0)
.
SOLVED: The Tagus Labs team has addressed the finding in commit a98d3e2
by following the mentioned recommendation.
// Informational
Throughout the codebase, there are multiple instances where the deposit
and withdraw
functions do not check if the amount to deposit or withdraw is zero. This may result in unnecessary gas consumption or unexpected behavior.
The affected functions are:
- deposit
and withdraw
functions in the InceptionBridge
contract.
- deposit
, depositTo
, depositNative
, depositNativeTo
, withdraw
and withdrawTo
functions in the XERC20Lockbox
contract.
Add checks in the aforementioned functions to ensure that the amount to deposit or withdraw is greater than zero.
ACKNOWLEDGED: The Tagus Labs team made a business decision to acknowledge this finding and not alter the contracts.
// Informational
Throughout the codebase, there are multiple functions that are marked as public
but are not used internally in the contract they are declared. This may result in unnecessary gas consumption.
The affected functions are:
- mint
, burn
, setLockbox
, mintingMaxLimitOf
, burningMaxLimitOf
in the XERC20
contract.
- depositNativeTo
in the XERC20Lockbox
contract.
- getDeploymentCreate2Address
in the BridgeFactory
contract.
Modify the aforementioned functions with the external
visibility modifier.
SOLVED: The Tagus Labs team has addressed the finding in commit a98d3e2
by following the mentioned recommendation.
// Informational
In the deposit
function of the InceptionBridge
contract, the post-increment operator is used to increment the _globalNonce
variable:
_globalNonce++;
This is not the most gas efficient approach to incrementing a variable.
Replace the post-increment operator with the pre-increment operator to reduce gas costs. Additionally, the piece of code can be executed inside an unchecked
block, since realistically it is not possible for the _globalNonce
to overflow in practice:
unchecked{
++_globalNonce;
}
SOLVED: The Tagus Labs team has addressed the finding in commit a98d3e2
by following the mentioned recommendation.
// Informational
In the InceptionBridge.sol
file, OpenZeppelin's IERC20Metada.sol
is imported but not used in the contract.
Remove the unused import statement.
SOLVED: The Tagus Labs team has addressed the finding in commit a98d3e2
by removing the unused import file.
// Informational
The InitializableTransparentUpgradeableProxy
contract contains require
statements that are missing error descriptions, particularly in the initialize()
function:
require(_implementation() == address(0));
and the _requireZeroValue()
function:
require(msg.value == 0);
Use descriptive reason strings. Alternatively, it is recommended the use of custom errors to follow the project's error standardization, best practices and to improve gas efficiency.
SOLVED: The Tagus Labs team has addressed the finding in commit a98d3e2
by using descriptive reason strings.
// Informational
To ensure stability, it's important that contracts are deployed with the same compiler version and flags used during development and testing. Locking the pragma helps to ensure that contracts do not accidentally get deployed using another pragma. For example, an outdated pragma version might introduce bugs that affect the contract system negatively.
Lock the pragma version to the same version used during development and testing.
ACKNOWLEDGED: The Tagus Labs team made a business decision to acknowledge this finding and not alter the contracts.
// Informational
The compiler for Solidity 0.8.20 switches the default target EVM version to Shanghai, which means that the generated bytecode will include PUSH0 opcodes. Be sure to select the appropriate EVM version in case you intend to deploy on a chain apart from mainnet like L2 chains that may not support PUSH0, otherwise deployment of your contracts will fail.
Make sure to specify the target EVM version when using Solidity 0.8.20 and above, especially if deploying to L2 chains that may not support the PUSH0
opcode. Stay informed about the opcode support of different chains to ensure smooth deployment and compatibility.
ACKNOWLEDGED: The Tagus Labs team made a business decision to acknowledge this finding and not alter the contracts.
// Informational
The bridge contract operates under the assumption that all bridgeable tokens across supported chains have uniform decimal places. When tokens are deposited, the system records the quantity of tokens. Conversely, during withdrawals, it is assumed that the exact figure is used to mint tokens for users. If the referenced tokens have different decimal scaling, it can lead to discrepancies between these values.
On the other hand, it is understood that since the bridgeable tokens are managed internally, it is expected that they are all issued with the same number of decimal places, which would make it unlikely to impact the current codebase.
It is recommended to document this assumption clearly in the system specifications to mitigate any potential errors eventually arising from decimal inconsistencies in future upgrades.
ACKNOWLEDGED: The Tagus Labs team made a business decision to acknowledge this finding and not alter the contracts.
// Informational
Throughout the codebase, there are multiple instances where full file imports are used. While this does not affect functionality, it is not considered a best practice.
Use named import syntax instead of importing full files. This restricts what is being imported to just the named items, not everything in the file, for example:
import {MyContract} from "src/MyContract.sol"
ACKNOWLEDGED: The Tagus Labs team made a business decision to acknowledge this finding and not alter the contracts.
Halborn used automated testing techniques to enhance the coverage of certain areas of the smart contracts in scope. Among the tools used was Slither, a Solidity static analysis framework. After Halborn verified the smart contracts in the repository and was able to compile them correctly into their abis and binary format, Slither was run against the contracts. 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.
The security team assessed all findings identified by the Slither software, however, findings with related to external dependencies are not included in the below results for the sake of report readability.
The findings obtained as a result of the Slither scan were reviewed, and not included in the report because they were determined as false positives.
The original repository used the Hardhat environment to develop and test the smart contracts. All tests were executed successfully. Additionally, the project in scope was cloned to a Foundry
environment, to allow for additional testing and fuzz testing that covered ~50,000 runs per test. These additional tests were run successfully.
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