Prepared by:
HALBORN
Last Updated 04/26/2024
Date of Engagement by: November 15th, 2022 - December 1st, 2022
93% of all REPORTED Findings have been addressed
All findings
14
Critical
0
High
0
Medium
2
Low
3
Informational
9
NFTfi engaged Halborn to conduct a security audit on their smart contracts beginning on 2022-11-15 and ending on 2022-12-01. The security assessment was scoped to the smart contracts provided to the Halborn team.
The team at Halborn was provided two 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 NFTfi team. The critical security finding was identified and fixed by the NFTfi team, and Halborn verified the fix.
Halborn performed a combination of manual and automated security testing to balance efficiency, timeliness, practicality, and accuracy in regard to the scope of this audit. 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 audit:
Research into architecture and purpose.
Smart contract manual code review and walkthrough.
Manual assessment of use and safety for the critical Solidity variables and functions in scope to identify any arithmetic related vulnerability classes.
Manual testing by custom scripts. (Brownie
).
Static Analysis of security for scoped contract, and imported functions manually.
Testnet deployment (Ganache
).
Repository
: eth.immutable-bundles
The security assessment was scoped to every smart contract in the audit-09-11-2022 branch.
Initial Commit ID: 2c7ef51f7f820c65d93f57490c77bf67a4578773
Changes to Initial Commit ID:
compare/audit-09-11-2022...3140329bec42b848dc008fd037aba1fec4a77ef5 - Added Pausing capabilities to ImmutableBundle.sol
- Fixed wrong event parameter in NftfiBundler.sol line 215 - Changed variable names on NftfiBundler.sol
- Added URI metadata to NftfiBundler.sol
, PersonalBundler.sol
, and ImmutableBundle.sol
- Added token name and symbol for PersonalBundler.sol
- Added _setOwner()
call in PersonalBundler.sol
's initialize()
function
Remediations Commit ID: 29be173454e816c58d98dea2614750dbc27bc39a
Remediations Commit ID 2: dc376d9ba96db9f9ac3718599f05b5bf3f8f7c61
Retest Commit ID: b39030551b8492b78f3fa1827ad840fcd01daace
Critical
0
High
0
Medium
2
Low
3
Informational
9
Impact x Likelihood
HAL-03
HAL-02
HAL-07
HAL-09
HAL-04
HAL-05
HAL-06
HAL-01
HAL-10
HAL-11
HAL-12
HAL-13
HAL-14
HAL-08
Security analysis | Risk level | Remediation Date |
---|---|---|
MISTAKENLY SENT BUNDLE TOKENS CAN NOT BE RESCUED | Medium | Solved - 12/09/2022 |
POSSIBLE LOSS OF OWNERSHIP | Medium | Solved - 12/09/2022 |
SENDELEMENTSTOPERSONALBUNDLER() FUNCTION CAN RUN INTO AN INFINITE LOOP | Low | Solved - 12/09/2022 |
ADD OR REMOVE BUNDLE ELEMENTS FUNCTIONS MAY RUN OUT OF GAS | Low | Risk Accepted |
MISSING PARAMETER VALIDATION | Low | Risk Accepted |
BUNDLES INSIDE IMMUTABLEBUNDLES CONTRACT CAN BE EXTRACTED | Informational | - |
USE OF INLINE ASSEMBLY | Informational | Acknowledged |
LOOP GAS USAGE OPTIMIZATION | Informational | Solved - 12/09/2022 |
SOLC 0.8.4 COMPILER VERSION CONTAINS MULTIPLE BUGS | Informational | Solved - 12/09/2022 |
SPLITTING REQUIRE() STATEMENTS THAT USES AND OPERATOR SAVES GAS | Informational | Solved - 12/09/2022 |
UNNECESSARY IMPORTS | Informational | Solved - 12/09/2022 |
ANYONE CAN ADD TOKENS TO ANY BUNDLE OR PERSONALBUNDLE | Informational | Acknowledged |
OPEN TODOs | Informational | Solved - 12/09/2022 |
INCOMPLETE NATSPEC DOCUMENTATION | Informational | Solved - 12/09/2022 |
// Medium
Users can lock their bundles (created with the NftfiBundler
or PersonalBundler
contracts) by transferring them to the ImmutableBundle
contract with the safeTransferFrom()
function. However, these contracts rely on users sending tokens to them with the appropriate functions (e.g., safeTransferFrom
or getChild
instead of transfer
and transferFrom
) to properly record those transactions.
The ImmutableBundle
contract allows the admin to recover ERC721
tokens with the rescueERC721
function. However, this function does not allow rescuing NftfiBundler
or PersonalBundler
tokens; therefore, it is impossible to recover bundles that were accidentally transferred with the wrong transfer functions (e.g., transfer
or transferFrom
).
/**
* @notice used by the owner account to be able to drain ERC721 tokens received as airdrops
* for the locked collateral NFT-s
* @param _tokenAddress - address of the token contract for the token to be sent out
* @param _tokenId - id token to be sent out
* @param _receiver - receiver of the token
*/
function rescueERC721(
address _tokenAddress,
uint256 _tokenId,
address _receiver
) external onlyOwner {
IERC721 tokenContract = IERC721(_tokenAddress);
require(
_tokenAddress != address(bundler) &&
!PersonalBundlerFactory(personalBundlerFactory).personalBundlerExists(msg.sender),
"token is a bundle"
);
require(tokenContract.ownerOf(_tokenId) == address(this), "nft not owned");
tokenContract.safeTransferFrom(address(this), _receiver, _tokenId);
}
As a proof of concept, user2
bundles NFTs 1, 2, and 3
with NftfiBundler
and transfers them to the ImmutableBundle
contract with the transferFrom()
function, which locks the bundle (and the NFTs contained in it) forever:
SOLVED: The NFTfi team
solved the issue by requiring immutableOfBundle[_tokenId]
or immutableOfPersonalBundler[_tokenAddress]
to be 0
, which means that NftfiBundler
or PersonalBundler
tokens not associated with no immutable bundle can be extracted.
Commit ID:
52f68e41a729e83f27c1cb747a464a2367132d5b
// Medium
When transferring the ownership of the protocol, no checks are performed on whether the new address is valid and active. In case there is a mistake when transferring the ownership, the whole protocol may lose all of its ownership functionalities.
/**
* @dev Transfers ownership of the contract to a new account (`newOwner`).
* Can only be called by the current owner.
*/
function transferOwnership(address _newOwner) public virtual onlyOwner {
require(_newOwner != address(0), "Ownable: new owner is the zero address");
_setOwner(_newOwner);
}
SOLVED: The NFTfi team
solved the issue by implementing a two-step ownership transfer process.
Commit ID:
52f68e41a729e83f27c1cb747a464a2367132d5b
// Low
Users can call sendElementsToPersonalBundler()
function to move every token inside a bundle to a personal bundle. This function uses a while
loop to iterate through every childToken
of every childContract
until childContracts[_tokenId]
and childTokens[_tokenId][childContrac]
lengths are 0
, meaning that no more child tokens are held in the bundle.
However, if tokens are already in a personal bundle, and they are transferred to the same bundle, or if they are in a NftfiBundler
bundle with id = 1
and they're being transferred to the same NftfiBundler
bundle (the second scenario is less likely than the first one), the function runs into an infinite loop, since the lengths mentioned above will never decrease, keeping the while
loop running until it spends the max amount of gas allowed for the call, reverting the state and incurring unnecessary cost to the user.
/**
* @notice Remove all the children from the bundle and send to personla bundler.
* If bundle contains a legacy ERC721 element, this will not work.
* @dev This method may run out of gas if the list of children is too big. In that case, children can be removed
* individually.
* @param _tokenId the id of the bundle
* @param _personalBundler address of the receiver of the children
*/
function sendElementsToPersonalBundler(uint256 _tokenId, address _personalBundler) external {
_validateReceiver(_personalBundler);
_validateTransferSender(_tokenId);
//fix this actual personalBundlerExists
require(
IERC165(_personalBundler).supportsInterface(type(IERC998ERC721TopDown).interfaceId),
"has to implement IERC998ERC721TopDown"
);
uint256 personalBundleId = 1;
//make sure sendeer owns personal bundler token
require(IERC721(_personalBundler).ownerOf(personalBundleId) == msg.sender, "has to own personal bundle token");
// In each iteration all contracts children are removed, so eventually all contracts are removed
while (childContracts[_tokenId].length() > 0) {
address childContract = childContracts[_tokenId].at(0);
// In each iteration a child is removed, so eventually all contracts children are removed
while (childTokens[_tokenId][childContract].length() > 0) {
uint256 childId = childTokens[_tokenId][childContract].at(0);
_removeChild(_tokenId, childContract, childId);
try
IERC721(childContract).safeTransferFrom(
address(this),
_personalBundler,
childId,
abi.encodePacked(personalBundleId)
)
{
// solhint-disable-previous-line no-empty-blocks
} catch {
revert("only safe transfer");
}
emit TransferChild(_tokenId, _personalBundler, childContract, childId);
}
}
}
As a proof of concept, user2
bundles NFTs 1, 2, and 3
with the NftfiBundler
contract. From there, the NFTs are being transferred to the user2's personal bundler with the sendElementsToPersonalBundler()
function, and then they are transferred again to the same personalbundle. This makes the sendElementsToPersonalBundler()
function to run into an infinite loop, which ends up with crashing the test environment.
SOLVED: The NFTfi team
solved the issue by preventing sendElementsToPersonalBundler()
from being called with msg.sender
as the _personalBundler
address.
Commit ID:
478ae0542a50367defd1f39047f418806205f7aa
// Low
Users can use functions to add or remove multiple NFTs at the same time in the NftfiBundler
or PersonalBundler
contracts. These functions can have high gas costs based on the number of tokens transferred. Adding elements also calls an external validator contract to check whether the asset is permitted or not, further increasing the gas cost.
Many users use wallets with default gas limit configured. When the limit is reached, the users lose a significant amount of Ether in those failed transactions.
The affected functions:
NftfiBundler.sol
buildBundle
addBundleElements
removeBundleElements
addAndRemoveBundleElements
decomposeBundle
sendElementsToPersonalBundler
RISK ACCEPTED: The NFTfi team
accepted the risk of this finding. In addition, gas limit and maximum bundle size checks will be implemented in the front-end.
// Low
The childContractByIndex
and childTokenByIndex
functions of the ERC998TopDown
contract did not validate their parameters. Setting invalid values may result in reverts without error messages.
contracts/NftfiBundler.sol
:
_permittedNfts
parameter is not a zero address._airdropFlashLoan
parameter is not a zero address.contracts/ImmutableBundle.sol
:
_bundler
parameter is not a zero address._personalBundlerFactory
parameter is not a zero address.contracts/PersonalBundlerFactory.sol
:
_personalBundlerImplementation
parameter is not a zero address.contracts/ERC998TopDown.sol
:
childContractByIndex
function does not validate that the _index
parameter is a valid index.childTokenByIndex
function does not validate that the _index
parameter is a valid index.contracts/utils/Ownable.sol
:
_initialOwner
parameter is not a zero address.RISK ACCEPTED: The NFTfi team
accepted the risk of this finding.
// Informational
// Informational
Inline assembly is a way to access the Ethereum Virtual Machine at a low level. This discards several important safety features of Solidity and the static compiler. Because the EVM is a stack machine, it is often hard to address the correct stack slot and provide arguments to opcodes at the correct point on the stack. Solidity’s inline assembly tries to facilitate that and other issues arising when writing manual assembly. Assembly is much more difficult to write because the compiler does not perform checks, so the contract developer should be aware of this warning.
assembly {
parentTokenOwner := or(ERC998_MAGIC_VALUE, parentTokenOwnerAddress)
}
assembly {
rootOwner := or(ERC998_MAGIC_VALUE, rootOwnerAddress)
}
assembly {
tokenId := mload(add(_data, 0x20))
}
ACKNOWLEDGED: The NFTfi team
acknowledged this issue.
// Informational
Multiple gas cost optimization opportunities were identified in the loops of the NftfiBundler
contract:
Unnecessary reading of the array length on each iteration wastes gas.
Using != consumes less gas than <.
It is possible to further optimize loops by using unchecked loop index incrementing and decrementing.
Loop counters do not need to be set to 0, since uint256
is already initialized to 0
.
contracts/NftfiBundler.sol
for (uint256 i = 0; i < _bundleElements.length; ++i) {
for (uint256 j = 0; j < _bundleElements[i].ids.length; ++j) {
for (uint256 j = 0; j < _bundleElements[i].ids.length; ++j) {
for (uint256 i = 0; i < _bundleElements.length; ++i) {
for (uint256 j = 0; j < _bundleElements[i].ids.length; ++j) {
contracts/PermittedNFTs.sol
for (uint256 i = 0; i < _nftContracts.length; ++i) {
SOLVED: The NFTfi team
implemented the recommended gas optimizations.
Commit ID:
d93033e7d122168797981dfbd439374fbe5d4dd2
// Informational
The scoped contracts have configured the fixed pragma set to 0.8.4
. The latest solidity compiler version, 0.8.17
, fixed important bugs in the compiler along with new native protections. The current version is missing the following fixes: 0.8.5, 0.8.6, 0.8.7, 0.8.8, 0.8.9, 0.8.12, 0.8.13, 0.8.14, 0.8.15, 0.8.16, 0.8.17.
The official Solidity's recommendations are that you should use the latest released version of Solidity when deploying contracts. Apart from exceptional cases, only the newest version receives security fixes.
SOLVED: The NFTfi team
bumped the Solidity compiler version to 0.8.17
.
Commit ID:
faa56c0d56293a7a43008a4c2f4f2500ba131cbf
// Informational
Instead of using the `&&`` operator in a single require statement to check multiple conditions, using multiple require statements with one condition per require statement saves 8 GAS per operation. The gas difference can only be realized if the revert condition is satisfied.
/**
* @notice used by the owner account to be able to drain ERC721 tokens received as airdrops
* for the locked collateral NFT-s
* @param _tokenAddress - address of the token contract for the token to be sent out
* @param _tokenId - id token to be sent out
* @param _receiver - receiver of the token
*/
function rescueERC721(
address _tokenAddress,
uint256 _tokenId,
address _receiver
) external onlyOwner {
IERC721 tokenContract = IERC721(_tokenAddress);
require(
_tokenAddress != address(bundler) &&
!PersonalBundlerFactory(personalBundlerFactory).personalBundlerExists(msg.sender),
"token is a bundle"
);
require(tokenContract.ownerOf(_tokenId) == address(this), "nft not owned");
tokenContract.safeTransferFrom(address(this), _receiver, _tokenId);
}
The following tests were carried out in Remix with optimization turned both on and off
require ( a > 1 && a < 5, "Initialized");
return a + 2;
Execution cost 21617 with optimization and using && 21976 without optimization and using &&
After splitting the require statement
require (a > 1 ,"Initialized");
require (a < 5 , "Initialized");
return a + 2;
Execution cost 21609 with optimization and split require 21968 without optimization and using split require
SOLVED: The NFTfi team
solved the issue by refactoring the mentioned require statement.
Commit ID:
52f68e41a729e83f27c1cb747a464a2367132d5b
// Informational
The following library imports can be removed because they are redundant or not used in the contracts:
IERC20.sol
is also included in SafeERC20.sol
.IERC1155.sol
is not used in some contracts.ERC721Holder.sol
is not used in some contracts.import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC1155/IERC1155.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC1155/IERC1155.sol";
``` {language=solidity firstnumber="7 " caption="contracts/PersonalBundler.sol"} import "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol";
``` {language=solidity firstnumber="8" caption="contracts/airdrop/AirdropFlashLoan.sol"}
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
SOLVED: The NFTfi team
solved the issue by removing unnecessary imports.
Commit ID:
fef3ac4bb4eb87a78e43082a560365a767178aae
// Informational
Users can add any whitelisted NFTs to their bundles or personal bundles with the safeTransferFrom()
or getChild()
functions.
However, it has been detected that no checks are in place to ensure that users can only add tokens to bundles they already own. Those kinds of checks are already implemented in functions such as sendElementsToPersonalBundler
, in which the owner of any bundle can only send the tokens to a personal bundle they own:
/**
* @notice Remove all the children from the bundle and send to personla bundler.
* If bundle contains a legacy ERC721 element, this will not work.
* @dev This method may run out of gas if the list of children is too big. In that case, children can be removed
* individually.
* @param _tokenId the id of the bundle
* @param _personalBundler address of the receiver of the children
*/
function sendElementsToPersonalBundler(uint256 _tokenId, address _personalBundler) external {
_validateReceiver(_personalBundler);
_validateTransferSender(_tokenId);
//fix this actual personalBundlerExists
require(
IERC165(_personalBundler).supportsInterface(type(IERC998ERC721TopDown).interfaceId),
"has to implement IERC998ERC721TopDown"
);
uint256 personalBundleId = 1;
//make sure sendeer owns personal bundler token
require(IERC721(_personalBundler).ownerOf(personalBundleId) == msg.sender, "has to own personal bundle token");
// In each iteration all contracts children are removed, so eventually all contracts are removed
while (childContracts[_tokenId].length() > 0) {
address childContract = childContracts[_tokenId].at(0);
// In each iteration a child is removed, so eventually all contracts children are removed
while (childTokens[_tokenId][childContract].length() > 0) {
uint256 childId = childTokens[_tokenId][childContract].at(0);
_removeChild(_tokenId, childContract, childId);
try
IERC721(childContract).safeTransferFrom(
address(this),
_personalBundler,
childId,
abi.encodePacked(personalBundleId)
)
{
// solhint-disable-previous-line no-empty-blocks
} catch {
revert("only safe transfer");
}
emit TransferChild(_tokenId, _personalBundler, childContract, childId);
}
}
}
This behavior allows the owner of the bundle to extract any token included in it, no matter who was the original owner. This can be used to use NFTfi reputation to perform phishing campaigns or any similar malicious activity that might have a reputational impact on the project.
**ACKNOWLEDGED: ** The NFTfi team
acknowledged this issue.
// Informational
Open To-dos can point to architecture or programming issues that still need to be resolved. Often these kinds of comments indicate areas of complexity or confusion for developers. This provides value and insight to an attacker who aims to cause damage to the protocol.
//fix this actual personalBundlerExists
SOLVED: The NFTfi team
solved the issue by removing every TODO present in the code.
Commit ID:
d93033e7d122168797981dfbd439374fbe5d4dd2
// Informational
Natspec documentation are useful for internal developers that need to work on the project, external developers that need to integrate with the project, auditors that have to review it but also for end users given that many chain explorers have officially integrated the support for it directly on their site.
It has been detected that, while many contracts have a complete natspec documentation, other contracts or functions are little to no documented.
SOLVED: The NFTfi team
added the missing natspec documentation.
Commit IDs:
Halborn used automated testing techniques to enhance the coverage of certain areas of the scoped contracts. Among the tools used was Slither, a Solidity static analysis framework. After Halborn verified all the contracts in the repository and was able to compile them correctly into their ABI and binary formats, Slither was run on the all-scoped 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.
AirdropFlashLoan.sol
PersonalBundlerFactory.sol, PersonalBundler.sol, NftfiBundler.sol, and ERC998TopDown.sol
ImmutableBundle.sol
All the reentrancies flagged are false positives.
No major issues were found by Slither.
Halborn used automated security scanners to assist with detection of well-known security issues, and to identify low-hanging fruits on the targets for this engagement. Among the tools used was MythX, a security analysis service for Ethereum smart contracts. MythX performed a scan on all the contracts and sent the compiled results to the analyzers to locate any vulnerabilities.
AirdropFlashLoan.sol
NftfiBundler.sol
PersonalBundler.sol
ImmutableBundler.sol
No major issues were found by MythX.
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