Halborn Logo

Spokes V1 - Concrete


Prepared by:

Halborn Logo

HALBORN

Last Updated 10/15/2024

Date of Engagement by: August 12th, 2024 - September 10th, 2024

Summary

100% of all REPORTED Findings have been addressed

All findings

24

Critical

1

High

4

Medium

7

Low

4

Informational

8


1. Introduction

Concrete engaged Halborn to conduct a security assessment on their smart contracts beginning on 2024-08-12 and ending on 2024-09-10. The security assessment was scoped to the smart contracts provided in directly be the team.

Commit hashes and further details can be found in the Scope section of this report.

2. Assessment Summary

Halborn was provided four weeks for the engagement and assigned one full-time security engineer to check 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 several security concerns that were mostly addressed by the Concrete team.

3. Test Approach and Methodology

Halborn performed a combination of manual review of the code and automated security testing to balance efficiency, timeliness, practicality, and accuracy in regard to the scope of the smart contract assessment. While manual testing is recommended to uncover flaws in logic, process, and implementation; automated testing techniques help enhance coverage of smart contracts and can quickly identify items that do not follow security best practices. The following phases and associated tools were used throughout the term of the assessment:

    • Research into the architecture, purpose, and use of the platform.

    • Smart contract manual code review and walkthrough to identify any logic issue.

    • Thorough assessment of safety and usage of critical Solidity variables and functions in scope that could lead to arithmetic related vulnerabilities.

    • Manual testing by custom scripts.

    • Graphing out functionality and contract logic/connectivity/functions (solgraph).

    • Static Analysis of security for scoped contract, and imported functions. (Slither).

    • Local or public testnet deployment (Foundry, Remix IDE).

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

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

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

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

5. SCOPE

Files and Repository
(a) Repository: sc_spokes-v1
(b) Assessed Commit ID: 984a7f0
(c) Items in scope:
  • src/libraries/ProtectionViewLib.sol
  • src/userBlueprints/UserRadiantV2.sol
  • src/userBlueprints/UserAaveV3.sol
↓ Expand ↓
Out-of-Scope:
Remediation Commit ID:
Out-of-Scope: New features/implementations after the remediation commit IDs.

6. Assessment Summary & Findings Overview

Critical

1

High

4

Medium

7

Low

4

Informational

8

Security analysisRisk levelRemediation Date
Incorrect Function Call Leads to Loss Of Claim Router AssetsCriticalSolved - 09/23/2024
Lack Of Protection Policy ValidationHighSolved - 09/23/2024
Incorrect Protection Period Checks May Deny Valid ClaimsHighSolved - 09/23/2024
Incorrect Borrowing Modality Leads to Broken FunctionalityHighSolved - 09/23/2024
Protection Claim After Lender-Side Liquidation Causes Funds To Be LockedHighSolved - 09/23/2024
On-Chain Slippage Calculation Can Be ManipulatedMediumRisk Accepted - 09/23/2024
Incorrect Modality Usage Results In Transaction RevertsMediumSolved - 09/23/2024
Remote Protocol Proxies Unable To Execute Core FunctionsMediumSolved - 09/23/2024
Lack Of Chainlink's Stale Price ChecksMediumSolved - 09/23/2024
Broken Functionality Of RemoteProtocolProxy Due To Access ControlMediumSolved - 09/23/2024
Incorrect Calculation Order In Credit UpdateMediumSolved - 09/24/2024
Token Transfer Reverts Leading to Blocked ForeclosureMediumSolved - 09/24/2024
Unsafe use of transfer()/transferFrom() with IERC20LowSolved - 09/26/2024
Insolvent Position Cannot Be LiquidatedLowSolved - 09/24/2024
Usage Of Direct Approve CallsLowSolved - 09/24/2024
Usage Of Single-Step Role TransferLowSolved - 09/24/2024
Unlocked Pragma CompilersInformationalSolved - 09/26/2024
Consider Using Named MappingsInformationalSolved - 09/24/2024
Unused Imports, Errors and EventsInformationalSolved - 09/26/2024
Commented Out CodeInformationalSolved - 09/24/2024
Excess Gas Consumption Due to Redundant Token TransfersInformationalAcknowledged
Lack Of Event EmissionInformationalSolved - 09/24/2024
Inconsistent Naming Convention In ProtectionLibV1 For Value UnitsInformationalSolved - 09/25/2024
Unused Parameter in claimProtection FunctionInformationalAcknowledged

7. Findings & Tech Details

7.1 Incorrect Function Call Leads to Loss Of Claim Router Assets

// Critical

Description

The FundsRequesterConnector contract is designed to manage interactions with the funds' requester. It includes the _repayTokenToClaimRouter functions used for handing foreclosure and debt repayment processes.

    function _repayTokenToClaimRouter(address asset, uint256 amount) internal {
        address claimRouter = REMOTE_REGISTRY.getEarnClaimRouter();
        _increaseAllowanceIfNecessary(claimRouter, asset, amount);
        FUNDS_REQUESTER.requestTokenFromClaimRouter(asset, amount);
    }

    function _repayTokenToClaimRouter(address claimRouter, address asset, uint256 amount) internal {
        _increaseAllowanceIfNecessary(claimRouter, asset, amount);
        FUNDS_REQUESTER.requestTokenFromClaimRouter(asset, amount);
    }

However, instead of correctly invoking the repayTokenToClaimRouter, the contract mistakenly calls requestTokenFromClaimRouter in its logic. It leads to a situation (presented in PoC) where users can exploit the protocol by repaying their debt and, instead of decreasing their balance, they receive additional tokens. This allows them to siphon off more assets than they initially deposited, resulting in significant financial losses for the system.

Proof of Concept
    function test_incorrectFunctionCall() external { 
        uint256 supplyAmount = 1000 ether;
        uint256 borrowAmount = 1_500_000 * 10 ** 6;

        address userBlueprint = _createAaveV3UserBlueprint();
        weth.mint(alice, supplyAmount + 30 ether);
        vm.prank(alice);
        weth.approve(userBlueprint, supplyAmount + 30 ether);
        
        (uint256 encodedProtectionInfo, uint256 promisedAmountInCollateral) = _createConcreteProtectionEncoding(supplyAmount, borrowAmount);

        vm.startPrank(address(cccm));
            //User supplies WETH and borrows USDC
            IUserIntervention(userBlueprint).supply(supplyAmount, 0);
            IUserIntervention(userBlueprint).setBorrowToken(address(usdc));
            IUserIntervention(userBlueprint).borrow(borrowAmount, 0);

            //User opens a protection policy
            IProtocolIntervention(userBlueprint).openProtection(
                encodedProtectionInfo, concreteForeclosureFeeDebtFractionInWad, promisedAmountInCollateral
            );
            uint256 protectionAmount =
            promisedAmountInCollateral.mulDiv(conreteClaimOneAmountAsPromisedAmountFractionInWad, WAD);
            assertEq(protectionAmount, 30 ether);
            
            //User claims protection, which increases their debt by the protection amount
            IProtocolIntervention(userBlueprint).claimProtection(protectionAmount, 0, address(0));
            uint userConcreteDebt = IViewUserState(userBlueprint).viewConcreteDebtOfUser();
            assertEq(userConcreteDebt, 30 ether);

            uint256 supplyBeforeRepay = IViewUserState(userBlueprint).getLenderSuppliedWithInterest();

            //User repays their concrete debt of 30 WETH
            IReclaimRepayCancel(userBlueprint).repayConcreteDebt(userConcreteDebt, false, Uint8CodecLib.encodeModeAndIssuer(0, 0));

            // User supplies the outstanding 60 WETH balance to the lender
            assertEq(weth.balanceOf(address(userBlueprint)), 60 ether);
            IUserIntervention(userBlueprint).supply(60 ether, 2);
        vm.stopPrank();

        //Concrete debt is now 0 and the supply has increased by 60 WETH
        assertEq(IViewUserState(userBlueprint).viewConcreteDebtOfUser(), 0);
        uint256 supplyAfterRepay = IViewUserState(userBlueprint).getLenderSuppliedWithInterest();
        
        assertEq(supplyAfterRepay, supplyBeforeRepay + 60 ether);        
    }

BVSS
Recommendation

Ensure that the FundsRequesterConnector contract correctly invokes the repayTokenToClaimRouter method in the mentioned functions. 

Remediation

SOLVED: The Concrete team fixed the issue as recommended.

Remediation Hash
References

7.2 Lack Of Protection Policy Validation

// High

Description

When the CCCM begins the process of opening a protection policy, the claimProtection function requires the encoded protection information.

The protection policy data is initialized by encoding these parameters using the encodeProtectionDataFromAbsoluteValues method provided in the ProtectionLibV1 library. This data, along with the promised protection amount is then used to open the protection policy for a given user.

        ProtectionDataAbsolute memory protectionDataAbsolute = ProtectionDataAbsolute({
            endTime: uint40(block.timestamp + DURATION),
            numberOfTranches: 3,
            protocolRights: 0,
            remoteProxyRights: 0,
            remoteConcreteerRights: 0,
            remotePublicRights: 0,
            ltvProtectForClaimsInBP: concreteClaimBufferInBP,
            ltvProtectForForeclosureInBP: concreteForeclosureBufferInBP,
            openingFeeInBase: promisedAmountInCollateral.mulDiv(concreteOpeningFeePromisedAmoiuntFractionInWad, WAD),
            cancellationFeeInBase: promisedAmountInCollateral.mulDiv(
                concreteCancellationFeePromisedAmountFractionInWad, WAD
            ),
            trancheAmountInBase: trancheAmountInCollateral,
            trancheFeeInBase: trancheFeeInCollateral
        });

        encodedProtectionData =
            ProtectionLibV1.encodeProtectionDataFromAbsoluteValues(promisedAmountInCollateral, protectionDataAbsolute);
    }

However, when the policy is claimed, no validation is performed to ensure that the claim adheres to the encoded protection data. Additionally, the protection system is designed to ensure that claims are made within a specified buffer from the current LTV position, but this check is also not enforced. This can lead to financial losses due to unauthorized claims, protection being claimed outside the intended LTV buffer, protection claims being too large or executed outside the intended timeframe, and a decrease in protocol integrity.

Proof of Concept
    function test_ProtectionDataNotChecked() external {
        uint256 supplyAmount = 1000 ether;
        uint256 borrowAmount = 1_500_000 * 10 ** 6;

        address userBlueprint = _createAaveV3UserBlueprint();
        weth.mint(alice, supplyAmount);
        vm.startPrank(alice);
            weth.approve(userBlueprint, supplyAmount);
            usdc.approve(userBlueprint, type(uint256).max);
        vm.stopPrank();
        
        (uint256 encodedProtectionInfo, uint256 promisedAmountInCollateral) = _createConcreteProtectionEncoding(supplyAmount, borrowAmount);

        vm.startPrank(address(cccm));
            //User supply WETH and borrow USDC
            IUserIntervention(userBlueprint).supply(supplyAmount, 0);
            IUserIntervention(userBlueprint).setBorrowToken(address(usdc));
            IUserIntervention(userBlueprint).borrow(borrowAmount, 0);

            //User opens protection policy
            IProtocolIntervention(userBlueprint).openProtection(
                encodedProtectionInfo, concreteForeclosureFeeDebtFractionInWad, promisedAmountInCollateral
            );
            uint256 protectionAmount =
            promisedAmountInCollateral.mulDiv(conreteClaimOneAmountAsPromisedAmountFractionInWad, WAD);
            uint256 protectionFee = promisedAmountInCollateral.mulDiv(conreteClaimOneFeeAsPromisedAmountFractionInWad, WAD);
            assertEq(protectionAmount, 30 ether);
            
            //Duration is set to 30 days
            vm.warp(ProtectionLibV1.getEndTime(encodedProtectionInfo) + 100 days);

            //Number of tranches is set to 3
            for(uint i=0; i < 15; i++){
                IProtocolIntervention(userBlueprint).claimProtection(999 ether, 555 ether, address(0));
        }
        vm.stopPrank();
    }

BVSS
Recommendation

Implement comprehensive validation checks against the protection policy data to ensure that all claims adhere to the encoded parameters, including verification of the LTV buffer, the number of tranches, and the claim amounts and timeframe.

Remediation

SOLVED: The Concrete team solved the issue. Additional checks were added to the protocol.

  • repayTotalDebtAndCancel: Uses a policy cancellation fee if the issuer is 0, otherwise checks if the cancellation fee is within bounds using ProtectionViewLibV1.checkAmountsFromInfo.

  • cancelPolicy: Follows the same approach as repayTotalDebtAndCancel.

  • reclaimDebtOrForeclose: A new function for hub contracts that checks ProtectionViewLibV1.surpassCriticalLtvAfterWithdraw and either calls reclaimDebt or foreclose, ensuring foreclosure fees are within bounds via ProtectionViewLibV1.checkForeclosureFeeAmount.

  • claimProtection: Verifies with QueryInterventionV1.canClaimProtection and ensures claim amounts and fees are within bounds using ProtectionViewLibV1.checkAmountsFromInfo.

  • foreclose: First checks QueryInterventionV1.isLiteForeclosableFromInfo - if not, checks if it is seizable by claims using QueryInterventionV1.isForeclosableByClaims, or by expiration via reclaimDebtOrForeclose, ensuring fees are checked with ProtectionViewLibV1.checkForeclosureFeeAmount.

  • openProtection: Ensures the borrow token is set, validates protection data consistency using ProtectionLibV1.validateEncodedProtection, and checks if the promised amount exceeds limits using a formula from PolicyInsightsLibV1.getLowerBoundForMaxPromisedAmountFromInfo.

Remediation Hash

7.3 Incorrect Protection Period Checks May Deny Valid Claims

// High

Description

The canClaimProtection function from UserBase contract is designed to determine whether a user can claim protection under a specific Blueprint. This function evaluates the eligibility based on several parameters, including the end time of the protection, the number of claims already made, the number of available tranches and LTV ratio. 

    function canClaimProtection() external view override(IViewUserState) returns (bool isClaimable) {
        uint256 protectionInfo = _getProtectionInfo();
        if (protectionInfo == 0) return false;
        uint256 liqLtvInWad = _getLiquidationLtvInWad();
        isClaimable = QueryInterventionV1.isClaimable(
            protectionInfo.getEndTime() <= block.timestamp,
            protectionInfo.getNumberOfClaims(),
            protectionInfo.getNumberOfTranches(),
            _getCurrentLtvInWad(),
            liqLtvInWad - protectionInfo.getLtvProtectBufferForClaimsInWad().mulDiv(liqLtvInWad, WAD)
        );
    }

However, the condition protectionInfo.getEndTime() <= block.timestamp, used to check whether the protection period has expired, is inverted. It checks if the protection end time is less than or equal to the current timestamp and returns true if the protection period has already expired. This is in contrary to the intended purpose of verifying whether protection is still active.

    function isClaimable(
        bool isProtected,
        uint8 numberOfClaimsUsed,
        uint8 availableClaims,
        uint256 currentLtvInWad,
        uint256 foreclosureThresholdInWad
    ) internal pure returns (bool) {
        return _isClaimablePrimitive(
            isProtected,
            numberOfClaimsUsed >= availableClaims, // not used up all claims
            currentLtvInWad >= foreclosureThresholdInWad
        ); //
    }
    function _isClaimablePrimitive(bool isProtected, bool hasUsedUpAllClaims, bool crossLtvProtect)
        private
        pure
        returns (bool)
    {
        return isProtected && !hasUsedUpAllClaims && crossLtvProtect;
    }

The same logic is used in the claimProtection function defined in the UserBaseFull contract. As the canClaimProtection function will return false when the position is in the actual protection period, this can result in legitimate claims being incorrectly denied, undermining the functionality of the protection mechanism.

Proof of Concept
    function test_canClaimProtectionFails() external {
        address userBlueprint = _createAaveV3UserBlueprint();
        // how much the user supplies and borrows
        uint256 supplyAmount = 1000 ether;
        uint256 borrowAmount = 1_500_000 * 10 ** 6;
        (uint256 encodedProtectionInfo, uint256 promisedAmountInCollateral) =
            _createConcreteProtectionEncoding(supplyAmount, borrowAmount);
        
        weth.mint(alice, supplyAmount);
        vm.prank(alice);
        weth.approve(userBlueprint, supplyAmount);
        
        vm.startPrank(address(cccm));
            // Supply WETH as a collateral and borrow USDC tokens
            IUserIntervention(userBlueprint).supply(supplyAmount, 0);
            IUserIntervention(userBlueprint).setBorrowToken(address(usdc));
            IUserIntervention(userBlueprint).borrow(borrowAmount, 0);
            
            //Open a protection policy
            IProtocolIntervention(userBlueprint).openProtection(
                encodedProtectionInfo, concreteForeclosureFeeDebtFractionInWad, promisedAmountInCollateral
            );
            //Simulate a price drop of 10% 
            uint256 newEthPrice = initialEthPriceInBaseUnits.mulDiv(90, 100);
            _updatePrice(aaveSetup.priceOracle, address(weth), newEthPrice);

            //The current LTV is greater than or equal to the liquidation LTV minus a buffer
            uint256 currentLTV = IViewUserState(userBlueprint).getCurrentLtvInWad();
            uint256 liquidationLTV = IViewUserState(userBlueprint).getLiquidationLtvInWad();
            assertGe(currentLTV, liquidationLTV - (liteForeclosureThresholdBufferInWad * liquidationLTV / 1e18));

            //The canClaimProtection indicate that user still cannot claim protection.
            assertEq(IViewUserState(userBlueprint).canClaimProtection(), false);

        vm.stopPrank();
    }   

BVSS
Recommendation

Correct the logic in the canClaimProtection function by replacing protectionInfo.getEndTime() <= block.timestamp with protectionInfo.getEndTime() >= block.timestamp to ensure valid claims during the protection period are not mistakenly denied.

Remediation

SOLVED: The Concrete team fixed the issue as recommended. The direction of the inequality has been changed from <= to >=. The canClaimProtection function is now a library function inside the QueryInterventionV1 library.

Remediation Hash
References

7.4 Incorrect Borrowing Modality Leads to Broken Functionality

// High

Description

The supplyBorrowAndProtect function facilitates supplying liquidity, borrowing, and opening a protection policy within a single transaction. It internally calls the _openProtectionPolicy function, passing the provided parameters.

    function supplyBorrowAndProtect(
        uint256 supplyAmount_,
        uint256 borrowAmount_,
        address borrowToken_,
        uint256 protectionInfo_,
        uint256 protectionForeclosureFeeFractionInWad_,
        uint256 promisedAmountInToken_
    ) external virtual override(IProtocolIntervention) {
        bytes32 previousBorrowTokenInfo = _borrowTokenInfo;
        bytes32 collateralTokenInfo = _collateralTokenInfo;
        bool borrowTokenNeedsToBeSet = previousBorrowTokenInfo.getAddress() == address(0);
        if (borrowTokenNeedsToBeSet) {
            // set Borrow token
            _lenderSpecificAssetValidation(collateralTokenInfo.getAddress(), borrowToken_);
            _setBorrowTokenInfo(borrowToken_);
        } else if (previousBorrowTokenInfo.getAddress() != borrowToken_) {
            revert Errors.AssetDivergence();
        }

        bool callerIsProtocol = _callerIsEndpoint();
        bool mayOverwriteExistingProtection = _callerIsRemoteProtocolProxy() || callerIsProtocol;
        if (_getProtectionInfo() != 0 && !mayOverwriteExistingProtection) {
            revert Errors.ProtectionAlreadyOpened();
        }

        if (callerIsProtocol) {
            if (borrowTokenNeedsToBeSet) {
                previousBorrowTokenInfo = _borrowTokenInfo;
            }
            _openProtectionPolicy(
                protectionInfo_,
                protectionForeclosureFeeFractionInWad_,
                promisedAmountInToken_,
                supplyAmount_,
                borrowAmount_,
                collateralTokenInfo,
                previousBorrowTokenInfo
            );
        }

As the borrowAmountInToken was set, the following branch will be executed:

        if (borrowAmountInToken > 0 && borrowTokenInfo.getAddress() != address(0)) {
            newCreditInfo = _updateUserFractions(
                newCreditInfo, _getLenderDebt(false), borrowAmountInToken, PositionInteractionType.Borrow, true
            );

            _borrow(
                borrowTokenInfo.getAddress(),
                borrowAmountInToken,
                Uint8CodecLib.encodeOnlyMode(uint8(SendFundsModality.ONLY_SECOND_STEP))
            );
        }

However, since the ONLY_SECOND_STEP modality in the _borrow function only transfers the provided amount of tokens to the owner, the function will revert because the funds were not sent to the Blueprint beforehand. This will cause the transaction to fail unexpectedly, preventing the operation from executing successfully.

Proof of Concept
    function test_incorrectModalityBorrow() public {
        address userBlueprint = _createAaveV3UserBlueprint();  
        
        uint256 supplyAmount = 1000 ether;
        uint256 borrowAmount = 1_500_000 * 10 ** 6;

        weth.mint(alice, supplyAmount + 0.5 ether);
        vm.startPrank(alice);
        weth.approve(userBlueprint, supplyAmount + 0.5 ether);
        vm.stopPrank();

        vm.startPrank(address(cccm));

        (uint256 encodedProtectionInfo, uint256 promisedAmountInCollateral) =
            _createConcreteProtectionEncoding(1 ether, 1500e6);

        vm.startPrank(address(cccm));

        //User supplies 1 WETH, and intends to borrow 1500 USDC
        //Transaction reverts due to insufficient funds
        vm.expectRevert();
        IProtocolIntervention(userBlueprint).supplyBorrowAndProtect(1 ether, 1500e6, address(usdc),
            encodedProtectionInfo, concreteForeclosureFeeDebtFractionInWad, promisedAmountInCollateral
        );
    }

BVSS
Recommendation

Instead of using the ONLY_SECOND_STEP modality, consider using the ONLY_FIRST_STEP modality to borrow assets against the previously supplied collateral.

Remediation

SOLVED: The Concrete team solved the issue. The functions supplyAndProtect and supplyBorrowAndProtect were removed from the User Blueprint Base contract.

Remediation Hash
References

7.5 Protection Claim After Lender-Side Liquidation Causes Funds To Be Locked

// High

Description

The protocol allows users to open a protection policy for their positions to safeguard against risks that leads to position liquidation.

If the user anticipates issues with their position (such as market volatility), they can claim protection funds by calling the claimProtection function, provided the protection policy was opened beforehand. This function deducts fees, updates the user's credit position, and supplies the remaining credit to the lender. Once the protection is claimed, the protocol records the updated credit information to reflect the newly injected funds.

If a position falls into default, the protocol provides a foreclosure mechanism, allowing the system to liquidate the user's position and settle any outstanding debts. The foreclosure logic depends on the credit and collateral amounts in the user’s position.

However, when a user’s position is liquidated on the lender side the protocol still allows the user to claim protection, even though the position has already been liquidated. This creates a situation where the injected protection funds become trapped within the contract. Since the foreclosure process cannot handle this scenario, the position becomes unrecoverable, leading to assets being stuck in the protocol.

Proof of Concept
    function test_positionCannotBeForeclosed() public { 
        address userBlueprint = _createAaveV3UserBlueprint();

        uint256 supplyAmount = 1000 ether;
        uint256 borrowAmount = 1_500_000 * 10 ** 6;
        uint256 promisedAmountInCollateral;
        uint256 encodedProtectionInfo;
        (encodedProtectionInfo, promisedAmountInCollateral) = _createConcreteProtectionEncoding(supplyAmount, borrowAmount);
        assertEq(promisedAmountInCollateral, 100 ether);

        weth.mint(alice, supplyAmount);
        vm.prank(alice);
        weth.approve(userBlueprint, supplyAmount);

        vm.startPrank(address(cccm));
            //User supplies WETH collateral and borrow USDC
            IUserIntervention(userBlueprint).supply(supplyAmount, 0);
            IUserIntervention(userBlueprint).setBorrowToken(address(usdc));
            IUserIntervention(userBlueprint).borrow(borrowAmount, 0);
        
            //Protection policy is opened
            IProtocolIntervention(userBlueprint).openProtection(
            encodedProtectionInfo, concreteForeclosureFeeDebtFractionInWad, promisedAmountInCollateral);

            //User claims protection but their position gets liquidated in the lender right before
            IPool(aaveSetup.pool).setUserCollateralBalance(address(userBlueprint), 0);
            assertEq(IViewUserState(userBlueprint).getLenderSuppliedWithInterest(), 0);

            //User claims protection
            IProtocolIntervention(userBlueprint).claimProtection(20 ether, 0, address(0));
            assertEq(IViewUserState(userBlueprint).getLenderSuppliedWithInterest(),20 ether);

            //Position cannot be liquidated
            vm.expectRevert();
            IProtocolIntervention(userBlueprint).foreclose(0, address(0));
        vm.stopPrank();
    }

BVSS
Recommendation
  • Update the foreclosure logic to properly handle cases where protection claims are made after a position has been liquidated on the lender side. 

  • Prevent users from claiming protection if their position has already been liquidated.

  • Introduce a mechanism to allow recovery of protection funds injected into liquidated positions.

Remediation

SOLVED: The Concrete team solved the issue. The function reclaimDebtOrForeclose was added to the User Base. It has two branches. Either the policy is expired, in which case the foreclosureFeeFraction is passed in the arguments and used after a check that it is within the bounds of the policy. Alternatively, the policy is not expired. This branch will be executed when funds are locked because of a lender side liquidation.

Remediation Hash

7.6 On-Chain Slippage Calculation Can Be Manipulated

// Medium

Description

The _swap function defined in the SwapperUniV3 contract is designed to swap tokens for a given amount and ensure that the swap does not exceed a specified slippage. It is used during the foreclosure process, to swap the collateral to the borrowed asset. 

The function transfers the input tokens from the sender, approves them for the swap router, and determines the best possible path and fees for the swap using the _getPath function.

    function _swap(address fromToken, address toToken, uint256 amount, uint256 amountOutMin)
        internal
        override
        returns (uint256 swappedAmount)
    {
        IERC20(fromToken).transferFrom(msg.sender, address(this), amount);
        IERC20(fromToken).approve(address(SWAP_ROUTER), amount);
        // TransferHelper.safeTransferFrom(fromToken, msg.sender, address(this), amount);
        // TransferHelper.safeApprove(fromToken, address(SWAP_ROUTER), amount);

        (bytes memory path, uint24 bestFee, uint256 bestAmountOut) = _getPath(fromToken, toToken, amount);
        if (amountOutMin == 0) {
            amountOutMin = bestAmountOut.mulDiv(WAD - MAX_SLIPPAGE_IN_WAD, WAD); // consider 0.5% slippage
        }

The amountOutMin value is determined by fetching the best possible output amount via the _getBestFee method. The slippage is then calculated based on this value.

    function _getPath(address fromToken, address toToken, uint256 amount)
        internal
        returns (bytes memory, uint24, uint256)
    {
        (uint24 bestFee0, uint256 bestAmountOut0) = _getBestFee(fromToken, toToken, amount);
        if (bestAmountOut0 > 0) {
            return ("", bestFee0, bestAmountOut0);
        }
        // If no direct swap is possible, try to find a path with WETH
        (uint24 bestFee1, uint256 bestAmountOut1) = _getBestFee(fromToken, WETH, amount);
        (uint24 bestFee2, uint256 bestAmountOut2) = _getBestFee(WETH, toToken, bestAmountOut1);
        if (bestAmountOut1 == 0 || bestAmountOut2 == 0) {
            revert Errors.NoPathFound();
        }
        bytes memory path = abi.encodePacked(fromToken, bestFee1, WETH, bestFee2, toToken);
        return (path, bestFee2, bestAmountOut2);
    }

This code attempts to perform on-chain slippage calculations using Quoter.quoteExactInputSingle. However, this approach is prone to manipulations, because quoteExactInputSingle simulates a swap on-chain, making it vulnerable to sandwich attacks. Attackers can manipulate the price by front-running the transaction, leading to inaccurate slippage calculations. Additionally, the Quoter natspec states that these functions are not gas efficient and should not be called on-chain, which makes the current implementation not only insecure but also inefficient.

    function _getBestFee(address tokenA, address tokenB, uint256 amount)
        internal
        returns (uint24 bestFee, uint256 bestAmountOut)
    {
        for (uint8 i; i < feeTiers.length;) {
            if (hasPool(tokenA, tokenB, feeTiers[i])) {
                uint256 amountOut = QUOTER.quoteExactInputSingle(tokenA, tokenB, feeTiers[i], amount, 0);
                if (amountOut > bestAmountOut) {
                    bestFee = feeTiers[i];
                    bestAmountOut = amountOut;
                }
            }
            unchecked {
                ++i;
            }
        }
    }
BVSS
Recommendation

Consider implementing an off-chain price feed for slippage checks to minimize the risk of manipulation during swaps.

Remediation

RISK ACCEPTED: The Concrete team accepted the risk.

References

7.7 Incorrect Modality Usage Results In Transaction Reverts

// Medium

Description

The supplyAndProtect function allows for supplying liquidity and opening the protection policy in a single transaction. It calls the internal _openProtectionPolicy function, passing the provided data. The _openProtectionPolicy calculates the opening fee based on the data passed in protectionInfo and checks if it’s greater than the supplied amount.

        // calculate the openingFee in Token
        uint256 openingFeeInToken = protectionInfo.getOpeningFeeFractionInWad().mulDiv(promisedAmountInCollateral, WAD);

        // get treatury and split
        (address claimRouter, bytes32 TreasuryAndRevenueSplit) =
            REMOTE_REGISTRY.getClaimRouterAndEncodedTreasuryAndRevenueSplitInWAD();

        TempAddresses memory tempAddresses;
        tempAddresses.claimRouter = claimRouter;
        tempAddresses.treasury = TreasuryAndRevenueSplit.getAddress();

        // take from the lender supplied collateral
        bool withdrawOpeningFeeFromLender = supplyAmountInToken < openingFeeInToken;

If it's greater, the following branch will be executed:

            } else {
                // fee will be deducted in the next step, but book-keeping will be done here
                newCreditInfo = _updateUserFractions(
                    newCreditInfo, totalSuppliedBefore, supplyAmountInToken, PositionInteractionType.Supply, true
                );

                // supply the collateral
                _supply(
                    collateralToken,
                    supplyAmountInToken,
                    Uint8CodecLib.encodeOnlyMode(uint8(SendFundsModality.ONLY_SECOND_STEP))
                );
            }

However, since the ONLY_SECOND_STEP modality in the _supply function only approves the provided amount and supplies it to the lender, this function will revert because the funds were not sent beforehand. This will result in the transaction failing unexpectedly, preventing the operation from being successfully executed.

Proof of Concept
    function test_incorrectModality() public {
        address userBlueprint = _createAaveV3UserBlueprint();  
        
        uint256 supplyAmount = 1000 ether;
        uint256 borrowAmount = 1_500_000 * 10 ** 6;

        weth.mint(alice, supplyAmount + 0.5 ether);
        vm.startPrank(alice);
        weth.approve(userBlueprint, supplyAmount + 0.5 ether);
        vm.stopPrank();


        //User supplies WETH collateral and borrow USDC
        vm.startPrank(address(cccm));
            IUserIntervention(userBlueprint).supply(supplyAmount, 0);
            IUserIntervention(userBlueprint).setBorrowToken(address(usdc));
            IUserIntervention(userBlueprint).borrow(borrowAmount, 0);
        vm.stopPrank();

            (uint256 encodedProtectionInfo, uint256 promisedAmountInCollateral) =
                _createConcreteProtectionEncoding(supplyAmount, borrowAmount);

        vm.startPrank(address(cccm));
            //User supplies only 0.5 WETH, which is less than the required fee
            //Transaction reverts due to insufficient funds
            vm.expectRevert();
            IProtocolIntervention(userBlueprint).supplyAndProtect(0.5 ether, 
                encodedProtectionInfo, concreteForeclosureFeeDebtFractionInWad, promisedAmountInCollateral
            );
    }

BVSS
Recommendation

Instead of using the ONLY_SECOND_STEP modality, consider using the SEND_THROUGH modality for the described case. If withdrawOpeningFeeFromLender is true, the function will withdraw the opening fee using the updated balance.

Remediation

SOLVED: The Concrete team solved the issue. The function supplyAndProtect was removed from the User Blueprint Base contract.

Remediation Hash
References

7.8 Remote Protocol Proxies Unable To Execute Core Functions

// Medium

Description

The openProtection, SupplyAndProtect, and SupplyBorrowAndProtect functions from the UserBase contract utilize similar logic to validate whether the caller is authorized to perform the intended operation. The logic begins by checking if the caller is the protocol endpoint using _callerIsEndpoint method. If this check is true, it sets the callerIsProtocol flag. Additionally, the logic checks if the caller is a remote protocol proxy using callerIsRemoteProtocolProxy and combines these two checks to determine if the existing protection can be overwritten.

        bool callerIsProtocol = _callerIsEndpoint();
        bool mayOverwriteExistingProtection = _callerIsRemoteProtocolProxy() || callerIsProtocol;
        if (_getProtectionInfo() != 0 && !mayOverwriteExistingProtection) {
            revert Errors.ProtectionAlreadyOpened();
        }
        if (callerIsProtocol) {
	        LOGIC HERE
            );
        } else {
            revert Errors.NotProtocolAndNotIntervenable();
        }

However, the logic incorrectly restricts the execution of the function's core logic to only when the caller is the protocol endpoint. If the caller is a valid remote proxy, the function will incorrectly prevent the remote proxy from executing its intended functionality, causing disruptions or unintended denials of service.

Proof of Concept
    function test_remoteProtocolProxyReverts() external {
        address userBlueprint = _createAaveV3UserBlueprint();
        vm.prank(address(cccm));
            IUserIntervention(userBlueprint).setBorrowToken(address(usdc));
        uint256 supplyAmount = 1000 ether;
        uint256 borrowAmount = 1_500_000 * 10 ** 6;
        (uint256 encodedProtectionInfo, uint256 promisedAmountInCollateral) =
            _createConcreteProtectionEncoding(supplyAmount, borrowAmount);

        //Expect a revert if the remote proxy is the caller
        vm.prank(address(remoteProtocolProxy));
            vm.expectRevert(Errors.NotProtocolAndNotIntervenable.selector);
            IProtocolIntervention(userBlueprint).openProtection(
                encodedProtectionInfo, concreteForeclosureFeeDebtFractionInWad, promisedAmountInCollateral);

        vm.prank(address(remoteProtocolProxy));
            vm.expectRevert(Errors.NotProtocolAndNotIntervenable.selector);
            IProtocolIntervention(userBlueprint).supplyAndProtect(
                supplyAmount, encodedProtectionInfo, liteForeclosureFeeFractionInWad, promisedAmountInCollateral);

        vm.prank(address(remoteProtocolProxy));
            vm.expectRevert(Errors.NotProtocolAndNotIntervenable.selector);
            IProtocolIntervention(userBlueprint).supplyBorrowAndProtect(
                supplyAmount, borrowAmount, address(usdc), encodedProtectionInfo, liteForeclosureFeeFractionInWad, promisedAmountInCollateral);
    }

BVSS
Recommendation

Modify the access control logic to ensure that both the protocol endpoint and the remote proxy are allowed to execute the functions. Implement a function that allows the Remote Proxy to claim rewards as intended by the logic of the claimRewards function.

Remediation

SOLVED: The Concrete team solved the issue. The mentioned logic was removed from the functions.

Remediation Hash
References

7.9 Lack Of Chainlink's Stale Price Checks

// Medium

Description

The ChainlinkPriceProvider contract uses Chainlink feed as their price oracle. When requesting the price via the _getPrice() function, the contracts query Chainlink for the underlying token price using the latestRoundData() function. This function returns uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt and uint80 answeredInRound.

  • roundId denotes the identifier of the most recent update round,

  • answer is the price of the asset,

  • startedAt is the timestamp at which the round started.

  • updatedAt is the timestamp at which the feed was updated,

  • answeredInRound is already deprecated by Chainlink.


    function _getPrice(address priceFeedAddress) internal view override returns (uint256) { 
        (, int256 price,,,) = AggregatorV3Interface(priceFeedAddress).latestRoundData();
        return uint256(price);
    }

The getPrice() function does not check if the feed was updated at the most recent round nor does it verify that startedAt (timestamp at which the round started) is not 0, and this can result in accepting stale data which may threaten the stability of the protocol in a volatile market.

BVSS
Recommendation

Modify the function to utilize Chainlink's latestRoundData. This approach provides detailed information about the most recent price round, ensuring that retrieved the price remains up-to-date. Also, consider saving all used the Chainlink aggregators into a mapping and using different grace period for different heartbeats.

Example implementation:

uint256 private constant GRACE_PERIOD_TIME = 3600; // duration until the price is considered stale

function getChainlinkPrice(AggregatorV2V3Interface feed) internal {
    (uint80 roundId, int256 price,, uint updatedAt,) = feed.latestRoundData();
    require(price > 0, "Invalid price");
    require(block.timestamp <= updatedAt + GRACE_PERIOD_TIME, "Stale price");
    return price;
}
Remediation

SOLVED: The Concrete team fixed the issue. The check for stale prices was added. The 1-hour grace period was used. For L2 chains, the team have added sequencer check, where it checks if the sequencer is up and running as well as updatedAt timestamp falls within the grace period.

Remediation Hash
References

7.10 Broken Functionality Of RemoteProtocolProxy Due To Access Control

// Medium

Description

The RemoteProtocolProxy contract is intended to allow foreclosure and claim protection for Blueprint owners with Concreteer privileges. However, both the foreclose and claimProtection functions implemented in the Blueprint do not use the _callerIsRemoteProtocolProxy to allow the Remote Proxy to call, and are callable only by the endpoint, making this functionality broken.

function foreclose(uint256 foreclosureFeeFractionInWad, address beneficiary)
        external
        virtual
        override(IProtocolIntervention)
    {
        // _foreclose();
        uint256 protectionInfo = _getProtectionInfo();
        if (_callerIsEndpoint() && protectionInfo.getProtocolEnabledForClosureAndForeclosure()) {
            _requestFlashLoan(
                _borrowTokenInfo.getAddress(), _getLenderDebt(false), foreclosureFeeFractionInWad, beneficiary
            );
            return;
        }

        revert Errors.UnauthorizedTriggerOfForeclosure();
    }
    function claimProtection(uint256 amount, uint256 protectionFeeInToken, address externalBeneficiary)
        external
        virtual
        override(IProtocolIntervention)
    {
        uint256 protectionInfo = _getProtectionInfo();
        // if sender is endpoint then claim protection
        if (_callerIsEndpoint() && protectionInfo.getProtocolEnabledForClaimsAndReclaims()) {
            // external Beneficiary is disregarded.
            protectionInfo = _claimProtectionDefault(protectionInfo, amount, protectionFeeInToken);
            _setProtectionInfo(protectionInfo);
            return;
        }
        externalBeneficiary;
        revert Errors.UnauthorizedTriggerOfClaimProtection();
    }
Proof of Concept
    function test_remoteProtocolProxyReverts() external {
        address userBlueprint = _createAaveV3UserBlueprint();
        vm.prank(address(cccm));
            IUserIntervention(userBlueprint).setBorrowToken(address(usdc));
        uint256 supplyAmount = 1000 ether;
        uint256 borrowAmount = 1_500_000 * 10 ** 6;
        (uint256 encodedProtectionInfo, uint256 promisedAmountInCollateral) =
            _createConcreteProtectionEncoding(supplyAmount, borrowAmount);

        //Expect a revert if the remote proxy is the caller
        vm.prank(address(remoteProtocolProxy));
            vm.expectRevert(Errors.NotProtocolAndNotIntervenable.selector);
            IProtocolIntervention(userBlueprint).openProtection(
                encodedProtectionInfo, concreteForeclosureFeeDebtFractionInWad, promisedAmountInCollateral);

        vm.prank(address(remoteProtocolProxy));
            vm.expectRevert(Errors.NotProtocolAndNotIntervenable.selector);
            IProtocolIntervention(userBlueprint).supplyAndProtect(
                supplyAmount, encodedProtectionInfo, liteForeclosureFeeFractionInWad, promisedAmountInCollateral);

        vm.prank(address(remoteProtocolProxy));
            vm.expectRevert(Errors.NotProtocolAndNotIntervenable.selector);
            IProtocolIntervention(userBlueprint).supplyBorrowAndProtect(
                supplyAmount, borrowAmount, address(usdc), encodedProtectionInfo, liteForeclosureFeeFractionInWad, promisedAmountInCollateral);
    }
BVSS
Recommendation

Adjust the access of the RemoteProxy to the intended functionalities.

Remediation

SOLVED: The Concrete team fixed the issue. The mentioned invocation was removed from the contract. The RemoteProtocolProxy connectors and RemotePublicExecutor variables are retained in UserBaseV1 for potential future use.

Remediation Hash
References

7.11 Incorrect Calculation Order In Credit Update

// Medium

Description

When a new protection tranche is claimed, the claimProtection method from ProtectionHandler contract, is responsible for updating the credit information. This process involves recalculating the totalCreditInBase and totalCreditInToken values. The method currently attempts to update totalCreditInBase by adding the amount of tokens multiplied by quote denomination (10e8) and then dividing the result by the price returned from the oracle.

            params.creditInfo = _creditInfo;
            (params.totalCreditInBase, params.totalCreditInToken) = params.creditInfo.decodeCreditAmount();
            params.newCreditInfo = params.creditInfo.updateCredit(
                params.totalCreditInBase
                    + params.amountInToken.mulDiv(
                        PRICE_FEED_QUOTE_DENOMINATION, _getAssetPrice(collateralTokenInfo.getAddress())
                    ).mulDiv(BASE_DENOMINATION, collateralTokenInfo.getDenomination()),
                params.totalCreditInToken + params.amountInToken
            );

However, the calculation is performed in the wrong order. The amount of tokens should first be multiplied by the price and then normalized to the intended 10e8 denomination.

BVSS
Recommendation

Adjust the calculation to first multiply the amountInToken by the asset price to ensure accurate conversion. 

Remediation

SOLVED: The Concrete team solved the issue. The order of calculations was switched.

Remediation Hash
References

7.12 Token Transfer Reverts Leading to Blocked Foreclosure

// Medium

Description

The _executeForeclosure function is invoked during the foreclosure flow to execute the logic required to repay unsecured debt. Using the assets withdrawn from the lender, it repays the borrowed amount, subtracts the fee, and sends the remaining collateral back to the position owner.

        // ------ repay to the claimRouter/fundRequester(flashloan) ------
        params.amountInBorrowTokenLeft -= params.totalRepayAmountInToken;
        if (isEarnFlashloan) {
            _increaseAllowanceIfNecessary(params.claimRouter, borrowToken, params.totalRepayAmountInToken);
        } else {
            IERC20(borrowToken).transfer(params.fundRequester, params.totalRepayAmountInToken);
        }
        // ------ deduct foreclosure fee ------
        uint256 foreclosureFeeInBorrow = repayAmount.mulDiv(foreclosureFeeFractionInWad, WAD);
        if (foreclosureFeeInBorrow > params.amountInBorrowTokenLeft) {
            foreclosureFeeInBorrow = params.amountInBorrowTokenLeft;
        }
        params.amountInBorrowTokenLeft -= foreclosureFeeInBorrow;
        _deductFee(
            TempAddresses(params.claimRouter, TreasuryAndRevenueSplit.getAddress(), beneficiary),
            params.borrowTokenInfo,
            foreclosureFeeInBorrow,
            TreasuryAndRevenueSplit.getMax96BitNumber()
        );

        // ------ send back the remaining amount to the owner ------
        IERC20(borrowToken).transfer(_owner(), params.amountInBorrowTokenLeft);

Some tokens implement a blacklist functionality (e.g., USDC), forbidding transfers to or from blocked addresses. Moreover, some tokens revert when attempting to transfer 0 tokens. If the transfer fails for any reason, the entire foreclosure process will revert, preventing the liquidation of the unsecured position.

BVSS
Recommendation

Consider using the pull pattern. Instead of transferring tokens to the owner's address directly, increase the internal balance of pending withdrawals and introduce a claim functionality that allows the owner to manually withdraw the remaining funds.

Remediation
        if (params.amountInBorrowTokenLeft > 0) {
            try IERC20(borrowToken).transfer(_owner(), params.amountInBorrowTokenLeft) {}
            catch {
                IERC20(borrowToken).safeIncreaseAllowance(_owner(), params.amountInBorrowTokenLeft);
            }
        }
Remediation Hash
References

7.13 Unsafe use of transfer()/transferFrom() with IERC20

// Low

Description

Some tokens do not implement the ERC20 standard properly but are still accepted by most code that accepts ERC20 tokens. For example, Tether (USDT)'s transfer() and transferFrom() functions on L1 do not return booleans as the specification requires, and instead have no return value. When these tokens are cast to IERC20, their function signatures do not match, causing calls to revert.

BVSS
Recommendation

Use OpenZeppelin's SafeERC20 library, which provides safeTransfer() and safeTransferFrom() functions. These functions handle non-standard ERC20 implementations safely, ensuring compatibility with a wider range of tokens.

Remediation

SOLVED: The Concrete team solved the issue as recommended.

Remediation Hash

7.14 Insolvent Position Cannot Be Liquidated

// Low

Description

The foreclose function implemented in the UserBase contract is designed to handle the liquidation of a position. It interacts with a flash loan mechanism to acquire the necessary funds for foreclosure, either via a primary method or a fallback, depending on availability. 

The function invokes the _requestFlashLoan method on the funds' requester contract. 

    function foreclose(uint256 foreclosureFeeFractionInWad, address beneficiary)
        external
        virtual
        override(IProtocolIntervention)
    {
        // _foreclose();
        uint256 protectionInfo = _getProtectionInfo();
        if (_callerIsEndpoint() && protectionInfo.getProtocolEnabledForClosureAndForeclosure()) {
            _requestFlashLoan(
                _borrowTokenInfo.getAddress(), _getLenderDebt(false), foreclosureFeeFractionInWad, beneficiary
            );
            return;
        }

        revert Errors.UnauthorizedTriggerOfForeclosure();
    }

The requestFlashLoan function from the FundsRequester attempts to obtain the funds necessary to repay the position on the lending platform, through a claim router. If successful, it proceeds with the foreclosure process and then repays the claim router. If the attempt with the claim router fails, it falls back to using Aave to obtain a flash loan for the required funds.

    function requestFlashLoan(address token, uint256 amountInBorrowToken, uint256 feeFractionInWad, address beneficiary)
        external
        onlyUserBlueprint
    {
        // try and except handling ... first try the earn claim router, then try Aave
        address userBlueprint = msg.sender;
        IClaimRouter claimRouter = IClaimRouter(REMOTE_REGISTRY.getEarnClaimRouter());
        try claimRouter.requestToken(VaultFlags(0), token, amountInBorrowToken, payable(userBlueprint)) {
            // now execute the flashloan
            IProtocolIntervention(userBlueprint).executeForecloseByFundsRequester(
                amountInBorrowToken, 0, feeFractionInWad, beneficiary
            );
            // now pull the funds from the user and repay the claim router
            claimRouter.repay(token, amountInBorrowToken, payable(userBlueprint));
        } catch {
            // fallback to Aave
            if (address(POOL) == address(0)) revert Errors.AavePoolNotSet();

            POOL.flashLoanSimple(
                address(this), token, amountInBorrowToken, abi.encode(userBlueprint, feeFractionInWad, beneficiary), 0
            );
        }
    }

During the flash loan process, the _executeForeclosure function in the ProtectionHandler contract is invoked. It repays the borrowed credit using the flash loaned amount, withdraws the position’s collateral, swaps it into the borrowed token, and then transfers the swapped amount to the fund requester to repay the flash loan.

        params.totalRepayAmountInToken = repayAmount + premium;
        params.collateralAmountLeft = _getLenderSupplied(false);

        // ------ repay lender ------
        _repay(borrowToken, repayAmount, Uint8CodecLib.encodeOnlyMode(uint8(SendFundsModality.ONLY_SECOND_STEP)));
        // ------ withdraw collateral from lender ------
        _withdraw(
            params.collateralToken,
            params.collateralAmountLeft,
            Uint8CodecLib.encodeOnlyMode(uint8(SendFundsModality.ONLY_FIRST_STEP))
        );

        // User has 3 ETH in AAVE, he has 5000 USDC debtToken in AAVE. OF THE 3 ETH, 0.5 ETH are credit injections (ConcreteDebt of the user). Then there is a foreclosure with a fee of 1%.
        // 1. We request 5000 USDC from the fundsRequester
        // 2. Either we get it from claimRouter or from AaveFlashloan
        // 3. _executeForeclosure is called with uint256 repayAmount, uint256 premium, uint256 foreclosureFeeFractionInWad,
        // 4. We use this money to repay the 5000 USDC debt in AAVE
        // 5. We get the 3 ETH from the AAVE
        // 6. We repay to the claimRouter the 0.5 ETH of users Concrete Debt.
        // 7. We swap as much as possible into USDC of the remaining 2.5 ETH
        // 8. We need to make sure that enough money (repayAmount + premium) is available in the contract in borrow denomination.
        // 9. We deduct the 50 USDC fee from the 5000 USDC (in borrow token)
        // 10 The rest of the ETH and USDC is send back to the user.

        // ------ repay the credit injections ------
        params.concreteDebtInCollateral = _getTotalConcreteDebtOfUser();

        if (params.concreteDebtInCollateral > 0) {
            if (params.concreteDebtInCollateral > params.collateralAmountLeft) {
                params.concreteDebtInCollateral = params.collateralAmountLeft;
            }
            _repayTokenToClaimRouter(params.claimRouter, params.collateralToken, params.concreteDebtInCollateral);
            params.collateralAmountLeft -= params.concreteDebtInCollateral;
        }

        // ------ swap collateral to borrow token ------
        if (params.collateralAmountLeft > 0) {
            params.amountInBorrowTokenLeft = _swap(params.collateralToken, borrowToken, params.collateralAmountLeft, 0);
        }
        if (params.amountInBorrowTokenLeft < params.totalRepayAmountInToken) {
            params.totalRepayAmountInToken = params.amountInBorrowTokenLeft;
        }

        // ------ repay to the claimRouter/fundRequester(flashloan) ------
        params.amountInBorrowTokenLeft -= params.totalRepayAmountInToken;
        if (isEarnFlashloan) {
            _increaseAllowanceIfNecessary(params.claimRouter, borrowToken, params.totalRepayAmountInToken);
        } else {
            IERC20(borrowToken).transfer(params.fundRequester, params.totalRepayAmountInToken);
        }

However, when a position becomes insolvent, the collateral swapped into the borrowed token is insufficient to repay the flash loan. This situation prevents the foreclosure process from successfully liquidating the position, leading to the position being stuck in an insolvent state. When integrating with high-liquidity and high-performance lending protocols like Aave or Compound, this will not be an issue, as positions are liquidated before reaching insolvency. In the future though, it may be necessary to ensure that the Blueprint position can be always liquidated by the protocol.

Proof of Concept
    function test_CannotLiquidateInsolventPosition() public { 
        address userBlueprint = _createAaveV3UserBlueprint();
        
        uint256 supplyAmount = 1000 ether;
        uint256 borrowAmount = 1_500_000 * 10 ** 6;
        uint256 promisedAmountInCollateral;
        uint256 encodedProtectionInfo;
        (encodedProtectionInfo, promisedAmountInCollateral) = _createConcreteProtectionEncoding(supplyAmount, borrowAmount);
        assertEq(promisedAmountInCollateral, 100 ether);

        weth.mint(alice, supplyAmount);
        vm.prank(alice);
        weth.approve(userBlueprint, supplyAmount);

        vm.startPrank(address(cccm));
            //The user supplies 1000 WETH and borrow 1500000 USDC
            IUserIntervention(userBlueprint).supply(supplyAmount, 0);
            IUserIntervention(userBlueprint).setBorrowToken(address(usdc));
            IUserIntervention(userBlueprint).borrow(borrowAmount, 0);
        vm.stopPrank();

        //Simulate a price drop of 25% 
        uint256 newEthPrice = initialEthPriceInBaseUnits.mulDiv(75, 1000);
        _updatePrice(aaveSetup.priceOracle, address(weth), newEthPrice);

        //The claim router fails to provide funds
        claimRouter.setRevertOnRequest(true);
        vm.prank(address(cccm));
            //Foreclosure reverts because the position is insolvent
            vm.expectRevert();
            IProtocolIntervention(userBlueprint).foreclose(liteForeclosureFeeFractionInWad, address(0));
    }

BVSS
Recommendation

In cases where the collateral is insufficient to cover the full repayment of the flash loan, consider implementing a mechanism to obtain the remaining required tokens using a separate liquidity designated specifically for such situations.

Remediation

SOLVED: The Concrete team solved the issue. In cases where the flashloan provider cannot be repaid, the missing amount is pulled from the user blueprint. The user blueprint can hold funds, either deposited by its owner or by Concrete’s treasury. If there are still insufficient funds (or none) in the user blueprint, the missing amount is pulled from the treasury. If this is not possible, the transaction reverts. This setup allows either the addition of funds to the user blueprint to rescue the position or the approval of the blueprint to pull the remaining funds from the treasury.

Remediation Hash
References

7.15 Usage Of Direct Approve Calls

// Low

Description

In order to allow for the transfer of tokens from one address, the protocol calls the approve function using the IERC20 interface in several places. This approach might be problematic for a few reasons:

  • Some tokens, to protect against approval race conditions, do not allow approving an amount M > 0 when an existing amount N > 0 is already approved.

  • The approve call does not return a boolean.

  • The function reverts if the approval value is larger than uint96.

BVSS
Recommendation

It is recommended to use safeIncreaseAllowance and safeDecreaseAllowance from the SafeERC20 library across the entire protocol, instead of calling approve directly.

Remediation

SOLVED: The Concrete team fixed the issue as recommended.

Remediation Hash

7.16 Usage Of Single-Step Role Transfer

// Low

Description

The RemoteRegistry contract implements setter functions that allow the modification of critical roles and components within the system. The transfer of these critical roles is done through a single-step process. The address to which ownership is transferred should be verified to ensure it is active and willing to act as the owner. This is especially important for the setEndpointAddress and setNewRegistryAddress functions.

BVSS
Recommendation

Consider implementing a two-step transfer pattern. In this approach, the privileged account nominates a new address, and the nominated account must then accept the role for the ownership transfer to be fully completed. By separating the process, this ensures that the new account is both active and willing to assume the associated permissions.

Remediation

SOLVED: The Concrete team fixed the issue. The proposeEndpointAddress, confirmNewEndpointAddress and revokeEndpointProposal function were added. The setNewRegistry function was deleted.

Remediation Hash

7.17 Unlocked Pragma Compilers

// Informational

Description

The files in scope currently use floating pragma version ^0.8.24, which means that the code can be compiled by any compiler version that is greater than or equal to 0.8.0, and less than 0.9.0. It is recommended that contracts should be deployed with the same compiler version and flags used during development and testing. Locking the pragma helps to ensure that contracts do not accidentally get deployed using another pragma. For example, an outdated pragma version might introduce bugs that affect the contract system negatively.

Additionally, using a newer compiler version that introduces default optimizations, including unchecked overflow for gas efficiency, presents an opportunity for further optimization.

Score
Impact:
Likelihood:
Recommendation

Lock the pragma version to the same version used during development and testing.

Remediation

SOLVED: The Concrete team fixed the issue as recommended.

Remediation Hash

7.18 Consider Using Named Mappings

// Informational

Description

The project is using Solidity version greater than 0.8.18, which supports named mappings. Using named mappings can improve the readability and maintainability of the code by making the purpose of each mapping clearer. This practice will enhance code readability and make the purpose of each mapping more explicit.

Score
Impact:
Likelihood:
Recommendation

Consider refactoring the mappings to use named arguments.

For example, on PortfolioProxy, instead of declaring:
mapping(address => uint256) public _portfolioId;
The mapping could be declared as:
mapping(address userBlueprint => uint256 id) public _portfolioId;

Remediation

SOLVED: The Concrete team fixed the issue as recommended.

Remediation Hash

7.19 Unused Imports, Errors and Events

// Informational

Description

Throughout the code in scope, there are several instances where the imports, errors, and events are declared but never used.

In PriceFeedManagerEvents.sol :

  • event PriceProviderRemoved(address indexed token);


In RemoteRegistryEvent.sol:

  • event RemoteChainwideInterventionTypeSetTo(InterventionConstraintType type_);

  • event RemoteChainwideAllowUserDirectHandlingSetTo(bool enable);

  • event OwnerTransferred(address user, address newOwner);


In Errors.sol:

  • error ProtectionMetadataUnequalArrayLengths();

  • error ProtectionMetadataArrayLengthExceedsLimit();

  • error NotProtocolAndUserCannotControl();

  • error PositionAlreadyProtected();

  • error PositionNotProtected();

  • error WithdrawalExceedsUserFractionOfFunds();

  • error OwnerAddressNotSet();

  • error ClaimRouterAddressZero();

  • error AssetMustBeCollateral();

  • error AssetMustBeBorrowToken();

  • error EndpointCannotBeZeroAddress();

  • error RemoteProtocolProxyCannotBeZeroAddress();

  • error PublicExecutorCannotBeZeroAddress();

  • error UnsupportedAsset(address token);


In RemoteExecutorsBookkeeper.sol:

  • import {IProtocolIntervention} from "../userBase/interfaces/IProtocolIntervention.sol";


In FundsRequester.sol:

  • import {IUserBlueprint} from "../userBlueprints/interfaces/IUserBlueprint.sol";


In Inspector.sol:

  • import {IProtocolIntervention} from "../userBase/interfaces/IProtocolIntervention.sol";

  • import {IUserIntervention} from "../userBase/interfaces/IUserIntervention.sol";


In ProtectionViewLib.sol:

  • import {Errors} from "../helpers/Errors.sol";


In PortfolioProxyRemoteTransfer.sol:

  • import {IConcreteCrossChainMessaging as ICCCM} from "@cccm-evm/src/interfaces/IConcreteCrossChainMessaging.sol";

  • import {IRemoteRegistry} from "../remoteRegistry/interfaces/IRemoteRegistry.sol";

  • import {IResponseHandler, IProtocol} from "../userBase/interfaces/IProtocolResponse.sol";


In CloneFactory.sol:

  • import {EndpointAddressGetterStandalone} from "@cccm-evm/src/integration/Getters.sol";

  • import {OnlyEndpointStandalone} from "@cccm-evm/src/integration/OnlyEndpoint.sol";

  • import {IRemoteRegistry} from "./interfaces/IRemoteRegistry.sol"; import {Errors} from "../helpers/Errors.sol";


In RemoteRegistry.sol:

  • import {OnlyEndpointStandalone} from "@cccm-evm/src/integration/OnlyEndpoint.sol";

  • import {InterventionConstraintType} from "../helpers/DataTypes.sol";


In SwapperUniV3.sol:

  • import {IRemoteRegistry} from "../../remoteRegistry/interfaces/IRemoteRegistry.sol";


In SwapperBase.sol:

  • import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";


In UserBase.sol:

  • import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol";

  • import {IERC20, IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";

  • import {IResponseHandler, IProtocol} from "./interfaces/IProtocolResponse.sol";


In ExecutorConnector.sol:

  • import {Errors} from "../../helpers/Errors.sol";

  • import {Uint8CodecLib} from "@hub-and-spokes-libs/src/libraries/Uint8CodecLib.sol";


In ProtectionHandler.sol:

  • import {IClaimRouter} from "../../fundsRequester/interfaces/IClaimRouter.sol";

  • import {InterventionConstraintType} from "../../helpers/DataTypes.sol";


In RemoteRegistryConnector.sol:

  • import {InterventionConstraintType} from "../../helpers/DataTypes.sol";


In UserAaveV3.sol:

  • import {EthereumMainnetAddresses as ADDR} from "../addresses/EthereumMainnet.sol";

  • import {TokenType, PositionInteractionType, SendFundsModality} from "../helpers/DataTypes.sol";


In UserCompoundV3.sol:

  • import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol";

  • import {EthereumMainnetAddresses as ADDR} from "../../src/addresses/EthereumMainnet.sol";

  • import {TokenType, PositionInteractionType, SendFundsModality} from "../../src/helpers/DataTypes.sol";


In UserRadiantV2.sol:

  • import {EthereumMainnetAddresses as ADDR} from "../addresses/EthereumMainnet.sol";

  • import {TokenType, PositionInteractionType, SendFundsModality} from "../helpers/DataTypes.sol";


In UserSiloV1.sol:

  • import {IPriceOracleGetter} from "@aave-v3-core/contracts/interfaces/IPriceOracleGetter.sol";

  • import {BASE_DENOMINATION, WAD, BP, PRICE_FEED_QUOTE_DENOMINATION} from "../helpers/Constants.sol";

  • import {EthereumMainnetAddresses as ADDR} from "../addresses/EthereumMainnet.sol";

  • import {TokenType, PositionInteractionType, SendFundsModality} from "../helpers/DataTypes.sol";

Score
Impact:
Likelihood:
Recommendation

Consider removing all unused imports, errors and events.

Remediation

SOLVED: The Concrete team fixed the issue as recommended.

Remediation Hash

7.20 Commented Out Code

// Informational

Description

The SwapperBEX contract is entirely commented out, including both the constructor and the swap function. While the contract structure is present, no functional code is active and the swap function contains only a placeholder comment for future implementation.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {IRemoteRegistry} from "../../remoteRegistry/interfaces/IRemoteRegistry.sol";
import {Errors} from "../../helpers/Errors.sol";
import {SwapperBase} from "../SwapperBase.sol";

// contract SwapperBEX is SwapperBase {
//     constructor(address remoteRegistry_) SwapperBase(remoteRegistry_) {}

//     function _swap(address fromToken, address toToken, uint256 amount)
//         internal
//         override
//         returns (uint256 swappedAmount)
//     {
//         // @Atul, please implement the swap logic here.
//         // If you need some addresses, put them into the constructor.
//     }
// }
Score
Impact:
Likelihood:
Recommendation

Either complete the implementation of the swap logic or remove the unused SwapperBEX contract to reduce unnecessary complexity.

Remediation

SOLVED: The Concrete team fixed the issue as recommended. The SwapperBEX contract was removed.

Remediation Hash
References

7.21 Excess Gas Consumption Due to Redundant Token Transfers

// Informational

Description

In logic of function implemented on SEND_THROUGH modality on some user blueprints, redundant token transfers are made by first withdrawing tokens to the contract and then transferring them to the user. This leads to unnecessary gas costs due to additional transfer steps. By utilizing withdrawTo, claimTo (for Compound V3), withdrawFor and borrowFor (for SiloV1) functions, tokens can be transferred directly to the user, reducing the number of operations and thus gas costs.

Score
Impact:
Likelihood:
Recommendation

Replace the two-step process of withdrawing and transferring tokens with methods that allow direct sending of funds to the user's address whenever possible, to minimize gas usage.

Remediation

SOLVED: The Concrete team acknowledged the issue.

7.22 Lack Of Event Emission

// Informational

Description

It has been observed that Blueprint and Remote Registry functionalities are missing emitting events.

Events are a method of informing the transaction initiator about the actions taken by the called function. It logs its emitted parameters in a specific log history, which can be accessed outside of the contract using some filter parameters. Events help non-contract tools to track changes, and events prevent users from being surprised by changes.

Score
Impact:
Likelihood:
Recommendation

All functions updating important parameters should emit events.

Remediation

SOLVED: The Concrete team fixed the issue as recommended.

Remediation Hash

7.23 Inconsistent Naming Convention In ProtectionLibV1 For Value Units

// Informational

Description

The ProtectionLibV1 library incorrectly uses InBase in variable names and function parameters, even though the values should represent collateral decimals. This mismatch in naming convention can cause confusion in the future maintenance.

Score
Impact:
Likelihood:
Recommendation

Update variable names and parameters throughout the ProtectionLibV1 library to reflect that the values are in collateral decimals, ensuring clarity and consistency in the codebase.

Remediation

SOLVED: The Concrete team fixed the issue as recommended.

Remediation Hash

7.24 Unused Parameter in claimProtection Function

// Informational

Description

The externalBeneficiary parameter in the claimProtection function of UserBase contract is unused and does not impact the function's logic. This parameter can be safely removed without affecting functionality, as it is disregarded when claims are triggered.

    function claimProtection(uint256 amount, uint256 protectionFeeInToken, address externalBeneficiary)
        external
        virtual
        override(IProtocolIntervention)
    {
        uint256 protectionInfo = _getProtectionInfo();
        // if sender is endpoint then claim protection
        if (_callerIsEndpoint() && protectionInfo.getProtocolEnabledForClaimsAndReclaims()) {
            // external Beneficiary is disregarded.
            protectionInfo = _claimProtectionDefault(protectionInfo, amount, protectionFeeInToken);
            _setProtectionInfo(protectionInfo);
            return;
        }
        externalBeneficiary;
        revert Errors.UnauthorizedTriggerOfClaimProtection();
    }
Score
Impact:
Likelihood:
Recommendation

Remove the externalBeneficiary parameter from the claimProtection function to simplify the code and improve clarity.

Remediation

ACKNOWLEDGED: The Concrete team keep the function signature with the external beneficiary parameter as it will be used in the future versions.

References

8. Automated Testing

Static Analysis Report

Description

Halborn used automated testing techniques to enhance the coverage of certain areas of the scoped contracts. Among the tools used was Slither, a Solidity static analysis framework. After Halborn verified all the contracts in the repository and was able to compile them correctly into their ABI and binary formats, Slither was run on the all-scoped contracts. This tool can statically verify mathematical relationships between Solidity variables to detect invalid or inconsistent usage of the contracts' APIs across the entire code-base.


FundsRequester.sol


ProtectionViewLib.sol


PortfolioProxy.sol


RemoteRegistry.sol

SwapperUniV3.sol

UserBase.sol


FundsRequesterConnector.sol


ProtectionHandler.sol


TokenInfo.sol


UserAaveV3.sol

UserCompoundV3.sol

UserRadiantV2.sol

UserSiloV1.sol

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

© Halborn 2024. All rights reserved.