Halborn Logo

bazaar.art Marketplace - Haqq


Prepared by:

Halborn Logo

HALBORN

Last Updated 05/13/2024

Date of Engagement by: February 26th, 2024 - March 8th, 2024

Summary

100% of all REPORTED Findings have been addressed

All findings

11

Critical

0

High

0

Medium

2

Low

1

Informational

8


Introduction

Haqq engaged Halborn to conduct a security assessment on their smart contracts beginning on February 28th and ending on March 8th. The security assessment was scoped to the smart contracts provided in the haqq-network/nft-marketplace-smart-contracts GitHub repository. Commit hashes and further details can be found in the Scope section of this report.

Assessment Summary

Halborn was provided 2 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 security that were mostly addressed by the Haqq team, being the most relevant ones:

  • Wrong royalty distribution for non-ERC721H and non-ERC1155H tokens.

  • Auction and Listing contracts are locking-up ether.

Test Approach and Methodology

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).

  • ERC721 property verification and fuzzing tests (echidna).

  • Testnet deployment (Foundry).


Out-of-scope

  • External libraries and financial-related attacks.

  • New features/implementations after/with the remediation commit IDs.

1. RISK METHODOLOGY

Every vulnerability and issue observed by Halborn is ranked based on two sets of Metrics and a Severity Coefficient. This system is inspired by the industry standard Common Vulnerability Scoring System.
The two Metric sets are: Exploitability and Impact. Exploitability captures the ease and technical means by which vulnerabilities can be exploited and Impact describes the consequences of a successful exploit.
The Severity Coefficients is designed to further refine the accuracy of the ranking with two factors: Reversibility and Scope. These capture the impact of the vulnerability on the environment as well as the number of users and smart contracts affected.
The final score is a value between 0-10 rounded up to 1 decimal place and 10 corresponding to the highest security risk. This provides an objective and accurate rating of the severity of security vulnerabilities in smart contracts.
The system is designed to assist in identifying and prioritizing vulnerabilities based on their level of risk to address the most critical issues in a timely manner.

1.1 EXPLOITABILITY

Attack Origin (AO):
Captures whether the attack requires compromising a specific account.
Attack Cost (AC):
Captures the cost of exploiting the vulnerability incurred by the attacker relative to sending a single transaction on the relevant blockchain. Includes but is not limited to financial and computational cost.
Attack Complexity (AX):
Describes the conditions beyond the attacker’s control that must exist in order to exploit the vulnerability. Includes but is not limited to macro situation, available third-party liquidity and regulatory challenges.
Metrics:
EXPLOITABILITY METRIC (mem_e)METRIC VALUENUMERICAL 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
Exploitability EE is calculated using the following formula:

E=meE = \prod m_e

1.2 IMPACT

Confidentiality (C):
Measures the impact to the confidentiality of the information resources managed by the contract due to a successfully exploited vulnerability. Confidentiality refers to limiting access to authorized users only.
Integrity (I):
Measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of data stored and/or processed on-chain. Integrity impact directly affecting Deposit or Yield records is excluded.
Availability (A):
Measures the impact to the availability of the impacted component resulting from a successfully exploited vulnerability. This metric refers to smart contract features and functionality, not state. Availability impact directly affecting Deposit or Yield is excluded.
Deposit (D):
Measures the impact to the deposits made to the contract by either users or owners.
Yield (Y):
Measures the impact to the yield generated by the contract for either users or owners.
Metrics:
IMPACT METRIC (mIm_I)METRIC VALUENUMERICAL 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
Impact II is calculated using the following formula:

I=max(mI)+mImax(mI)4I = max(m_I) + \frac{\sum{m_I} - max(m_I)}{4}

1.3 SEVERITY COEFFICIENT

Reversibility (R):
Describes the share of the exploited vulnerability effects that can be reversed. For upgradeable contracts, assume the contract private key is available.
Scope (S):
Captures whether a vulnerability in one vulnerable contract impacts resources in other contracts.
Metrics:
SEVERITY COEFFICIENT (CC)COEFFICIENT VALUENUMERICAL VALUE
Reversibility (rr)None (R:N)
Partial (R:P)
Full (R:F)
1
0.5
0.25
Scope (ss)Changed (S:C)
Unchanged (S:U)
1.25
1
Severity Coefficient CC is obtained by the following product:

C=rsC = rs

The Vulnerability Severity Score SS is obtained by:

S=min(10,EIC10)S = min(10, EIC * 10)

The score is rounded up to 1 decimal places.
SeverityScore Value Range
Critical9 - 10
High7 - 8.9
Medium4.5 - 6.9
Low2 - 4.4
Informational0 - 1.9

2. SCOPE

Files and Repository
(b) Assessed Commit ID: b81d2e6
(c) Items in scope:
  • contracts/Auction.sol
  • contracts/Listing.sol
  • contracts/NFTRegistry.sol
↓ Expand ↓
Out-of-Scope: - External and third-party dependencies, - Financial attacks.
Remediation Commit ID:
Out-of-Scope: New features/implementations after the remediation commit IDs.

3. Assessment Summary & Findings Overview

Critical

0

High

0

Medium

2

Low

1

Informational

8

Security analysisRisk levelRemediation Date
Wrong royalty distributionMediumSolved - 05/07/2024
Locked etherMediumSolved - 04/23/2024
Centralization Risk for trusted ownersLowRisk Accepted
Use Ownable2Step instead of OwnableInformationalAcknowledged
Outdated compiler version and floating pragmaInformationalAcknowledged
Duplicate access control mechanismInformationalSolved - 04/23/2024
Use custom errorsInformationalAcknowledged
Missing input validationsInformationalSolved - 04/23/2024
Avoid using magic numbersInformationalAcknowledged
Modifier `nonReentrant` is misplacedInformationalSolved - 04/23/2024
Functions not used internally can be marked externalInformationalSolved - 04/23/2024

4. Findings & Tech Details

4.1 Wrong royalty distribution

// Medium

Description

In both Auction and Listing contracts, the royalty calculation is performed accordingly to the standard of the contract. If we're dealing with ERC721H or ERC1155H type contracts, we have one logic that guides the royalty calculation. If the contract implements EIP2981, the royalty calculation follows another calculation.

The ERC2981 standard provides a way for NFT creators to receive royalties from secondary sales. The standard defines a royaltyInfo function that takes the sale price of an NFT as an input and returns the recipient address for the royalties and the royalty amount due based on the sale price.

In the provided code for the Listing contract, the distributeRoyalties function incorrectly passes a fixed value of 100 as the salePrice parameter to the royaltyInfo function. This mistake will lead to incorrect calculation of royalty amounts, as the royalty should be a percentage of the actual sale price, not a fixed value.

    function distributeRoyalties(address nftAddress, address nftOwner, uint256 nftID, uint256 price) internal {
        address nftCreator;
        uint256 royalityPercent;
        if (nftAddress == erc721HAddress || nftAddress == erc1155HAddress) {
            (nftCreator, royalityPercent) = IERCH(nftAddress).royalties(nftID);
        } else {
            (nftCreator, royalityPercent) = IERC2981(nftAddress).royaltyInfo(nftID, 100);
        }

        uint256 royalties = (price * royalityPercent).uncheckedDiv(100);
        if (nftCreator != address(0) && royalties > 0) {
            (bool successRoy, ) = payable(nftCreator).call{value: royalties}("");
            require(successRoy, "Failed to pay nftCreator");
        }
        (bool successOwner, ) = payable(nftOwner).call{value: price - royalties}("");
        require(successOwner, "Failed to pay nftOwner");
    }

Furthermore, the salePrice should be considered as the actual price of the sale, and not the royalty percentage as expected by ERC721H or ERC1155H type contracts.

The same happens within the scope of endAuction function in Auction contract.

    function endAuction(uint256 saleListingID) external onlyOwner {
        (address nftAddress, uint256 nftID, address nftOwner, , , , uint256 quantity) = listing.saleListing(
            saleListingID
        );
        require(listing.isOnAuctionSale(saleListingID), "A: not listed in auction sale");

        address highestBidder = highestBid[saleListingID].highestBidder;
        require(highestBidder != address(0), "A: nobody placed bid");

        uint256 highestBidAmount = highestBid[saleListingID].highestBidAmount;
        uint256 commission = (highestBidAmount * auctionComPercent).uncheckedDiv(100);

        if (NFTIdentifier.isERC721(nftAddress)) {
            listing.unlist(true, saleListingID, nftAddress, nftID, nftOwner);

            IERC721(nftAddress).safeTransferFrom(address(this), highestBidder, nftID, "");
        } else if (NFTIdentifier.isERC1155(nftAddress)) {
            listing.unlist(false, saleListingID, nftAddress, nftID, nftOwner);

            IERC1155(nftAddress).safeTransferFrom(address(this), highestBidder, nftID, quantity, "");
        } else {
            revert("A: not a NFT address");
        }

        delete highestBid[saleListingID];

        address nftCreator;
        uint256 royalityPercent;
        if (nftAddress == erc721HAddress || nftAddress == erc1155HAddress) {
            (nftCreator, royalityPercent) = IERCH(nftAddress).royalties(nftID);
        } else {
            (nftCreator, royalityPercent) = IERC2981(nftAddress).royaltyInfo(nftID, 100);
        }

        uint256 royalties = ((highestBidAmount - commission) * royalityPercent).uncheckedDiv(100);
        if (nftCreator != address(0) && royalties > 0) {
            (bool successRoy, ) = payable(nftCreator).call{value: royalties}("");
            require(successRoy, "Failed to pay nftCreator");
        }
        (bool successOwner, ) = payable(nftOwner).call{value: highestBidAmount - commission - royalties}("");
        require(successOwner, "Failed to pay nftOwner");
        (bool successCom, ) = payable(commissionAddress).call{value: commission}("");
        require(successCom, "Failed to pay commissionAddress");

        emit AuctionEnded(saleListingID, highestBidder, highestBidAmount);
    }

In both cases, contracts are considering wrong values for calculation of royalties for non-ERC721H and non-ERC1155H type contracts. Also, the tuple must consider that the royaltyInfo function is returning the uint256 royaltyAmount and not a percentage value.

It is important to mention that the current scenario could also lead to calculations where there is not enough of ether to transfer the royalty amount, bricking the buyFixedSale and the NFT purchase/sale.

Proof of Concept

The following forge test demonstrates in diverse scenarios the calculation of royalties for non-ERC721H and non-ERC1155H will bring wrong values and brick contract functionality.

Test case:

    function test_purchase_fixed_sale_erc721_standard() public {
        // build the state of one NFT listed by Morpheus
        test_listing_list_fixed_sale_erc721_standard();

        // check ETH balances before
        uint256 niobe_native_balance_before = niobe.balance;
        console.log(
            StdStyle.green("\n\n[BALANCE] Niobe native ETH balance before purchase:"),
            niobe_native_balance_before
        );

        uint256 morpheus_native_balance_before = morpheus.balance;
        console.log(
            StdStyle.green("\n\n[BALANCE] Morpheus native ETH balance before purchase:"),
            morpheus_native_balance_before
        );

        uint256 listing_contract_native_balance_before = listing_address.balance;
        console.log(
            StdStyle.green("\n\n[BALANCE] Listing contract native ETH balance before purchase:"),
            listing_contract_native_balance_before
        );

        uint256 comission_address_native_balance_before = commission_address.balance;
        console.log(
            StdStyle.green("\n\n[BALANCE] Comission address native ETH balance before purchase:"),
            comission_address_native_balance_before
        );

        // Niobe wants to buy the NFT
        vm.startPrank(niobe);

        uint8 _listing_id = 1;
        uint256 _nft_price = 7 ether;

        // call `buyFixedSale` function with value and listing ID
        vm.expectRevert();
        (bool success, ) = address(listing).call{value: _nft_price + 1 ether}(abi.encodeWithSignature("buyFixedSale(uint256)", _listing_id));
        require(success, "Call Reverted");

        vm.stopPrank();

    }

Stack trace:

    ├─ [60765] 0x71eACd69f581665CC7F511dF9670Ef61f335Bc91::buyFixedSale{value: 8000000000000000000}(1)
    │   ├─ [1048] 0xB242b8DdCEcEA9bdf7B38fBd2C828dfc8ef17c80::isWhitelisted(0x03ee8124771E6ab1961AA08BDdFBc9408D2A6d33, 1) [staticcall]
    │   │   └─ ← true
    │   ├─ [639] 0x03ee8124771E6ab1961AA08BDdFBc9408D2A6d33::supportsInterface(0x01ffc9a700000000000000000000000000000000000000000000000000000000) [staticcall]
    │   │   └─ ← true
    │   ├─ [639] 0x03ee8124771E6ab1961AA08BDdFBc9408D2A6d33::supportsInterface(0xffffffff00000000000000000000000000000000000000000000000000000000) [staticcall]
    │   │   └─ ← false
    │   ├─ [552] 0x03ee8124771E6ab1961AA08BDdFBc9408D2A6d33::supportsInterface(0x80ac58cd00000000000000000000000000000000000000000000000000000000) [staticcall]
    │   │   └─ ← true
    │   ├─ [29917] 0x03ee8124771E6ab1961AA08BDdFBc9408D2A6d33::safeTransferFrom(0x033aee9f29811662CE15fe38565320d5D455AFE5, 0x7f61DEdEf4Ea255E8FAfbE3e34857ea063F34324, 1, 0x)
    │   │   ├─ emit Transfer(from: 0x033aee9f29811662CE15fe38565320d5D455AFE5, to: 0x7f61DEdEf4Ea255E8FAfbE3e34857ea063F34324, tokenId: 1)
    │   │   └─ ← ()
    │   ├─ [417] 0x03ee8124771E6ab1961AA08BDdFBc9408D2A6d33::royaltyInfo(1, 100) [staticcall]
    │   │   └─ ← 0x033aee9f29811662CE15fe38565320d5D455AFE5, 445203457625689783512 [4.452e20]
    │   ├─ [0] 0x033aee9f29811662CE15fe38565320d5D455AFE5::fallback{value: 31164242033798284845840000000000000000}()
    │   │   └─ ← EvmError: OutOfFund
    │   └─ ← revert: Failed to pay nftCreator
    ├─ [0] VM::stopPrank()
BVSS
Recommendation

To fix this issue, the royaltyInfo function should be called with the actual sale price of the NFT.

The corrected line in the distributeRoyalties function should look something like this:

(nftCreator, royaltyAmount) = IERC2981(nftAddress).royaltyInfo(nftID, price); 

Here, price is the actual sale price of the NFT, which ensures that the royalty calculation is based on the correct sale/purchase value, and also ensures that the returned value is correctly attributed in the tuple.


Remediation Plan

SOLVED : The Haqq team solved this issue in the commit id a8d4b89e23912d634b1411ec94db9c1a45f74e10.

Remediation Hash

4.2 Locked ether

// Medium

Description

In the Listing contract, users can send ether to the buyFixedSale payable function, in order to purchase a NFT listed for a fixed price (in ether). The protocol commission, when considered, is applied on top of the listing (selling) value, and needs to be added-up to the msg.value, otherwise the call to the function will revert.

    function buyFixedSale(uint256 saleListingID) external payable existingFixedSaleListing(saleListingID) nonReentrant {

In this case, users can send more than required ether to perform the purchase transaction (considering protocol commissions). The contract doesn't have a function that allows authorized parties and/or protocol administrators to withdraw any remaining ether from the contract, what can lead to funds being locked-up in the contract permanently.

Proof of Concept

The following forge test demonstrates that it is possible to send arbitrary amounts of ether to the Listing contract, having the remaining funds locked.

Test case:

function test_purchase_fixed_sale_erc721h() public {
        // build the state of one NFT listed by Morpheus
        test_listing_list_fixed_sale_erc721h();

        // check ETH balances before
        uint256 niobe_native_balance_before = niobe.balance;
        console.log(
            StdStyle.green("\n\n[BALANCE] Niobe native ETH balance before purchase:"),
            niobe_native_balance_before
        );

        uint256 morpheus_native_balance_before = morpheus.balance;
        console.log(
            StdStyle.green("\n\n[BALANCE] Morpheus native ETH balance before purchase:"),
            morpheus_native_balance_before
        );

        uint256 listing_contract_native_balance_before = listing_address.balance;
        console.log(
            StdStyle.green("\n\n[BALANCE] Listing contract native ETH balance before purchase:"),
            listing_contract_native_balance_before
        );

        uint256 comission_address_native_balance_before = commission_address.balance;
        console.log(
            StdStyle.green("\n\n[BALANCE] Comission address native ETH balance before purchase:"),
            comission_address_native_balance_before
        );

        // Niobe wants to buy the NFT
        vm.startPrank(niobe);

        uint8 _listing_id = 1;
        uint256 _nft_price = 7 ether;

        // call `buyFixedSale` function with value and listing ID + ether surplus
        (bool success, ) = address(listing).call{value: _nft_price + 2 ether}(abi.encodeWithSignature("buyFixedSale(uint256)", _listing_id));
        require(success, "Call Reverted");

        vm.stopPrank();

        // check ETH balances after
        uint256 niobe_native_balance_after = niobe.balance;
        console.log(
            StdStyle.green("\n\n[BALANCE] Niobe native ETH balance after purchase:"),
            niobe_native_balance_after
        );

        uint256 morpheus_native_balance_after = morpheus.balance;
        console.log(
            StdStyle.green("\n\n[BALANCE] Morpheus native ETH balance after purchase:"),
            morpheus_native_balance_after
        );

        uint256 listing_contract_native_balance_after = listing_address.balance;
        console.log(
            StdStyle.green("\n\n[BALANCE] Listing contract native ETH balance after purchase:"),
            listing_contract_native_balance_after
        );

        uint256 comission_address_native_balance_after = commission_address.balance;
        console.log(
            StdStyle.green("\n\n[BALANCE] Comission address native ETH balance after purchase:"),
            comission_address_native_balance_after
        );

    }

Stack trace:

    ├─ [104081] 0x71eACd69f581665CC7F511dF9670Ef61f335Bc91::buyFixedSale{value: 9000000000000000000}(1)
    │   ├─ [1048] 0xB242b8DdCEcEA9bdf7B38fBd2C828dfc8ef17c80::isWhitelisted(0x57369153bB16A0E736FE95AE3A19a76Add02D70A, 1) [staticcall]
    │   │   └─ ← true
    │   ├─ [639] 0x57369153bB16A0E736FE95AE3A19a76Add02D70A::supportsInterface(0x01ffc9a700000000000000000000000000000000000000000000000000000000) [staticcall]
    │   │   └─ ← true
    │   ├─ [639] 0x57369153bB16A0E736FE95AE3A19a76Add02D70A::supportsInterface(0xffffffff00000000000000000000000000000000000000000000000000000000) [staticcall]
    │   │   └─ ← false
    │   ├─ [552] 0x57369153bB16A0E736FE95AE3A19a76Add02D70A::supportsInterface(0x80ac58cd00000000000000000000000000000000000000000000000000000000) [staticcall]
    │   │   └─ ← true
    │   ├─ [29928] 0x57369153bB16A0E736FE95AE3A19a76Add02D70A::safeTransferFrom(0x033aee9f29811662CE15fe38565320d5D455AFE5, 0x7f61DEdEf4Ea255E8FAfbE3e34857ea063F34324, 1, 0x)
    │   │   ├─ emit Transfer(from: 0x033aee9f29811662CE15fe38565320d5D455AFE5, to: 0x7f61DEdEf4Ea255E8FAfbE3e34857ea063F34324, tokenId: 1)
    │   │   └─ ← ()
    │   ├─ [693] 0x57369153bB16A0E736FE95AE3A19a76Add02D70A::royalties(1) [staticcall]
    │   │   └─ ← 0x033aee9f29811662CE15fe38565320d5D455AFE5, 10
    │   ├─ [0] 0x033aee9f29811662CE15fe38565320d5D455AFE5::fallback{value: 700000000000000000}()
    │   │   └─ ← ()
    │   ├─ [0] console::log("Sent royalties to NFT Creator") [staticcall]
    │   │   └─ ← ()
    │   ├─ [0] console::log("NFT Creator (minter) address:") [staticcall]
    │   │   └─ ← ()
    │   ├─ [0] console::log(0x033aee9f29811662CE15fe38565320d5D455AFE5) [staticcall]
    │   │   └─ ← ()
    │   ├─ [0] 0x033aee9f29811662CE15fe38565320d5D455AFE5::fallback{value: 6300000000000000000}()
    │   │   └─ ← ()
    │   ├─ [0] 0xb17075c9D3564628a7054Dd6ee9e4806C275AF61::fallback{value: 700000000000000000}()
    │   │   └─ ← ()
    │   ├─ emit BoughtFixedSale(nftAddress: 0x57369153bB16A0E736FE95AE3A19a76Add02D70A, nftID: 1, buyer: 0x7f61DEdEf4Ea255E8FAfbE3e34857ea063F34324, quantity: 1, saleListingID: 1)
    │   └─ ← ()
    ├─ [0] VM::stopPrank()
    │   └─ ← ()
    ├─ [0] console::log("\u{1b}[92m\n\n[BALANCE] Niobe native ETH balance after purchase:\u{1b}[0m", 5000000000000000000 [5e18]) [staticcall]
    │   └─ ← ()
    ├─ [0] console::log("\u{1b}[92m\n\n[BALANCE] Morpheus native ETH balance after purchase:\u{1b}[0m", 107000000000000000000 [1.07e20]) [staticcall]
    │   └─ ← ()
    ├─ [0] console::log("\u{1b}[92m\n\n[BALANCE] Listing contract native ETH balance after purchase:\u{1b}[0m", 1300000000000000000 [1.3e18]) [staticcall]
    │   └─ ← ()
    ├─ [0] console::log("\u{1b}[92m\n\n[BALANCE] Comission address native ETH balance after purchase:\u{1b}[0m", 700000000000000000 [7e17]) [staticcall]
    │   └─ ← ()
    └─ ← ()
BVSS
Recommendation

It is recommended to create a withdraw mechanism with access control, allowing authorized parties and/or administrators to perform safe withdrawals from any remaining ether existent in the contract.

Furthermore, it is recommended to enhance the checks (validations) when this function is called, reverting when the msg.value is higher than the listed price + protocol commission.


Remediation Plan

SOLVED : The Haqq team solved this issue in the commit id 1d6c5a0913e01ac2400d574822b9da169a79d50b.

Remediation Hash

4.3 Centralization Risk for trusted owners

// Low

Description

The contracts in-scope are safe-guarded by an access control mechanism, which is a good practice in smart contract development and prevents non-authorized parties and bad actors to perform unauthorized activities within the contracts and ecosystem.

Owners with privileged rights to perform administrative operations need to be trusted and have power mitigated to prevent unilateral actions.

In Wallet contract, updateBalances function, gives the ability to the protocol owner to transfer locked (deposited) amounts from user wallet to protocol wallets.

    function updateBalance(uint256 mintingID, uint256 gasFee, bool unlock) external override onlyAuthorized {
        uint256 amount = balances[mintingID].locked;
        balances[mintingID].locked = 0;
        if (amount > 0) {
            if (!unlock) {
                (bool successDAO, ) = payable(daoAddress).call{value: amount}("");
                require(successDAO, "W: Failed to pay daoAddress");
            } else if (gasFee > 0) {
                require(amount >= gasFee, "W: User balance is lower than gas fee");
                balances[mintingID].available = amount - gasFee;
                plateformBalance += gasFee;
            }
        }
    }

In Listing contract, the owner has privileged rights in unlistFixedSale function.

    function unlistFixedSale(uint256 saleListingID) external existingFixedSaleListing(saleListingID) {

{ ...}

        require(msg.sender == nftOwner || msg.sender == owner(), "L: not the nftOwner or contract owner");

While centralization doesn't directly pose a security concern, it is always a security best practice, mitigating the risk of critical operations being performed by a single actor.

BVSS
Recommendation

It is recommended to use a multi-signature mechanism (multisign) and establish a secure minimum signatures' threshold for triggering access-controlled calls to smart contracts.


Remediation Plan

RISK ACCEPTED : The Haqq team accepted the risk of this issue. The risk is currently mitigated through the usage of multi-signature wallets.

4.4 Use Ownable2Step instead of Ownable

// Informational

Description

The Ownable2Step pattern is a variant of the traditional Ownable pattern in smart contract design, specifically aimed at adding another layer of security for ownership transfers.

In the standard Ownable pattern, ownership of the contract can be transferred with a single transaction, which, while efficient, poses a security risk if the owner's account is compromised or in cases of accidental transfers to incorrect addresses.

The Ownable2Step pattern mitigates this risk by introducing a two-step process for ownership transfer. The current owner initiates the transfer by proposing a new owner, but the transfer only completes when the proposed new owner accepts it.

This process ensures that ownership can only be transferred to an address that consents to take over the responsibilities, thereby reducing the risk of accidental or malicious transfers.

Score
Recommendation

Implement the official Ownable2Step OpenZeppelin's implementation by inheriting from Ownable2Step instead of Ownable.


Remediation Plan

ACKNOWLEDGED: The Haqq team acknowledged this issue.

4.5 Outdated compiler version and floating pragma

// Informational

Description

Outdated compiler version:

It was identified an outdated compiler version in multiple contracts

As from the official Solidity documentation, it is recommended to use the latest released version of Solidity.

It was identified that the contracts in the set under analysis are using solc version 0.8.19, hence, outdated, considering the current Solidity compiler (solc) version is 0.8.24.


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, an outdated compiler version that might introduce bugs that affect the contract system negatively.

- contracts/Auction.sol [Line: 2](contracts/Auction.sol#L2)

	pragma solidity ^0.8.19;

- contracts/Listing.sol [Line: 2](contracts/Listing.sol#L2)

	pragma solidity ^0.8.19;

- contracts/NFTRegistry.sol [Line: 2](contracts/NFTRegistry.sol#L2)

	pragma solidity ^0.8.19;

- contracts/Wallet.sol [Line: 2](contracts/Wallet.sol#L2)

	pragma solidity ^0.8.19;

- contracts/libraries/NFTIdentifier.sol [Line: 2](contracts/libraries/NFTIdentifier.sol#L2)

	pragma solidity ^0.8.19;

Score
Recommendation

Outdated compiler version:

Update to the most recent version of Solidity (0.8.24), by changing the pragma as follows:

pragma solidity 0.8.24;

Floating pragma:

Consider using a specific version of Solidity in your contracts instead of a wide version. For example, instead of pragma solidity ^0.8.0;, use pragma solidity 0.8.24;


Remediation Plan

ACKNOWLEDGED : The Haqq team acknowledged this issue.

References

4.6 Duplicate access control mechanism

// Informational

Description

The NFTRegistry contract incorporates both the Ownable pattern and Role-Based Access Control (RBAC) pattern for managing access and permissions. While both systems are robust for access control in their respective contexts, employing them concurrently within the same contract can introduce complexity and confusion.

contract NFTRegistry is INFTRegistry, OwnableUpgradeable, AccessControlUpgradeable {

This overlap may lead to inconsistencies in access control logic, making the contract harder to maintain and potentially increasing the risk of security vulnerabilities. Specifically, the duplicate mechanism could cause confusion regarding which system takes precedence in various situations, complicate the assignment and revocation of roles and ownership, and make it more challenging to audit and reason about the contract's security model.

It is important to mention that, in regular scenarios, the DEFAULT_ADMIN_ROLE is already granted to the deployer address, when dealing with role-based access control default implementation by OpenZeppelin.

Score
Recommendation

Evaluate the requirements regarding access control. If a more granular control is required, then it is recommended to use Role-based access control (RBAC).

On the other hand, if there is no need for granular control over authorizations and there is no need for having different types of roles, then it is advisable to use Ownable2Step.


Remediation Plan

SOLVED : The Haqq team solved this issue in the commit id 1d6c5a0913e01ac2400d574822b9da169a79d50b.

Remediation Hash

4.7 Use custom errors

// Informational

Description

In Solidity smart contract development, replacing hard-coded revert message strings with the Error() syntax is an optimization strategy that can significantly reduce gas costs. Hard-coded strings, stored on the blockchain, increase the size and cost of deploying and executing contracts.

The Error() syntax allows for the definition of reusable, parameterized custom errors, leading to a more efficient use of storage and reduced gas consumption. This approach not only optimizes gas usage during deployment and interaction with the contract but also enhances code maintainability and readability by providing clearer, context-specific error information.

Score
Recommendation

It is recommended to replace hard-coded revert strings in require statements for custom errors, which can be done following the logic below.

1. Standard require statement (to be replaced):

require(condition, "Condition not met");

2. Declare the error definition to state

error ConditionNotMet();

3. As currently is not possible to use custom errors in combination with require statements, the standard syntax is:

if (!condition) revert ConditionNotMet();

More information about this topic in Official Solidity Documentation.


Remediation Plan

ACKNOWLEDGED : The Haqq team acknowledged this issue.

References

4.8 Missing input validations

// Informational

Description

In the ERC721H and ERC1155H contracts, the _setRoyalties function does not validate if the royaltyPercent parameter is below 100. This oversight could potentially allow for setting a royalty percentage that exceeds 100%, which is illogical and could lead to unexpected behavior or exploitation.

    function _setRoyalties(uint256 tokenId, address signer, uint256 royaltyPercent) internal {
        royalties[tokenId] = Royalties({nftCreator: signer, royaltyPercent: royaltyPercent});
    }

In the Listing Contract, the setFixedComPercent function similarly lacks validation for its newFixedComPercent parameter, failing to ensure it is below 100.

- contracts/Listing.sol [Line: 137-139](contracts/Listing.sol#L137-139)

    function setFixedComPercent(uint256 newFixedComPercent) external onlyOwner {
        fixedComPercent = newFixedComPercent;
    }

The same happens within the setAuctionComPercent in Auction contract:

- contracts/Auction.sol [Line: 68-70](contracts/Auction.sol#L68-70)

    function setAuctionComPercent(uint256 newAuctionComPercent) external onlyOwner {
        auctionComPercent = newAuctionComPercent;
    }

This poses a risk of setting a commission percentage that exceeds 100%, leading to impractical or exploitative scenarios.

Additionally, the contracts lack checks for the address(0) (zero address) when assigning values to address state variables. This is found in the ERC1155H and ERC721H contracts. Assigning the zero address to critical state variables can lead to loss of control over these variables or potentially lock assets within the contract without a way to interact with them.

- contracts/tokens/ERC1155H.sol [Line: 41](contracts/tokens/ERC1155H.sol#L41)

	        systemAddress = _systemAddress;

- contracts/tokens/ERC1155H.sol [Line: 87](contracts/tokens/ERC1155H.sol#L87)

	        systemAddress = target;

- contracts/tokens/ERC721H.sol [Line: 43](contracts/tokens/ERC721H.sol#L43)

	        systemAddress = _systemAddress;

- contracts/tokens/ERC721H.sol [Line: 87](contracts/tokens/ERC721H.sol#L87)

	        systemAddress = target;

Score
Recommendation

Royalty percentage validation:

It is crucial to add input validation checks in the _setRoyalties, setFixedComPercent and setAuctionComPercent functions to ensure the percentages passed as parameters do not exceed 100. This can be done by using the following syntax.

Error declaration:

error WrongPercentage(uint256 _percentage);

  1. In ERC721H and ERC1155H contracts - _setRoyalties function:

if (royaltyPercent > 100) revert WrongPercentage(royaltyPercent);

2. In Listing contract - setFixedComPercent function:

if (newFixedComPercent > 100) revert WrongPercentage(newFixedComPercent);

3. In Auction contract - setAuctionComPercent function:

if (newAuctionComPercent > 100) revert WrongPercentage(newAuctionComPercent);

These checks will prevent setting illogical values for royalties and commissions, safeguarding against unintended behavior or misuse.

Validation against the Zero Address:

Before assigning values to address state variables, it is essential to validate that the incoming address is not the zero address. This can prevent scenarios where the contract's functionality is compromised due to the unintentional assignment of critical variables to an unusable address.

Error declaration:

error ZeroAddressNotAllowed();

Implement a check using the following syntax:

if (_systemAddress == address(0)) revert ZeroAddressNotAllowed();

This validation should be added before every assignment operation to address state variables across all the contracts where such assignments occur, particularly in the lines highlighted within the ERC1155H and ERC721H contracts.


Remediation Plan

SOLVED : The Haqq team solved this issue in the commit id 1d6c5a0913e01ac2400d574822b9da169a79d50b.

Remediation Hash

4.9 Avoid using magic numbers

// Informational

Description

During the analysis of the contracts under scope, specifically in Auction and Listing contracts, it was identified the use of magic numbers. This can reduce code readability, as some literals might not be so obvious or intuitive.

- contracts/Auction.sol [Line: 147](contracts/Auction.sol#L147)

	            (nftCreator, royalityPercent) = IERC2981(nftAddress).royaltyInfo(nftID, 100);

- contracts/Listing.sol [Line: 125](contracts/Listing.sol#L125)

	        _saleListingID = 1;

- contracts/Listing.sol [Line: 297](contracts/Listing.sol#L297)

	            list(List.FIXEDSALE, true, nftAddress, nftID, msg.sender, price, 0, expiration, 1);

- contracts/Listing.sol [Line: 299](contracts/Listing.sol#L299)

	            emit ListedFixedSale(nftAddress, nftID, msg.sender, price, expiration, 1, saleListingID);

- contracts/Listing.sol [Line: 388](contracts/Listing.sol#L388)

	            (nftCreator, royalityPercent) = IERC2981(nftAddress).royaltyInfo(nftID, 100);

- contracts/Listing.sol [Line: 476](contracts/Listing.sol#L476)

	            list(List.AUCTIONSALE, true, nftAddress, nftID, msg.sender, minPrice, startTime, endTime, 1);

- contracts/Listing.sol [Line: 478](contracts/Listing.sol#L478)

	            emit ListedAuctionSale(nftAddress, nftID, msg.sender, minPrice, startTime, endTime, 1, saleListingID);

Score
Recommendation

It is recommended to declare constants and use it across the code, instead of literals or "magic numbers", for improved code readability and maintenance.

For example:

uint16 someConstant = 256;


Remediation Plan

ACKNOWLEDGED : The Haqq team acknowledged this issue.

4.10 Modifier `nonReentrant` is misplaced

// Informational

Description

The nonReentrant modifier should be placed before all other modifiers. It is a best practice to prevent reentrancy occurrences in other modifiers.

- contracts/Listing.sol [Line: 345](contracts/Listing.sol#L345)

	    function buyFixedSale(uint256 saleListingID) external payable existingFixedSaleListing(saleListingID) nonReentrant {
Score
Recommendation

It is best practice to place the nonReentrant modifier after all other modifiers. In the specific occurrence, in Listing contract, buyFixedSale function:

	    function buyFixedSale(uint256 saleListingID) external payable nonReentrant existingFixedSaleListing(saleListingID) {

Remediation Plan

SOLVED : The Haqq team solved this issue in the commit id 1d6c5a0913e01ac2400d574822b9da169a79d50b.

Remediation Hash

4.11 Functions not used internally can be marked external

// Informational

Description

Declaring functions as public when they are only meant to be accessed externally results in higher gas costs compared to marking them as external.

This is because public functions generate additional bytecode to handle both internal and external calls, whereas external functions are optimized for calls made from outside the contract. Switching to external to functions that do not require internal access can optimize contract efficiency by reducing gas consumption during execution.


- contracts/Auction.sol [Line: 163](contracts/Auction.sol#L163)

	    function onERC721Received(address, address, uint256, bytes memory) public virtual returns (bytes4) {

- contracts/Auction.sol [Line: 167](contracts/Auction.sol#L167)

	    function onERC1155Received(address, address, uint256, uint256, bytes memory) public virtual returns (bytes4) {

- contracts/Auction.sol [Line: 171](contracts/Auction.sol#L171)

	    function onERC1155BatchReceived(

- contracts/NFTRegistry.sol [Line: 78](contracts/NFTRegistry.sol#L78)

	    function isWhitelisted(address nftAddress, uint256 nftID) public view override returns (bool whitelisted) {

- contracts/helpers/Registry.sol [Line: 82](contracts/helpers/Registry.sol#L82)

	    function getContract(string memory name) public view returns (address) {

- contracts/tokens/ERC1155H.sol [Line: 101](contracts/tokens/ERC1155H.sol#L101)

	    function burn(address account, uint256 tokenId, uint256 value) public {

- contracts/tokens/ERC1155H.sol [Line: 114](contracts/tokens/ERC1155H.sol#L114)

	    function burnBatch(address account, uint256[] memory tokenIds, uint256[] memory values) public {

- contracts/tokens/ERC721H.sol [Line: 108](contracts/tokens/ERC721H.sol#L108)

	    function burn(uint256 tokenId) public {

Score
Recommendation

Functions that are not meant to be accessed internally should be marked as external.


Remediation Plan

SOLVED : The Haqq team solved this issue in the commit id 1d6c5a0913e01ac2400d574822b9da169a79d50b.

Remediation Hash

5. Review Notes

  • Reviewed updated fix for Wrong royalty distribution issue.

  • Updated report name as requested by client.

6. Automated Testing

Introduction

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.


Static code analysis:

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.


slither(01)
slither(02)
slither(03)
slither(04)
slither(05)
slither(06)

The automated test findings were analyzed and classified as false-positives. Therefore, these findings were not included to the final report.


ERC721 Properties tests (fuzzing):

Echidna is a property-based testing tool specifically designed for Ethereum smart contracts, which uses fuzzing to generate a wide range of inputs to test contracts for vulnerabilities and unexpected behavior.

Properties serve as predefined conditions or assertions that an ERC721 compliant contract should satisfy, such as correct ownership tracking, transferability, and adherence to the ERC721 standard's required functionalities.

We used the crytic/properties repository as reference when writing custom fuzzing tests for ERC721H, with a coverage of 19 properties (invariants).

echidna(01)
echidna(02)

After fuzzing ERC721H contract with the specified properties, all the 19 properties passed the fuzzing test, meaning that the ERC721H contract adheres to the EIP721 standard and is an ERC721 compliant contract.

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.

© Halborn 2024. All rights reserved.