Stratax Contracts

First Flight #57
Beginner FriendlyDeFi
100 EXP
Submission Details
Impact: medium
Likelihood: medium

Collateral stranded in contract after unwind — no path for owner to withdraw remaining Aave collateral

Author Revealed upon completion

Root + Impact

Description

  • Normal behavior: When a position is unwound, the contract repays Aave debt with a flash loan, withdraws the collateral that backed the repaid debt from Aave, swaps it to the debt token, repays the flash loan, and supplies any surplus debt token back to Aave. The owner expects that after a full unwind (debt fully repaid), all collateral either is used to close the position or can be withdrawn to them.

  • Specific issue: The amount of collateral to withdraw is computed with integer division (rounds down). For a full unwind, the contract may therefore withdraw less than the total collateral in the position. That remainder stays in the contract’s Aave position (supplied to the Pool). Withdrawn collateral is always sent to address(this) and then swapped; none is ever sent to the owner. The only recovery function, recoverTokens, transfers tokens held in the contract’s ERC20 balance and does not withdraw from Aave. There is no function that withdraws Aave-supplied collateral to the owner. Collateral left in the Aave position after unwind is permanently stranded.

// Stratax.sol – unwind withdraws to contract only; formula rounds down
// Step 2: Calculate and withdraw only the collateral that backed the repaid debt
uint256 withdrawnAmount;
{
(,, uint256 liqThreshold,,,,,,,) =
aaveDataProvider.getReserveConfigurationData(unwindParams.collateralToken);
// ...
// @> Integer division rounds down → collateralToWithdraw may be < total collateral in position
uint256 collateralToWithdraw = (
_amount * debtTokenPrice * (10 ** IERC20(unwindParams.collateralToken).decimals()) * LTV_PRECISION
) / (collateralTokenPrice * (10 ** IERC20(_asset).decimals()) * liqThreshold);
// @> Withdraws to address(this), not to owner; no other path withdraws Aave collateral to owner
withdrawnAmount = aavePool.withdraw(unwindParams.collateralToken, collateralToWithdraw, address(this));
}
// Withdrawn collateral is then fully swapped to debt token; none sent to owner.
// recoverTokens only moves tokens in contract balance – cannot recover Aave-supplied collateral
function recoverTokens(address _token, uint256 _amount) external onlyOwner {
IERC20(_token).transfer(owner, _amount); // @> Only contract-held balance; Aave balance is in Pool
}

Risk

Likelihood:

  • The collateral-to-withdraw formula uses integer division and rounds down. On a full unwind (repay all debt), the requested amount is at most the formula result, so any remainder (total collateral minus withdrawn amount) stays in Aave.

  • Every full unwind can leave dust or a non-trivial remainder in the position depending on prices, decimals, and liqThreshold. There is no code path that withdraws this remainder to the owner.

Impact:

  • Collateral left in the contract’s Aave position after unwind is permanently locked. The owner cannot withdraw it via the protocol.

  • Value is stuck in the contract’s Aave position with no owner recovery mechanism, directly harming the position owner.

Proof of Concept

What the PoC demonstrates:

  1. test_PoC_unwindFormulaRoundsDown_leavingStrandedRemainder — Uses the exact same formula as _executeUnwindOperation (Stratax.sol L574–576): collateralToWithdraw = (debt * debtPrice * collDec * LTV_PRECISION) / (collPrice * debtDec * liqThreshold). Integer division truncates. The test shows that when the position holds collateralToWithdraw + 1 wei (e.g. from prior supply rounding), the formula only requests collateralToWithdraw, so 1 wei (or more) remains in Aave after the unwind callback. That remainder is never withdrawn to the owner.

  2. test_PoC_recoverTokens_onlyTransfersContractBalance_notAave — Stratax has 0 balance of the collateral token (all supplied collateral is in Aave). Calling recoverTokens(collateralToken, 1) triggers IERC20(_token).transfer(owner, 1) from the contract; the transfer reverts because the contract’s ERC20 balance is 0. This proves that recoverTokens only moves tokens in the contract’s wallet, not tokens supplied to the Aave Pool.

  3. test_PoC_noWithdrawToOwnerFunction — Documents that no external function ever calls aavePool.withdraw(..., owner). The only withdraw in the codebase is inside the unwind callback and sends to address(this); that amount is then swapped. So any collateral left in Aave has no withdrawal path to the owner.

// 1) Formula rounds down -> remainder in Aave (same as _executeUnwindOperation)
// 1. Owner opens leveraged position (collateral supplied to Aave in Stratax’s name).
function test_PoC_unwindFormulaRoundsDown_leavingStrandedRemainder() public pure {
uint256 LTV_PRECISION = 1e4;
uint256 _amount = 1000 * 1e18;
uint256 debtTokenPrice = 1e8;
uint256 collateralTokenPrice = 1e8;
uint256 collDec = 18;
uint256 debtDec = 18;
uint256 liq = 8500;
uint256 collateralToWithdraw = (
_amount * debtTokenPrice * (10 ** collDec) * LTV_PRECISION
) / (collateralTokenPrice * (10 ** debtDec) * liq);
uint256 totalCollateralInPosition = collateralToWithdraw + 1;
uint256 remainderInAave = totalCollateralInPosition - collateralToWithdraw;
assertEq(remainderInAave, 1, "PoC: Integer division leaves at least 1 wei in Aave");
assertGt(totalCollateralInPosition, collateralToWithdraw, "PoC: Formula rounds down");
}
// 2) recoverTokens only transfers contract balance; cannot recover Aave-held collateral
function test_PoC_recoverTokens_onlyTransfersContractBalance_notAave() public {
MockERC20 mockCollateral = new MockERC20();
mockCollateral.mint(address(stratax), 0);
assertEq(mockCollateral.balanceOf(address(stratax)), 0);
vm.expectRevert();
stratax.recoverTokens(address(mockCollateral), 1);
}

Recommended Mitigation

  • Add an owner-only function to withdraw Aave-supplied collateral to the owner, e.g. withdrawCollateralToOwner(address collateralToken, uint256 amount), which calls aavePool.withdraw(collateralToken, amount, owner) after checking that the position remains healthy if there is remaining debt (e.g. health factor > 1).

  • Alternatively, when unwinding and the resulting debt is zero, withdraw the full aToken balance (or the full supplied balance for that reserve) so that no collateral remains in Aave; then either swap the full amount as today or transfer the appropriate portion to the owner so that no value is left in the Pool with no withdrawal path.

/**
* @notice Withdraws collateral from Aave to the position owner (e.g. after full unwind remainder).
* @param _collateralToken The Aave reserve used as collateral
* @param _amount Amount to withdraw to owner
*/
function withdrawCollateralToOwner(address _collateralToken, uint256 _amount) external onlyOwner {
require(_amount > 0, "Zero amount");
(,,,,, uint256 healthFactor) = aavePool.getUserAccountData(address(this));
if (healthFactor != type(uint256).max) {
require(healthFactor > 1e18, "Would be liquidatable");
}
aavePool.withdraw(_collateralToken, _amount, owner);
}

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!