Stratax Contracts

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

Front-run unwind to worsen swap output: revert traps position until victim accepts worse terms

Author Revealed upon completion

Root + Impact

Description

  • Normal behavior: The owner calls unwindPosition to close or reduce a leveraged position. The contract takes a flash loan of the debt token, repays Aave debt, withdraws proportional collateral, swaps collateral to debt token via 1inch, and repays the flash loan. The swap is expected to return at least totalDebt (principal + premium); any surplus is supplied back to Aave.

  • Specific issue: The collateral→debt swap executes at current DEX/liquidity prices. An attacker watching the mempool can front-run the victim’s unwind by trading in the same pools 1inch uses (e.g. buy collateral, sell debt token), worsening the victim’s swap execution. The victim’s swap then returns less debt token. When returnAmount < totalDebt, the callback reverts at require(returnAmount >= totalDebt, "Insufficient funds to repay flash loan"). The unwind fails and the position stays open. By repeating the front-run, the attacker can effectively trap the position until the victim retries with lower minReturnAmount (worse slippage) or conditions improve.

// Stratax.sol _executeUnwindOperation
// Step 3: Swap collateral to debt token to repay flash loan
IERC20(unwindParams.collateralToken).approve(address(oneInchRouter), withdrawnAmount);
// @> Swap runs at current DEX price; front-runner can move price so victim gets less debt token
uint256 returnAmount = _call1InchSwap(unwindParams.oneInchSwapData, _asset, unwindParams.minReturnAmount);
// Step 4: Repay flash loan
uint256 totalDebt = _amount + _premium;
// @> Front-run that worsens swap → returnAmount < totalDebt → revert; position remains open
require(returnAmount >= totalDebt, "Insufficient funds to repay flash loan");

Risk

Likelihood:

  • Unwind transactions are visible in the public mempool. Any actor with front-running capability can submit a trade before the victim’s unwind so that the victim’s swap receives worse execution.

  • 1inch routes through on-chain DEX pools; pool prices are sensitive to the order of trades in the same block. The attacker’s trade and the victim’s swap are in the same block, so the attack is feasible whenever the victim uses a non-private submission path.

Impact:

  • The victim’s unwind reverts; the position stays open and remains exposed to market and liquidation risk.

  • A motivated attacker can repeatedly front-run unwind attempts, effectively trapping the victim until they accept worse slippage (lower minReturnAmount) or wait for better conditions, causing economic harm and potential liquidation.

Proof of Concept

The PoC replicates the unwind repayment check from Stratax. When the swap returns less than totalDebt (e.g. because an attacker front-ran and moved DEX price), the require(returnAmount >= totalDebt, "Insufficient funds to repay flash loan") reverts. The tests demonstrate: (1) passing returnAmount = totalDebt - 1 causes the exact revert used in production; (2) the front-run scenario (swap returns 1002, totalDebt 1005) satisfies the revert condition. So the position cannot close until the victim retries with lower minReturnAmount or better market.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test} from "forge-std/Test.sol";
/**
* @title PoC: Front-run unwind causes revert and traps position
* @notice Proof of Concept for finding "Front-run unwind to worsen swap output"
* @dev Run with: forge test --match-contract PoC_FrontrunUnwindRevert -vvv
*
* The unwind callback requires: returnAmount >= totalDebt (principal + premium).
* An attacker who front-runs the victim's unwind can worsen the victim's swap
* (e.g. trade in the same DEX pool) so that returnAmount < totalDebt, causing
* the unwind to revert and the position to stay open.
*/
// External helper so vm.expectRevert() catches revert at subcall depth
contract UnwindRepayChecker {
function check(uint256 returnAmount, uint256 _amount, uint256 _premium) external pure {
uint256 totalDebt = _amount + _premium;
require(returnAmount >= totalDebt, "Insufficient funds to repay flash loan");
}
}
contract PoC_FrontrunUnwindRevert is Test {
UnwindRepayChecker public checker;
function setUp() public {
checker = new UnwindRepayChecker();
}
/**
* PoC: When swap returns less than totalDebt (e.g. after front-run),
* the unwind callback reverts with "Insufficient funds to repay flash loan".
*/
function test_PoC_unwindRevertsWhenSwapReturnsLessThanTotalDebt() public {
uint256 debtAmount = 1000e6; // 1000 USDC repaid
uint256 premium = 5e6; // flash loan fee
uint256 totalDebt = debtAmount + premium; // 1005e6
// Simulate swap output after attacker front-ran and worsened price
uint256 returnAmountAfterFrontRun = totalDebt - 1; // 1004e6 (insufficient)
vm.expectRevert("Insufficient funds to repay flash loan");
checker.check(returnAmountAfterFrontRun, debtAmount, premium);
}
/**
* PoC: Front-run scenario with concrete numbers.
* Victim unwinds: 1000 USDC debt + 5 USDC premium = 1005 USDC needed from swap.
* Without front-run, swap would return 1006 USDC (success).
* Attacker front-runs: trades in pool so victim's swap gets worse execution.
* After front-run, swap returns 1002 USDC < 1005revert; position stays open.
*/
function test_PoC_frontRunScenario_swapReturnBelowTotalDebt() public pure {
uint256 debtAmount = 1000e6;
uint256 premium = 5e6;
uint256 totalDebt = debtAmount + premium; // 1005e6
uint256 swapReturnWithoutFrontRun = 1006e6; // would succeed
uint256 swapReturnAfterFrontRun = 1002e6; // attacker moved price
assertTrue(swapReturnWithoutFrontRun >= totalDebt, "Without front-run: unwind would succeed");
assertTrue(swapReturnAfterFrontRun < totalDebt, "After front-run: unwind reverts");
assertTrue(
swapReturnAfterFrontRun < totalDebt,
"PoC: returnAmount < totalDebt triggers revert; position trapped until victim retries with worse slippage"
);
}
/**
* PoC: Exact Stratax revert condition - swap return 1 wei short of totalDebt.
*/
function test_PoC_exactStrataxRevertCondition() public {
uint256 _amount = 500e18;
uint256 _premium = 4e18;
uint256 returnAmount = _amount + _premium - 1; // 1 wei short
vm.expectRevert("Insufficient funds to repay flash loan");
checker.check(returnAmount, _amount, _premium);
}
}

Recommended Mitigation

  • In unwindPosition and/or UnwindParams NatSpec, document that _minReturnAmount must be chosen so that the swap is expected to return at least _debtAmount + flashLoanPremium under adverse execution (e.g. front-run), and that unwinds are sensitive to DEX price movement.

  • Optional: at the start of _executeUnwindOperation, require that the owner’s minimum return is at least the amount needed to repay the flash loan, so that the user cannot accidentally set a lower bound below the debt (does not stop front-running but aligns parameter with intent):

function _executeUnwindOperation(address _asset, uint256 _amount, uint256 _premium, bytes calldata _params)
internal
returns (bool)
{
(, address user, UnwindParams memory unwindParams) = abi.decode(_params, (OperationType, address, UnwindParams));
+ uint256 totalDebt = _amount + _premium;
+ require(unwindParams.minReturnAmount >= totalDebt, "minReturn must cover flash loan");
+
// Step 1: Repay the Aave debt using flash loaned tokens
IERC20(_asset).approve(address(aavePool), _amount);
aavePool.repay(_asset, _amount, 2, address(this));
...
// Step 4: Repay flash loan
- uint256 totalDebt = _amount + _premium;
require(returnAmount >= totalDebt, "Insufficient funds to repay flash loan");

Support

FAQs

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

Give us feedback!