Prepared by:
HALBORN
Last Updated 04/29/2024
Date of Engagement by: March 6th, 2024 - March 15th, 2024
100% of all REPORTED Findings have been addressed
All findings
9
Critical
0
High
0
Medium
1
Low
3
Informational
5
NFTfi engaged Halborn
to conduct a security assessment on their smart contracts beginning on 02/06/2024 and ending on 02/20/2024. The security assessment was scoped to the smart contracts provided in the NFTfi-Genesis/eth.gen-art GitHub repository. Commit hashes and further details can be found in the Scope section of this report.
Halborn was provided 1.5 weeks 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 issues that were mostly addressed by the NFTfi Team.
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 code and can quickly identify items that do not follow the security best practices. The following phases and associated tools were used during the assessment:
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.
Manual testing by custom scripts.
Static Analysis of security for scoped contract, and imported functions (slither
).
Testnet deployment (Foundry
).
External libraries and financial-related attacks.
New features/implementations after/with the remediation commit IDs.
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
1
Low
3
Informational
5
Security analysis | Risk level | Remediation Date |
---|---|---|
Locked Ether | Medium | Solved - 03/19/2024 |
Predictable sources of randomness | Low | Risk Accepted |
Outdated dependency - ERC721A | Low | Solved - 03/19/2024 |
Function name typo and conditional logic divergence | Low | Solved - 03/19/2024 |
On-chain state can be used as attack vector to off-chain components | Informational | Acknowledged |
New opcodes are not supported by all chains (solidity version >= 0.8.20) | Informational | Acknowledged |
Floating pragma | Informational | Solved - 03/19/2024 |
Events are missing `indexed` fields | Informational | Acknowledged |
Incorrect NatSpec in contract and constructor declarations | Informational | Solved - 03/19/2024 |
// Medium
The Collection
contract inherits from ERC721A.sol
, which carries payable
functions on its original implementation, influenced by EIP-712. Specifically, the functions are: approve
, transferFrom
, safeTransferFrom(from, to, tokenId)
and safeTransferFrom(from, to, tokenId, _data)
, as follows:
- ERC721A.sol (v.4.2.3) [Line: 425]
function approve(address to, uint256 tokenId) public payable virtual override {
- ERC721A.sol (v.4.2.3) [Lines: 540-544]
function transferFrom(
address from,
address to,
uint256 tokenId
) public payable virtual override {
- ERC721A.sol (v.4.2.3) [Lines: 616-620]
function safeTransferFrom(
address from,
address to,
uint256 tokenId
) public payable virtual override {
- ERC721A.sol (v.4.2.3) [Lines: 629-634]
function safeTransferFrom(
address from,
address to,
uint256 tokenId,
bytes memory _data
) public payable virtual override {
While it keeps consistency with the original EIP-712 proposal, where the function approve
is indeed marked as payable
, the contract set under analysis does not seem to handle ether
because the mint
functionality relies on valid merkle proofs
provided by users in order to perform the operation.
Given this information, we can conclude that users could send ETH (native tokens) to the contract mistakenly when interacting with the aforementioned functions. In this scenario, ETH would be locked permanently into the contract because neither ERC721A.sol
nor Collection.sol
contracts contain a withdraw
method to move funds out of the contract.
After successfully minting the correct amount of tokens, by providing a valid merkle proof
, the owner
of the NFT is able to call the approve
function in the `ERC721A.sol`
contract, which is one of the four payable functions inherited by the Collection.sol
contract.
Proof of Concept:
function test_merkle_mint_and_approve_with_value() public {
console.log(unicode"🔮", StdStyle.blue("test_merkle_mint\n\n"));
/**
1. set merkle proofs
*/
bytes32[] memory morpheus_proof = new bytes32[](1);
morpheus_proof[0] = 0x42069d1d9a5a0fbead4b4887d4bb3b204fa18c5cf54215bb92d5d12c143ffcf0;
bytes32[] memory niobe_proof = new bytes32[](1);
niobe_proof[0] = 0x7e585b6365b68eda89a77d89440802bf1191ef6075bd01e23d4d10d5b420a726;
uint256 morpheus_quantity = 10;
uint8 morpheus_index = 0;
uint256 niobe_quantity = 20;
uint8 niobe_index = 1;
/**
2. mint in Collection contract
*/
// Morpheus mints with merkle proof
vm.startPrank(morpheus);
(bool success_mint, ) = address(collection).call{value: 0}(abi.encodeWithSelector(Collection.mint.selector, morpheus_quantity, morpheus_proof, morpheus_index));
require(success_mint, "Call Reverted");
// as NFT owner, Morpheus call approve
(bool success_approve, ) = address(collection).call{value: 50_000 ether}(abi.encodeWithSelector(ERC721A.approve.selector, niobe, 0));
require(success_approve, "Call Reverted");
}
Traces:
Traces:
[401759] NFTFI_GenNFT_Test::test_merkle_mint_and_approve_with_value()
├─ [0] console::log("🔮", "\u{1b}[94mtest_merkle_mint\n\n\u{1b}[0m") [staticcall]
│ └─ ← ()
├─ [0] VM::startPrank(0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf)
│ └─ ← ()
├─ [351294] 0x0A2035683FE5587B0B09DB0e757306aD729FE6c1::mint(10, [0x42069d1d9a5a0fbead4b4887d4bb3b204fa18c5cf54215bb92d5d12c143ffcf0], 0)
│ ├─ emit Claimed(_index: 0, _amount: 10, _merkleProof: [0x42069d1d9a5a0fbead4b4887d4bb3b204fa18c5cf54215bb92d5d12c143ffcf0], _account: 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf)
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf, tokenId: 0)
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf, tokenId: 1)
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf, tokenId: 2)
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf, tokenId: 3)
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf, tokenId: 4)
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf, tokenId: 5)
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf, tokenId: 6)
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf, tokenId: 7)
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf, tokenId: 8)
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf, tokenId: 9)
│ ├─ emit Mint(_startTokenId: 0, _quantity: 10, _hashes: [0xbafa5066ad891c32f53b210438633d47e5e918185e2e2d801ddd1dad11108bc3, 0xc7c4e51c0993f729c6797bdcda861bd7151ee8d01ca670597c3867afaae6e305, 0x008366da29b133f615782daa5bd80d412c368c845e9d8fcbab309edd16328a77, 0xbd01bf9d4d956483e36eb2c6bebae55dc3b28726f373510b9dfa7ef156375121, 0x5b4cad1a9547da7f20b78547401c951fd25aaf2ff5d65f388a38891e8576f536, 0xa96228a3c65d3caabb6766c545cc4cfe1357cb5e833f166a15d11c79d02aec9b, 0xd91b54a915e904e5c19718607a5ad7afb0096c4b9651d6869dc320b1ffb71116, 0xdd24dcd6e2ab679ff93f9c554bee52cf86307211d412ad61b6ff399314c26ad4, 0xdd4e0d42b3f692f6beb3b5dafc9c77cae0c13612c715ff118fb8707d43133a50, 0x84cc4d5241f3f1ab3d37cb313452cea7bcb5132cda465ac6ba9a1cd9d9a7ed29], _owner: 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf)
│ └─ ← ()
├─ [25046] 0x0A2035683FE5587B0B09DB0e757306aD729FE6c1::approve{value: 50000000000000000000000}(0x4CCeBa2d7D2B4fdcE4304d3e09a1fea9fbEb1528, 0)
│ ├─ emit Approval(owner: 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf, approved: 0x4CCeBa2d7D2B4fdcE4304d3e09a1fea9fbEb1528, tokenId: 0)
│ └─ ← ()
└─ ← ()
It is recommended to create a withdraw
function with access control, allowing authorized parties and/or administrators to perform safe withdrawals from any remaining ether
existent in the contract. For enhanced transparency, an event can also be emitted in these cases.
Code example:
- Error declaration:
error ZeroBalance();
- Event declaration:
event AdminWithdrawal(uint256 indexed amount, address indexed to);
- Function declaration:
function withdraw(address _to) external onlyOwner {
uint256 _amount = address(this).balance;
if (_amount == 0) revert ZeroBalance();
emit AdminWithdrawal(_amount, _to);
(bool success, ) = msg.sender.call{value: _amount}("");
require (success, "Collection: Failed to withdrawal");
}
Additionally, it is not recommended to perform function overrides or custom modifications in the base ERC721A.sol
contract, for example, requiring msg.value == 0
, considering it heavily relies on low-level, assembly code blocks and any modification could introduce to bugs or vulnerabilities, if not thoroughly tested and validated.
SOLVED: The NFTfi team solved the issue by implementing a suggested fix.
// Low
The Collection.sol
contracts rely on a pseudo-random number generation technique that introduces predictability. The generation of tokenIdRandWordHash
in the _mintInternal
function is based on elements that are inherently predictable, such as the global variables blockhash(block.number - 1)
and msg.sender
, and also the incrementing startTokenId
.
- contracts/Collection.sol [Lines: 108-117]
for (; i < _quantity; ) {
bytes32 tokenIdRandWordHash = keccak256(
abi.encodePacked(startTokenId + i, blockhash(block.number - 1), msg.sender)
);
tokenGenerationParams[startTokenId + i] = tokenIdRandWordHash;
tokenGenerationParamsEvent[i] = tokenIdRandWordHash;
unchecked {
++i;
}
}
The tokenIdRandWordHash
is constructed by hashing a combination of the previous blockhash, an incremental token ID and the msg.sender. This approach presents two primary concerns:
Predictability of blockhash
: Utilizing the hash of the preceding block as part of the randomness source is inherently insecure. Given that the blockhash
is publicly available and can be predicted by the time a transaction is mined, it does not provide a robust foundation for randomness, allowing the manipulation of tokenIdRandWordHash
.
The other components of the hash function, namely the incremented startTokenId + i
and msg.sender
, are predictable within the context of a transaction: msg.sender
remains unchanged, and startTokenId + i
follows a sequential order.
This could potentially allow users to gain undue advantage in the NFT minting process, such as preferential access to rare or valuable attributes or traits, by anticipating the associated hashes because they are relying on a predictable source of randomness.
Adopting a more robust mechanism for randomness is advisable. Employing a Verifiable Random Function (VRF) from established third-party providers, like Chainlink VRF, is recommended. This would guarantee the unpredictability and integrity of the randomness in tokenIdRandWordHash
creation.
RISK ACCEPTED: The NFTfi team accepted the risk related to this issue.
// Low
During the audit of the Collection.sol
contract, it was identified that the contract inherits from ERC721A.sol
version 4.2.3. However, the most recent and stable version of ERC721A.sol
, according to the official GitHub repository chiru-labs/ERC721A, is version 4.3.0.
- ERC721A.sol (v.4.2.3) [Lines: 1-7]
// SPDX-License-Identifier: MIT
// ERC721A Contracts v4.2.3
// Creator: Chiru Labs
pragma solidity ^0.8.4;
import './IERC721A.sol';
Using an outdated version can expose the contract to previously addressed vulnerabilities and deprive it of the enhanced features and optimizations introduced in the newer version.
An upgrade of ERC721A.sol
to version 4.3.0 is recommended to ensure access to the latest improvements and security updates.
The latest version is available in the following GitHub repository:
SOLVED: The NFTfi team solved this issue by updating the ERC721A
dependency to its latest version.
// Low
Within the Collection.sol
contract, there are two similar functions: setGeneraingScript
with a typo, and setGeneratingScript
, which aim to update the generatingScript
variable, but they handle this task differently.
The former function takes its parameter as calldata
and includes a conditional security check that prevents updates if a isGeneratingScriptLocked
flag is set. On the other hand, the latter function accepts a parameter in memory
and lacks this conditional security check, allowing for a broader range of updates.
- contracts/Collection.sol [Lines: 54-58]
function setGeneraingScript(string calldata _generatingScript) external onlyOwner {
if (isGeneratingScriptLocked) revert Locked();
generatingScript = _generatingScript;
}
- contracts/Collection.sol [Lines: 122-124]
function setGeneratingScript(string memory _generatingScript) external onlyOwner {
generatingScript = _generatingScript;
}
The subtle differences between the two functions can make the contract harder to navigate and could lead to errors in use.
Merging these functions into one streamlined version is recommended. Use the name setGeneratingScript
, use calldata
for the string parameter to keep gas costs lower.
Incorporating the necessary security check within this single function will ensure that script updates are safely managed. This approach simplifies the interface, enhances security, and maintains efficiency, providing a clear, unified method for script updates.
Example:
function setGeneratingScript(string calldata _generatingScript) external onlyOwner {
if (isGeneratingScriptLocked) revert Locked();
generatingScript = _generatingScript;
}
SOLVED: The NFTfi team solved the issue by implementing a suggested fix.
// Informational
Users can write arbitrary _generatingScript
to contracts state (`string public generatingScript
`) when calling createCollection
on CollectionFactory.sol
contract.
- contracts/CollectionFactory.sol [Lines: 9-29]
function createCollection(
bytes32 _merkleRoot,
address _admin,
string memory _generatingScript,
string memory _baseUri,
uint256 _mintTimeout,
uint256 _maxSupply
) external {
Collection collectionAddress = new Collection(
"Nftfi GenArt Collection",
"NGAC",
_merkleRoot,
_admin,
_generatingScript,
_baseUri,
_mintTimeout,
_maxSupply
);
emit CollectionCreated(address(collectionAddress));
}
This could, potentially, allow users to save "payloads" on-chain, through the contract's state, that could be further executed by the Front-end application and/or the web server, accordingly to the business rules and overall system infrastructure.
If those inputs aren't thoroughly validated in the off-chain premises, on-chain data (crafted payloads stored in generatingScript
) can be used as an attack vector for exploring vulnerabilities in the web application, such as Stored Cross-site Scripting (Stored XSS) or HTML injection.
This finding is not directly related to a security issue. However, it's important to bear in mind that any future and unaudited releases of the Front-end application should be thoroughly tested, specially when reading user-provided, non sanitized on-chain data.
Because of inherent design limitations within EVM and Solidity, it is not economically neither technically possible to sanitize the user inputs - in this case, possible malicious payloads - that are written on-chain when calling the createCollection
function in the CollectionFactory.sol
contract.
It is recommended to test thoroughly any unaudited updates in the User Interface and Front-end application, to make sure that user-provided inputs, specifically those which are read on-chain, are correctly sanitized before interpreted by the browser.
ACKNOWLEDGED: The NFTfi team acknowledged the issue.
// Informational
Solc compiler version 0.8.20
switches the default target EVM version to Shanghai. The generated bytecode will include PUSH0 opcodes. The recently released Solc compiler version 0.8.25
switches the default target EVM version to Cancun, so it is also important to note that it also adds-up new opcodes such as TSTORE, TLOAD and MCOPY.
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, TSTORE, TLOAD and/or MCOPY, otherwise deployment of your contracts will fail.
It is important to consider the targeted deployment chains before writing immutable contracts because, in the future, there might exist a need for deploying the contracts in a network that could not support new opcodes from Shanghai or Cancun EVM versions.
ACKNOWLEDGED: The NFTfi team acknowledged the issue.
// Informational
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, an outdated compiler version that might introduce bugs that affect the contract system negatively.
During the analysis, both contracts Collection.sol
and CollectionFactory.sol
were identified using a floating pragma, as follows:
pragma solidity ^0.8.20;
Consider using a specific version of Solidity in your contracts instead of a wide version. For example, instead of pragma solidity ^0.8.20;
, use pragma solidity 0.8.25;
.
SOLVED: The NFTfi team solved the issue by implementing a suggested fix.
// Informational
Indexed event fields make the data more quickly accessible to off-chain tools that parse events, and add them to a special data structure known as “topics” instead of the data part of the log. A topic can only hold a single word (32 bytes) so if you use a reference type for an indexed argument, the Keccak-256 hash of the value is stored as a topic instead.
Each event can use up to three indexed fields. If there are fewer than three fields, all the fields can be indexed. It is important to note that each index field costs extra gas during emission, so it's not necessarily best to index the maximum allowed fields per event (three indexed fields).
This is specially recommended when gas usage is not particularly of concern for the emission of the events in question, and the benefits of querying those fields in an easier and straight-forward manner surpasses the downsides of gas usage increase.
- contracts/Collection.sol [Line: 13]
event Mint(uint256 indexed _startTokenId, uint256 _quantity, bytes32[] _hashes, address indexed _owner);
- contracts/CollectionFactory.sol [Line: 7]
event CollectionCreated(address collectionAddress);
- contracts/MerkleDistributor.sol [Line: 22]
event Claimed(uint256 _index, uint256 _amount, bytes32[] _merkleProof, address indexed _account);
It is recommended to add the indexed
keyword when declaring events, considering the following example:
event Indexed(
address indexed from,
bytes32 indexed id,
uint indexed value
);
ACKNOWLEDGED: The NFTfi team acknowledged the issue.
// Informational
In the MerkleDistributor.sol
contract, the NatSpec specifications are slightly wrong, considering they are referring to the contract TokenLock.sol
, which belongs to another NFTIfi's
contract set.
- contracts/MerkleDistributor.sol [Lines: 9-16]
/**
* @title MerkleDistributor
* @author NFTfi
* @dev Modified version of Uniswap's MerkleDistributor
* https://github.com/Uniswap/merkle-distributor/blob/master/contracts/MerkleDistributor.sol
* Main difference: in claim instead of transferring the tokens to the user,
* we transfer it to the tokenLock contract
*/
- contracts/MerkleDistributor.sol [Lines: 24-28]
/**
* @dev Constructor initializes references for NFTfi token and TokenLock contract.
* It also sets the owner of the contract.
* @param _admin The initial owner of the contract, usually able to set Merkle roots.
*/
Keeping consistency across NatSpec documentation enhances contract readability and makes it easier both for developers and non-developers to understand the contract's source code.
It is recommended to review the NatSpec specifications for any typos or misalignment.
SOLVED: The NFTfi team solved the issue by implementing a suggested fix.
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 severity Information
and Optimization
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 the one related to Locked Ether
was included in the report. The other findings were not included in the report because they were determined false positives.
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