Halborn Logo

Protocol Oct 2023 - bitsCrunch


Prepared by:

Halborn Logo

HALBORN

Last Updated 11/12/2024

Date of Engagement by: October 16th, 2023 - November 3rd, 2023

Summary

100% of all REPORTED Findings have been addressed

All findings

6

Critical

0

High

1

Medium

0

Low

1

Informational

4


1. INTRODUCTION

bitsCrunch engaged Halborn to conduct a security assessment on their smart contracts beginning on 2023-10-16 and ending on 2023-11-03. The security assessment was scoped to the smart contracts provided to the Halborn team.

2. ASSESSMENT SUMMARY

The team at Halborn was provided three weeks for the engagement and assigned a full-time security engineer to verify 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 assessment 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 bitsCrunch team.

3. TEST APPROACH & 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)

    • Testnet deployment. (Foundry)

4. SCOPE

IN-SCOPE CODE & COMMITS:\vspace{-8mm}

    • Repository: bitscrunch-protocol/smartcontracts

    • Commit ID: 903651081121334e2ac0f065668cefee79110bb6

    • Smart contracts in scope:

      • contracts/Bitscrunch/*

      • contracts/Epochs/*

      • contracts/GovernanceController/*

      • contracts/Operator/*

      • contracts/RewardManager/*

      • contracts/Staking/*

      • contracts/Token/*

      • contracts/Utils/*

    • Commit ID: 903651081121334e2ac0f065668cefee79110bb6

    • Smart contracts in scope:

    • contracts/Bitscrunch/*

    • contracts/Epochs/*

    • contracts/GovernanceController/*

    • contracts/Operator/*

    • contracts/RewardManager/*

    • contracts/Staking/*

    • contracts/Token/*

    • contracts/Utils/*

    • contracts/Bitscrunch/*

    • contracts/Epochs/*

    • contracts/GovernanceController/*

    • contracts/Operator/*

    • contracts/RewardManager/*

    • contracts/Staking/*

    • contracts/Token/*

    • contracts/Utils/*

OUT-OF-SCOPE:\vspace{-8mm}

    • Economical attacks.

    • Third-party libraries and dependencies.

**REMEDIATION COMMIT ID : **

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

5.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:
EXPLOITABILIY 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

5.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}

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

6. SCOPE

Out-of-Scope: New features/implementations after the remediation commit IDs.

7. Assessment Summary & Findings Overview

Critical

0

High

1

Medium

0

Low

1

Informational

4

Security analysisRisk levelRemediation Date
LOSS OF FUNDS FROM PROTOCOL WHEN SWAPPING STABLECOIN FOR BCUT TOKENSHighSolved - 11/13/2023
BROKEN SCHEME FUNCTIONALITY DUE TO A POTENTIAL DOSLowRisk Accepted - 11/13/2023
2-STEP TRANSFER OWNERSHIP MISSINGInformationalSolved - 11/13/2023
USE OF CUSTOM ERRORS MISSINGInformationalSolved - 11/13/2023
INCONSISTENCY BETWEEN FILE NAME AND CONTRACT NAMEInformationalSolved - 11/13/2023
CACHING LENGTH IN FOR LOOPSInformationalSolved - 11/13/2023

8. Findings & Tech Details

8.1 LOSS OF FUNDS FROM PROTOCOL WHEN SWAPPING STABLECOIN FOR BCUT TOKENS

// High

Description

Within the swapStableToBCUT() function, when the admin is swapping USDC tokens for BCUT tokens by using Polygon Uniswap exchange, the execution of the swap is done by calling the _swapExactInputSingle(address(_stableCoin), address(bcutToken_), toBcut, 1); always specifying the amountOutMinimum as 1 (no matter the amount we are swapping). This can be seen in the _swapExactInputSingle internal function. This makes the function vulnerable to slippage manipulation attacks. An attacker can perform a sandwich attack on the admin swap transaction, making the Bitscrunch contract to receive only a very few amounts of BCUT tokens in return.

Code Location

Payments.sol

function swapStableToBCUT(IERC20Upgradeable _stableCoin) external onlyManager nonReentrant{

    require(coins_[_stableCoin].active, "Pay:ERR1");

    uint currentEpoch = epochManager_.currentEpoch();
    uint256 toSwap = coins_[_stableCoin].billedBalance;

    require(toSwap > 0, "Pay:ERR2");
    //VULN high slippage manipulation in uniswap pool
    uint256 toBcut = _getShareAmount(toSwap, shares_.bcut);
    uint256 bcutReceived = _swapExactInputSingle(address(_stableCoin), address(bcutToken_), toBcut, 1);
    uint256 stableCoinLeft = toSwap - toBcut;
    uint256 toTreasury = _getShareAmount(stableCoinLeft, shares_.treasury);

    balances_.treasury[address(_stableCoin)] += toTreasury;

    balances_.coinBalance[currentEpoch][address(bcutToken_)] += bcutReceived;
    balances_.coinBalance[currentEpoch][address(_stableCoin)] += (stableCoinLeft - toTreasury);

    claimable_[address(this)][address(bcutToken_)].balance += bcutReceived;
    claimable_[address(this)][address(_stableCoin)].balance += (stableCoinLeft - toTreasury);

    coins_[_stableCoin].billedBalance = 0;

    emit Swapped(
        address(_stableCoin), 
        address(bcutToken_), 
        toBcut,
        bcutReceived
    );
}

Swap.sol

function _swapExactInputSingle(address token0, address token1, uint256 amountIn, uint256 amountOutMinimum)
     internal returns (uint256 amountOut) {

    TransferHelper.safeApprove(token0, address(swapRouter), amountIn);

    ISwapRouter.ExactInputSingleParams memory params =
        ISwapRouter.ExactInputSingleParams({
            tokenIn: token0,
            tokenOut: token1,
            fee: poolFee,
            recipient: address(this),
            deadline: block.timestamp,
            amountIn: amountIn,
            amountOutMinimum: amountOutMinimum,
            sqrtPriceLimitX96: 0
        });
    amountOut = swapRouter.exactInputSingle(params);
}
  1. The attacker sees the bitsCrunch swap transaction in the mempool.

  2. The attacker front runs the transaction by swapping a significant amount of USDC tokens for BCUT tokens.

  3. The price of BCUT increased considerably.

  4. bitsCrunch swaps USDC tokens for BCUT tokens at a higher price.

  5. bitsCrunch receives very few BCUT tokens compared to what they should have received.

  6. The attacker does the opposite transaction, swapping BCUT tokens for USDC tokens and also taking profit.

PoC.sol

function testPOC_001() public {
    swapSetup();

    logsInitialState();
    uint256 amountOut = swapBitscrunch(bcutAddr, usdcAddr, 1e18);
    console.log("BCUT PRICE ------------------> ", amountOut);
    swap(usdcAddr, bcutAddr, amountOut, owner);

    logsExpectedOutput();
    uint256 bcutExpected = swapBitscrunch(usdcAddr, bcutAddr, 100_000e6);
    console.log("BCUT EXPECTED ---------------> ", bcutExpected);
    swap(bcutAddr, usdcAddr, bcutExpected, owner);

    // STEP 1
    logsPoc();
    uint256 attackAmount = 1_000_000e6;
    uint256 bobbyAmountOut = swap(usdcAddr, bcutAddr, attackAmount, bobby);
    console.log("AMOUNT OUT ------------------> ", bobbyAmountOut);

    // STEP 2
    console.log("");
    console.log("###########     STEP 2: BITSCRUNCH SWAP     ###########");
    console.log(
        "Owner: TX --> out = bitscrunchFactory._swapExactInputSingle(usdcAddr, bcutAddr, 100_000e6, 1)"
    );
    uint256 bcutActuallyGet = swapBitscrunch(usdcAddr, bcutAddr, 100_000e6);
    console.log("BCUT ACTUALLY GETTING -------> ", bcutActuallyGet);
    uint256 loose = bcutExpected - bcutActuallyGet;
    console.log("LOOSE (EXPECTED - ACTUAL) ---> ", loose);

    // STEP 3
    console.log("");
    console.log("###########     STEP 3: BOBBY PROFITS       ###########");
    console.log(
        "BOBBY: TX --> out = swap(usdcAddr, bcutAddr, bobbyAmountOut(from previous swap))"
    );
    bobbyAmountOut = swap(bcutAddr, usdcAddr, bobbyAmountOut, bobby);
    uint256 profits = bobbyAmountOut - attackAmount;

    console.log("PROFITS ---------------------> ", profits);

    uint256 bitscrunchLossPercent = (bcutExpected * 10000) /
        bcutActuallyGet;
    uint256 attackerProfitPercent = (bobbyAmountOut * 10000) / attackAmount;

    console.log("");
    console.log("");
    console.log("#######################################################");
    console.log("################     POC SUMMARY     ##################");
    console.log("#######################################################");
    console.log("");
    console.log("BITSCRUNCH EXPECTED BCUT ---> ", bcutExpected);
    console.log("BITSCRUNCH ACTUAL BCUT -----> ", bcutActuallyGet);
    console.log("ATACKER USDC BEFORE --------> ", attackAmount);
    console.log("ATACKER USDC AFTER  --------> ", bobbyAmountOut);
}
1.png
BVSS
Recommendation

To solve this issue, calculating the amountOutMinimum of the swap that will be executed by doing it off-chain and using an oracle is recommended. Then use the amount previously calculated to execute the transaction on- chain.

Remediation

SOLVED: The bitsCrunch team solved the issue with the following commit id.

Commit ID: fcf43995a8ecf77586e3814914483469ab6b0f37

8.2 BROKEN SCHEME FUNCTIONALITY DUE TO A POTENTIAL DOS

// Low

Description

The owner of the protocol can create new schemes within the protocol for users to be able to use. There is no maximum amount of schemes that can be created. Thus, over time, or maliciously, the owner can create many schemes that would result in a denial of service due to reaching out of gas transaction, every time the owner needs to create a new one or update an existing one. Both functions (addScheme() and updateSchemeStatus()) call _isSchemeExists() to check if the scheme already exists, but this internal function iterates through the entire list of previously created schemes, consuming a lot of gas.

Code Location

Staking.sol

function addScheme(uint256 _discount, uint256 _stakeAmount) external onlyOwner {
    require((_discount>0 && _stakeAmount>0),"CS:ERR1");
    require(!_isSchemeExists(_discount, _stakeAmount),"CS:ERR2");

    schemeId_.increment();
    uint256 id = schemeId_.current();
    discountSchemes_[id] = Scheme(_discount, _stakeAmount,true);

    emit SchemeAdded(id, _discount, _stakeAmount);

}

Staking.sol

function updateSchemeStatus(uint256 _schemeId, bool _status) external onlyOwner {
    if(_status){
        require((_schemeId>0 && _schemeId<=schemeId_.current()),"CS:ERR3");
        require(!discountSchemes_[_schemeId].active,"CS:ERR4");
        require(!_isSchemeExists(
                discountSchemes_[_schemeId].discountPercent,
                discountSchemes_[_schemeId].stakeAmount),
                "CS:ERR2");

        discountSchemes_[_schemeId].active = true;
    }
    else{
        require((_schemeId>0 && _schemeId<=schemeId_.current()),"CS:ERR3");
        require(discountSchemes_[_schemeId].active,"CS:ERR5");

        discountSchemes_[_schemeId].active = false;
    }

    emit SchemeUpdated(_schemeId, _status);
}

Staking.sol

function _isSchemeExists(uint256 _discount, uint256 _stakeAmount) private view returns(bool){
    bool status = false;
    if(schemeId_.current()>0){
        for(uint256 i=1; i<= schemeId_.current(); i++){
            if(discountSchemes_[i].active && 
                ((discountSchemes_[i].discountPercent == _discount)||
                (discountSchemes_[i].stakeAmount == _stakeAmount)))
                    {
                        status = true;
                        break;
                    }
        }
    }
    return status;
}
BVSS
Recommendation

To avoid this type of issue, setting a cap for the amount of created schemes within the protocol is recommended.

Remediation

RISK ACCEPTED: The bitsCrunch team accepted the risk of the finding.

8.3 2-STEP TRANSFER OWNERSHIP MISSING

// Informational

Description

The code does not implement a two-step ownership transfer pattern. This practice is recommended when admin users have heavy responsibilities, such as the ability to mint tokens, freeze or unfreeze user accounts and set system configurations. It may happen that when transferring ownership of a contract, an error is made in the address. If the request were submitted, the contract would be lost forever. With this pattern, contract owners can submit a transfer request; however, this is not final until accepted by the new owner. If they realize they have made a mistake, they can stop it at any time before accepting it by calling cancelRequest.

Code Location

Controller.sol

function transferOwnership(address newOwner) public onlyOwner {
    _setupRole(DEFAULT_ADMIN_ROLE, newOwner);
    renounceRole(DEFAULT_ADMIN_ROLE, _msgSender());
}
BVSS
Recommendation

It is recommended to implement a two-step process where the owner nominates an account, and the nominated account needs to call an acceptOwnership() function for the transfer of the ownership to succeed fully. This ensures the nominated EOA account is valid and active.

Remediation

SOLVED: The bitsCrunch team solved the issue with the following commit id.

Commit ID: fcf43995a8ecf77586e3814914483469ab6b0f37

8.4 USE OF CUSTOM ERRORS MISSING

// Informational

Description

Failed operations in this contract are reverted with an accompanying message selected from a set of hard-coded strings.

In EVM, emitting a hard-coded string in an error message costs ~50 more gas than emitting a custom error. Additionally, hard-coded strings increase the gas required to deploy the contract.

Code Location

TokenUtils.sol

function pullTokens(
    IERC20Upgradeable _token,
    address _from,
    uint256 _amount
) internal {
    if (_amount > 0) {
        require(_token.transferFrom(_from, address(this), _amount), "Tokens: Couldn't transfer");
    }
}
BVSS
Recommendation

Custom errors are available from Solidity version 0.8.4 up. Consider replacing all revert strings with custom errors.

Remediation

SOLVED: The bitsCrunch team solved the issue with the following commit id.

Commit ID: fcf43995a8ecf77586e3814914483469ab6b0f37

8.5 INCONSISTENCY BETWEEN FILE NAME AND CONTRACT NAME

// Informational

Description

The file name and the contract name within the Solidity file are inconsistent. This can lead to confusion and potential errors when trying to interact with or deploy the contract. In Solidity, it is a best practice to keep the contract name and the file name the same for clarity and ease of management.

Code Location

Staking.sol

contract CustomerStaking is Billing {
BVSS
Recommendation

This is just an example within the code, but many other contracts have the same issue. We strongly recommend renaming either the file or the contract so that they match. If the contract name is CustomerStaking, then the file name should ideally be CustomerStaking.sol.

Remediation

SOLVED: The bitsCrunch team solved the issue with the following commit id.

Commit ID: fcf43995a8ecf77586e3814914483469ab6b0f37

8.6 CACHING LENGTH IN FOR LOOPS

// Informational

Description

In a for loop, the length of an array can be put in a temporary variable to save some gas. This has been done already in several other locations in the code.

In the above case, the solidity compiler will always read the length of the array during each iteration. That is,

  • if it is a storage array, this is an extra sload operation (100 additional extra gas (EIP-2929) for each iteration except for the first),

  • if it is a memory array, this is an extra mload operation (3 additional gas for each iteration except for the first),

  • if it is a calldata array, this is an extra calldataload operation (3 additional gas for each iteration except for the first)

Code Location

Staking.sol

function _isSchemeExists(uint256 _discount, uint256 _stakeAmount) private view returns(bool){
    bool status = false;
    if(schemeId_.current()>0){
        for(uint256 i=1; i<= schemeId_.current(); i++){
            if(discountSchemes_[i].active && 
                ((discountSchemes_[i].discountPercent == _discount)||
                (discountSchemes_[i].stakeAmount == _stakeAmount)))
                    {
                        status = true;
                        break;
                    }
        }
    }
    return status;
}
Score
Recommendation

In a for loop, store the length of an array or the current counter in a temporary variable.

Remediation

SOLVED: The bitsCrunch team solved the issue with the following commit id.

Commit ID: fcf43995a8ecf77586e3814914483469ab6b0f37

9. Automated Testing

STATIC ANALYSIS REPORT

Description

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.

Results

contracts/Bitscrunch/*

contracts/Epochs/*contracts/GovernanceController/*

contracts/Operator/*contracts/RewardManager/*

contracts/Staking/*contracts/Token/*contracts/Utils/*

All the issues flagged by Slither were manually reviewed by Halborn. Reported issues were either considered as false positives or are already included in the report findings.

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.