Security Audit
August 14, 2024
Version 1.0.0
Presented by 0xMacro
This document includes the results of the security audit for Infinex's smart contract code as found in the section titled ‘Source Code’. The security audit was performed by the Macro security team from July 12 to July 15, and July 26 to July 29 2024.
The purpose of this audit is to review the source code of certain Infinex Solidity contracts, and provide feedback on the design, architecture, and quality of the source code with an emphasis on validating the correctness and security of the software in its entirety.
Disclaimer: While Macro’s review is comprehensive and has surfaced some changes that should be made to the source code, this audit should not solely be relied upon for security, as no single audit is guaranteed to catch all possible bugs.
The following is an aggregation of issues found by the Macro Audit team:
Severity | Count | Acknowledged | Won't Do | Addressed |
---|---|---|---|---|
Medium | 3 | 2 | - | 1 |
Low | 4 | - | 1 | 3 |
Code Quality | 3 | - | 1 | 2 |
Infinex was quick to respond to these issues.
Our understanding of the specification was based on the following sources:
The changes audited rely on the following assumptions specifically.
AppRegistry
will be maintained with the correct and latest App Beacons of each App.The following source code was reviewed during the audit:
bf5b1fbade08e501d6604b94249318bf815e71d9
950f3568622ba185aa01ae1a65f75804071a2b47
ecaeef63f11cdf5a3639837eefd8abef223b5b73
Specifically, we audited the following contracts for
CCQ Sync Recovery Address within commit bf5b1fbade08e501d6604b94249318bf815e71d9
:
Source Code | SHA256 |
---|---|
./accounts/modules/AccountUtilsModule.sol |
|
./accounts/modules/RecoveryModule.sol |
|
./accounts/utils/SecurityModifiers.sol |
|
./storage/Recovery.sol |
|
./beacons/InfinexProtocolConfigBeacon.sol |
|
./libraries/Error.sol |
|
./libraries/WormholeQueryResponse.sol |
|
Additional Account Changes within commit 950f3568622ba185aa01ae1a65f75804071a2b47
:
Source Code | SHA256 |
---|---|
./accounts/modules/AppModule.sol |
|
./apps/AppRegistry.sol |
|
./apps/base/AppAccountBase.sol |
|
./apps/base/AppBeaconBase.sol |
|
./accounts/modules/BridgingModule.sol |
|
./accounts/modules/RecoveryModule.sol |
|
./beacons/InfinexProtocolConfigBeacon.sol |
|
./integrations/BridgeIntegrations.sol |
|
./libraries/Error.sol |
|
Note: Currently the referenced repository is private, but there are plans to make it public in the future.
Click on an issue to jump to it, or scroll down to see them all.
lastImplementation
is not immutable
ethQueryResponse
result
calldata
length validation
predictAppAccountAddress()
can return available for deprecated accounts with valid beacon
requestFinality
We quantify issues in three parts:
This third part – the severity level – is a summary of how much consideration the client should give to fixing the issue. We assign severity according to the table of guidelines below:
Severity | Description |
---|---|
(C-x) Critical |
We recommend the client must fix the issue, no matter what, because not fixing would mean significant funds/assets WILL be lost. |
(H-x) High |
We recommend the client must address the issue, no matter what, because not fixing would be very bad, or some funds/assets will be lost, or the code’s behavior is against the provided spec. |
(M-x) Medium |
We recommend the client to seriously consider fixing the issue, as the implications of not fixing the issue are severe enough to impact the project significantly, albiet not in an existential manner. |
(L-x) Low |
The risk is small, unlikely, or may not relevant to the project in a meaningful way. Whether or not the project wants to develop a fix is up to the goals and needs of the project. |
(Q-x) Code Quality |
The issue identified does not pose any obvious risk, but fixing could improve overall code quality, on-chain composability, developer ergonomics, or even certain aspects of protocol design. |
(I-x) Informational |
Warnings and things to keep in mind when operating the protocol. No immediate action required. |
(G-x) Gas Optimizations |
The presented optimization suggestion would save an amount of gas significant enough, in our opinion, to be worth the development cost of implementing it. |
In AppModule
, deployAppAccounts
is called to deploy a new app account. Inside the function, isValidAppBeacon
is called to check if the provided appBeacon
address is registered and valid. However, the function isValidAppBeacon
was recently changed and doesn’t check against the latestAppBeacon
anymore.
function isValidAppBeacon(address _appBeacon) external view returns (bool) {
- return appBeacons[_appBeacon] && IAppBeaconBase(_appBeacon).getLatestAppBeacon() == _appBeacon;
+ return appBeacons[_appBeacon]
}
Reference: AppRegistry.sol#L48-L50
This allows for deploying app accounts with an outdated appBeacon
as long as the appBeacon
is still registered in the AppRegistry.
Remediation to Consider
Preventing app accounts deployment with an outdated appBeacon
by undoing the above change.
We have moved to a posture where an appBeacon is valid only if it is in the AppRegistry. This makes it easy to reason about whether an AppBeacon is valid or not. In addition, this makes it so that we can execute all the deployment steps for adding a new AppBeacon version without breaking any of the platform functionality.
With the latest changes on Infinex contracts, PR #169 introduces some changes in the InfinexProtocolConfigBeacon
including breaking changes such as renaming getters and adding configuration variables and constants for bridging directly using CCTP and syncing the funds recovery address cross-chain, as mentioned in the PR’s description:
It has some breaking changes on the InfinexBeacon but that's fine because it'll only be used by new accounts (since we are only upgrading the beacon when we upgrade the account version), and I've confirmed with the platform that they aren't reading any of the functions that are getting changed.
It is not an issue if the new beacon is only used by new accounts. However, this is not enforced and the following misconfigurations can occur that will break functionality:
BridgingModule:bridgeUSDCWithWormholeEVM()
as the isSupportedWormholeChainId()
was renamed to isSupportedEVMWormholeChainId()
.BridgingModule:bridgeUSDCWithCCTPSolana()
as the getSolanaCCTPDestinationDomain()
is no longer in the contract.RecoveryModule:recoverUSDCToEVMChain()
will revert as the getMinimumUSDCBridgeAmount()
is no longer in the contract, as the new account implementation uses solanaCCTPDestinationDomain
public variable getter.BridgingModule:bridgeUSDCWithCCTPEVM()
will revert as CCTP bridging was added in the latest beacon version.RecoveryModule.syncFundsRecoveryAddressWithCCQ()
will revert as the public constant WORMHOLE_CORE
was added in the latest beacon version.Essentially rendering some module’s functions non-usable and forcing the account to be upgraded to a compatible version.
Remediations to Consider:
The following remediations can be made to prevent the above scenarios correspondingly:
Yes that is what we're currently doing. Our upgrade process involves doing these steps atomically: Upgrade to new beacon, upgrade account version, reinitialize legacy account if necessary.
lastImplementation
is not immutable
With the latest App beacon release, specifically In PR165, the app address implementation of each beacon was intended to be set as an immutable parameter. This was done by removing the address setter setLatestAppImplementation()
.
- /**
- * @notice Sets the latest app implementation address.
- * @param _latestAppImplementation The address of the latest implementation for the app.
- */
- function setLatestAppImplementation(address _latestAppImplementation) external onlyOwner {
- if (_latestAppImplementation == address(0)) revert ZeroAddress();
- emit LatestAppImplementationSet(_latestAppImplementation);
- appBeaconConfig.latestAppImplementation = _latestAppImplementation;
- }
This would guarantee the App implementation address’s compatibility with a given App Beacon.
However, since the appBeaconConfig
is a storage variable. App Beacon contracts inherent in the AppBeaconBase
contract could implement a setter and change the implementation address.
abstract contract AppBeaconBase is IAppBeaconBase, ERC165, Ownable2Step {
AppBeaconConfig public appBeaconConfig;
Reference: AppBeaconBase.sol#L28-29
struct AppBeaconConfig {
string appName;
address latestAppImplementation;
address latestAppBeacon;
}
Reference: IAppBeaconBase.sol#L19-23
Since the App deployment and predict address results rely only on the _appBeacon
address input but utilize the latestAppImplementation
, not making it explicitly immutable would restrain the user’s control over the deployed apps.
Remediations to Consider:
Consider declaring the latestAppImplementation
as an immutable address variable.
ethQueryResponse
result
The syncFundsRecoveryAddressWithCCQ()
function decodes the ethQueryResponse
result as an address after verifying only one result exists and ensuring the expected signature selector and contract address match the request's data, along with some other validations.
if (ethQueryResponse.result.length != 1) {
revert Error.UnexpectedResultLength();
}
WormholeQueryResponse.validateBlockNum(ethQueryResponse.blockNum, Recovery._getLastRecoveryAddressSetBlock());
WormholeQueryResponse.validateBlockTime(ethQueryResponse.blockTime, block.timestamp - 12 hours);
if (ethQueryResponse.requestFinality.length != 9
|| keccak256(ethQueryResponse.requestFinality) != keccak256("finalized")) {
revert Error.InvalidBlockFinality();
}
address[] memory expectedContractAddresses = new address[](1);
expectedContractAddresses[0] = address(this);
bytes4[] memory expectedFunctionSignatures = new bytes4[](1);
expectedFunctionSignatures[0] = IRecoveryModule.getFundsRecoveryAddress.selector;
WormholeQueryResponse.validateEthCallData(ethQueryResponse.result[0], expectedContractAddresses, expectedFunctionSignatures);
(address fundsRecoveryAddress) = abi.decode(ethQueryResponse.result[0].result, (address));
Reference: RecoveryModule.sol#L101-115
However, the result[0]
length is not being validated. Although the current getFundsRecoveryAddress()
implementation returns a unique address. If Base’s account changes the function structure and returns multiple values, the abi.decode()
method will decode and parse the first returned address, potentially allowing the function returned values to be changed without reverting.
Remediations to Consider:
Consider ensuring the result[0].length == 32
.
This has been done intentionally to preserve the functionality of syncing the funds recovery address even if multiple exist. Since we might enable setting multiple funds recovery addresses in the future.
calldata
length validation
To validate that the proper function call data was used in the cross-chain query, the syncFundsRecoveryAddressWithCCQ()
uses the WormholeQueryResponse.validateEthCallData()
library method (from Wormhole SDK QueryResponse.sol contract) to check that:
getFundsRecoveryAddress.selector
.address[] memory expectedContractAddresses = new address[](1);
expectedContractAddresses[0] = address(this);
bytes4[] memory expectedFunctionSignatures = new bytes4[](1);
expectedFunctionSignatures[0] = IRecoveryModule.getFundsRecoveryAddress.selector;
WormholeQueryResponse.validateEthCallData(ethQueryResponse.result[0], expectedContractAddresses, expectedFunctionSignatures);
Reference: RecoveryModule.sol#L109-113
However, the EthCallData
result’s calldata length is not verified to match the payload for the getFundsRecoveryAddress
call. Although unlikely, a function signature clash could cause a different function to render the same bytes4
signature.
Remediations to Consider:
Consider asserting the calldata
in the EthCallData.result
is only 4 bytes.
Note: This will not completely mitigate function signature collision, as the hashed function name alone can generate a signature clash. However, it will add an additional security layer to ensure transparency with the current Account code version and specification.
The Recovery
module data layout now includes a new storage parameter, which saves the block number on which the fundsRecoveryAddress
was set.
struct Data {
address fundsRecoveryAddress;
uint256 lastRecoveryAddressSetBlock;
}
Reference: Recovery.sol#L8-11
This value is later used in the RecoveryModule
to verify the ethQueryResponse.blockNum
is higher than the set number.
WormholeQueryResponse.validateBlockNum(ethQueryResponse.blockNum, Recovery._getLastRecoveryAddressSetBlock());
Reference: RecoveryModule.sol#L104
However, this value can be set using two different entry points: one using block.number
and the other using the responses ethQueryResponse.blockNum
. Since the block.number
parameter varies from chain to chain, and specifically, the Base’s block number is numerically lower than most of the other supported chains. Comparing this value as a minimum bound will not give any assurance on the contract’s storage state.
Remediations to Consider:
Using the block.number
in syncFundsRecoveryAddressWithCCQ()
setter function too.
function syncFundsRecoveryAddressWithCCQ(bytes memory _response, IWormhole.Signature[] memory _signatures)
external
requiresTrustedRecoveryKeeper
nonReentrant
{
...
WormholeQueryResponse.validateBlockNum(block.number, Recovery._getLastRecoveryAddressSetBlock());
...
_setFundsRecoveryAddress(fundsRecoveryAddress, block.number);
}
Implementing checks to verify whether this value was set using the ethQueryResponse.blockNum
or block.number
.
predictAppAccountAddress()
can return available for deprecated accounts with valid beacon
App account deployment now includes an arbitrary input salt
parameter which can also be used to predict a deterministic address where that app account will be deployed with create2
.
function predictAppAccountAddress(address _appBeacon, bytes32 _salt) external view returns (address newAppAccount, bool isAvailable) {
IInfinexProtocolConfigBeacon protocolConfigBeacon = Account._infinexProtocolConfig();
IAppRegistry appRegistry = IAppRegistry(protocolConfigBeacon.appRegistry());
if (_appBeacon == address(0)) revert Error.NullAddress();
if (!appRegistry.isValidAppBeacon(_appBeacon)) revert Error.InvalidAppBeacon();
address latestAppImplementation = IAppBeaconBase(_appBeacon).getLatestAppImplementation();
newAppAccount = Create2.computeAddress(_salt, keccak256(_getProxyBytecode(latestAppImplementation)));
isAvailable = App._getAppBeacon(newAppAccount) == address(0);
}
Reference: AppModule.sol#L63-72
However, if an account with a valid beacon was deployed and later deprecated, the predictAppAccountAddress()
will incorrectly return the predicted app’s address as available if the beacon is still valid. This could mistakenly be used to call the deployAppAccount()
, expecting it to deploy an account while reverting in the create2
call as this address will contain code.
Remediations to Consider:
Consider checking if the predicted address has code and returning false inisAvailable
.
Consider using a constant or variable for the timestamp
validation block time in syncFundsRecoveryAddressWithCCQ()
.
WormholeQueryResponse.validateBlockTime(ethQueryResponse.blockTime, block.timestamp - 12 hours);
Reference: RecoveryModule.sol#L105
requestFinality
In the function syncFundsRecoveryAddressWithCCQ()
, we have this check to make sure ethQueryResponce.requestFinality
is equal to finalized
:
if (ethQueryResponse.requestFinality.length != 9
|| keccak256(ethQueryResponse.requestFinality) != keccak256("finalized")) {
revert Error.InvalidBlockFinality();
}
Reference: RecoveryModule.sol#L106
The ethQueryResponse.requestFinality.length != 9
check is unnecessary here. Even if the caller (TrustedRecoveryKeeper
) is manipulated and brute-forcing successfully with a requestFinality
that its hash is still equal to keccak256("finalized")
, the requestFinality
is still included previously in the digest hash to verify the wormhole’s signature in WormholeQueryReponse.verifyQueryResponseSignatures()
.
Remediations to Consider
Consider removing this check.
- if (ethQueryResponse.requestFinality.length != 9 || keccak256(ethQueryResponse.requestFinality) != keccak256("finalized")) {
+ if (keccak256(ethQueryResponse.requestFinality) != keccak256("finalized")) {
We want to keep the standard string comparison check even if the constraints of this function mean they're not needed.
In RecoveryModule
, functions syncFundsRecoveryAddressWithCCQ()
and bridgeUSDCWithWormholeForRecovery()
use the default destination Wormhole chain ID to verify funds recovery address sync and bridge USDC to the main account correspondingly. However, bridgeUSDCWithWormholeForRecovery()
function fetches this value through the Infinex Protocol Beacon. This differs from the syncFundsRecoveryAddressWithCCQ()
function, which uses the stored defaultDestinationWormholeChainId
in the Bridge storage layout.
Consider using a consistent chain ID retrieval for both functions.
Macro makes no warranties, either express, implied, statutory, or otherwise, with respect to the services or deliverables provided in this report, and Macro specifically disclaims all implied warranties of merchantability, fitness for a particular purpose, noninfringement and those arising from a course of dealing, usage or trade with respect thereto, and all such warranties are hereby excluded to the fullest extent permitted by law.
Macro will not be liable for any lost profits, business, contracts, revenue, goodwill, production, anticipated savings, loss of data, or costs of procurement of substitute goods or services or for any claim or demand by any other party. In no event will Macro be liable for consequential, incidental, special, indirect, or exemplary damages arising out of this agreement or any work statement, however caused and (to the fullest extent permitted by law) under any theory of liability (including negligence), even if Macro has been advised of the possibility of such damages.
The scope of this report and review is limited to a review of only the code presented by the Infinex team and only the source code Macro notes as being within the scope of Macro’s review within this report. This report does not include an audit of the deployment scripts used to deploy the Solidity contracts in the repository corresponding to this audit. Specifically, for the avoidance of doubt, this report does not constitute investment advice, is not intended to be relied upon as investment advice, is not an endorsement of this project or team, and it is not a guarantee as to the absolute security of the project. In this report you may through hypertext or other computer links, gain access to websites operated by persons other than Macro. Such hyperlinks are provided for your reference and convenience only, and are the exclusive responsibility of such websites’ owners. You agree that Macro is not responsible for the content or operation of such websites, and that Macro shall have no liability to your or any other person or entity for the use of third party websites. Macro assumes no responsibility for the use of third party software and shall have no liability whatsoever to any person or entity for the accuracy or completeness of any outcome generated by such software.