Stratax Contracts

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

L03. `BORROW_SAFETY_MARGIN` Is Applied Off-Chain Only

Author Revealed upon completion

Root + Impact

Description

BORROW_SAFETY_MARGIN = 9500(95%) is applied only insidecalculateOpenParams, the off-chain view helper used to size the borrow before calling createLeveragedPosition`.

The on-chain execution path — createLeveragedPosition_executeOpenOperation — forwards _borrowAmount directly to aavePool.borrow() without any safety margin check.

The sole on-chain enforcement is a post-position health factor check of HF > 1.0, which is Aave's own liquidation floor, not the 5% buffer the safety margin was designed to maintain.

// src/Stratax.sol:426 — calculateOpenParams: safety margin applied only here (view function)
uint256 borrowValueUSD =
(totalCollateralValueUSD * ltv * BORROW_SAFETY_MARGIN) / (LTV_PRECISION * 10000);
// @> BORROW_SAFETY_MARGIN = 9500 → effective borrow = 95% of LTV limit
// src/Stratax.sol:314-339 — createLeveragedPosition: _borrowAmount accepted with no margin check
function createLeveragedPosition(
address _flashLoanToken,
uint256 _flashLoanAmount,
uint256 _collateralAmount,
address _borrowToken,
uint256 _borrowAmount, // @> forwarded as-is — no BORROW_SAFETY_MARGIN enforcement
bytes calldata _oneInchSwapData,
uint256 _minReturnAmount
) public onlyOwner { ... }
// src/Stratax.sol:501-507 — _executeOpenOperation: borrow called with raw _borrowAmount
aavePool.borrow(
flashParams.borrowToken,
flashParams.borrowAmount, // @> Aave only enforces HF >= 1.0
2, 0, address(this)
);
// src/Stratax.sol:524-526 — only post-position check: HF > 1.0 (Aave's liquidation floor)
(,,,,, uint256 healthFactor) = aavePool.getUserAccountData(address(this));
require(healthFactor > 1e18, "Position health factor too low");
// @> No check for HF > 1.05 (the equivalent of BORROW_SAFETY_MARGIN)

A caller who constructs _borrowAmount directly — bypassing calculateOpenParams — can borrow up to the Aave LTV ceiling rather than the protocol's intended 95% of that ceiling. The resulting position opens with a materially thinner liquidation buffer than the protocol design assumes.

Risk

Likelihood:

  • The function is onlyOwner, so the bypassing caller is the owner acting against their own position — misconfiguration rather than a third-party exploit

  • Off-chain tooling, automation scripts, or future integrations that compute parameters independently may inadvertently omit the safety margin

  • The bypass is invisible: no event, no error, and no on-chain indicator distinguishes a safety-margin-respecting call from one that doesn't

Impact:

  • Concrete numbers for WETH (ltv = 8000, liqThreshold = 8250) at 2x leverage with 1 WETH collateral:

    Path Borrow amount Resulting HF Buffer above liquidation
    Via calculateOpenParams (×0.95) 1.52 WETH equiv. USDC ≈ 1.085 8.5%
    Direct call (Aave ceiling, ×1.00) 1.60 WETH equiv. USDC ≈ 1.031 3.1%
  • A position opened at HF ≈ 1.031 (3.1% above liquidation) is liquidated by a collateral price move of roughly 3% — plausible within minutes during high volatility

  • When Aave liquidates a position, liquidators seize collateral at a bonus (typically 5–10%). The liquidation bonus comes out of the owner's collateral, producing a worse outcome than a timely voluntary unwind would have

Proof of Concept

The bypass works because calculateOpenParams and createLeveragedPosition are two separate, independent functions. The safety margin exists only in the view helper; the execution path has no reference to it.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test, console} from "forge-std/Test.sol";
contract BorrowSafetyMarginTest is Test {
// Constants matching Stratax.sol
uint256 constant LTV_PRECISION = 10000;
uint256 constant BORROW_SAFETY_MARGIN = 9500;
function test_margin_bypassed_with_direct_call() public pure {
// Scenario: WETH collateral (LTV = 8000), 2x leverage, 1 WETH user collateral
uint256 ltv = 8000;
uint256 liqThreshold = 8250;
uint256 totalCollateral = 2e18; // 1 WETH user + 1 WETH flash loan
// --- Path A: calculateOpenParams (safety margin applied) ---
uint256 maxBorrowSafe = totalCollateral * ltv * BORROW_SAFETY_MARGIN
/ (LTV_PRECISION * 10000);
// = 2e18 * 8000 * 9500 / (10000 * 10000) = 1.52e18 (in collateral-equivalent units)
// --- Path B: direct call to createLeveragedPosition (no margin check) ---
uint256 maxBorrowAave = totalCollateral * ltv / LTV_PRECISION;
// = 2e18 * 8000 / 10000 = 1.60e18
// @> A direct caller can pass maxBorrowAave instead of maxBorrowSafe.
// All on-chain checks pass (Aave borrow succeeds; HF > 1.0 passes).
console.log("Safe borrow (95% margin):", maxBorrowSafe); // 1.52e18
console.log("Aave ceiling (no margin):", maxBorrowAave); // 1.60e18
// Resulting health factors (HF = collateral * liqThreshold / (borrow * LTV_PRECISION))
uint256 hfSafe = (totalCollateral * liqThreshold * 1e18) / (maxBorrowSafe * LTV_PRECISION);
uint256 hfAave = (totalCollateral * liqThreshold * 1e18) / (maxBorrowAave * LTV_PRECISION);
console.log("HF with safety margin (x1e18):", hfSafe); // ~1.085e18
console.log("HF without safety margin (x1e18):", hfAave); // ~1.031e18
// @> Both pass the on-chain check (require(healthFactor > 1e18)).
// The safety margin provides no on-chain guarantee.
assertGt(hfAave, 1e18, "Direct call still passes on-chain HF check");
assertGt(hfSafe, hfAave, "Safety margin gives materially better HF");
}
}

Recommended Mitigation

Replace the post-position health factor floor with one that encodes the safety margin, making BORROW_SAFETY_MARGIN a genuine on-chain invariant regardless of how _borrowAmount was computed:

// src/Stratax.sol — _executeOpenOperation, Step 5
(,,,,, uint256 healthFactor) = aavePool.getUserAccountData(address(this));
- require(healthFactor > 1e18, "Position health factor too low");
+ // Enforce a buffer equivalent to BORROW_SAFETY_MARGIN (95% of LTV ceiling ≈ HF > 1.05)
+ require(healthFactor > 1.05e18, "Position health factor below minimum safety margin");

This change means any call to createLeveragedPosition — whether via calculateOpenParams or not — must result in a position with at least the intended buffer, closing the bypass.

Support

FAQs

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

Give us feedback!