Stratax Contracts

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

`_executeUnwindOperation` ignores user-provided `_collateralToWithdraw` and uses inconsistent formula

Author Revealed upon completion

Root Cause + Impact

_executeUnwindOperation() declares a new local collateralToWithdraw (L575) that shadows unwindParams.collateralToWithdraw. The user's input is dead code. The internal formula also withdraws ~15% more collateral than calculateUnwindParams() predicts, so users lose more collateral from Aave than expected.

Description

unwindPosition() accepts _collateralToWithdraw (L238), packs it into UnwindParams (L246), and passes it via flash loan callback. But _executeUnwindOperation() never reads the struct field. At L575-577 it creates a new local variable:

// Stratax.sol:575-577
// @> uint256 collateralToWithdraw = (
// @> _amount * debtTokenPrice * (10 ** IERC20(unwindParams.collateralToken).decimals()) * LTV_PRECISION
// @> ) / (collateralTokenPrice * (10 ** IERC20(_asset).decimals()) * liqThreshold);

This divides by liqThreshold (8250 = 82.5%), producing a 1.21x buffer. Meanwhile calculateUnwindParams() uses a 1.05x buffer:

// Stratax.sol:464-468
// @> collateralToWithdraw = (collateralToWithdraw * 1050) / 1000; // 5% buffer

For 1 ETH debt at $3,000 with USDC collateral: internal = 3,636 USDC, external = 3,150 USDC, difference = 486 USDC (15.4%).

Risk

Likelihood: High — Every unwind triggers the internal formula. No special conditions.

Impact: High — For a 10 ETH position, the excess withdrawal is ~4,860 USDC. Extra collateral goes through the DEX swap increasing slippage. For partial unwinds, the excess degrades the remaining position's health factor more than expected, potentially triggering liquidation.

Proof of Concept

Place in test/exploits/Exploit_DeadParam.t.sol. Run: forge test --match-contract Exploit_DeadParam -vv

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test} from "forge-std/Test.sol";
import {Stratax} from "../../src/Stratax.sol";
import {StrataxOracle} from "../../src/StrataxOracle.sol";
import {ConstantsEtMainnet} from "../Constants.t.sol";
import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol";
import {BeaconProxy} from "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol";
contract Exploit_DeadParam is Test, ConstantsEtMainnet {
uint256 constant LTV_PRECISION = 1e4;
// Pure math proof: the two formulas produce different results
function test_FormulasMismatch() public pure {
uint256 debt = 1 ether; uint256 debtPrice = 3000e8;
uint256 colPrice = 1e8; uint256 colDec = 1e6; uint256 debtDec = 1e18;
// Internal formula (_executeUnwindOperation L575-577)
uint256 internal_ = (debt * debtPrice * colDec * LTV_PRECISION) / (colPrice * debtDec * 8250);
// External formula (calculateUnwindParams L464-468)
uint256 external_ = (debtPrice * debt * colDec) / (colPrice * debtDec);
external_ = (external_ * 1050) / 1000;
assertGt(internal_, external_); // 3636 > 3150 USDC
assertGt(internal_ - external_, 400e6); // diff > 400 USDC
}
// Dead parameter proof: collateralToWithdraw=1 and MAX both succeed
function test_DeadParameter() public {
vm.mockCall(USDC_PRICE_FEED, abi.encodeWithSignature("decimals()"), abi.encode(uint8(8)));
vm.mockCall(WETH_PRICE_FEED, abi.encodeWithSignature("decimals()"), abi.encode(uint8(8)));
StrataxOracle oracle = new StrataxOracle();
oracle.setPriceFeed(USDC, USDC_PRICE_FEED); oracle.setPriceFeed(WETH, WETH_PRICE_FEED);
Stratax impl = new Stratax();
UpgradeableBeacon beacon = new UpgradeableBeacon(address(impl), address(this));
bytes memory init = abi.encodeWithSelector(
Stratax.initialize.selector, AAVE_POOL, AAVE_PROTOCOL_DATA_PROVIDER, INCH_ROUTER, USDC, address(oracle));
Stratax stratax = Stratax(address(new BeaconProxy(address(beacon), init)));
address owner = stratax.owner();
vm.mockCall(AAVE_POOL, abi.encodeWithSignature("flashLoanSimple(address,address,uint256,bytes,uint16)"), "");
vm.prank(owner); stratax.unwindPosition(USDC, 1, WETH, 1 ether, "", 0);
vm.prank(owner); stratax.unwindPosition(USDC, type(uint256).max, WETH, 1 ether, "", 0);
// Both succeed — the value is never read
}
}
[PASS] test_FormulasMismatch() (gas: 20923)
Internal: 3636 USDC | External: 3150 USDC | Excess: 486 USDC (15.4%)
[PASS] test_DeadParameter() (gas: 6100475)
collateralToWithdraw=1 and MAX both succeed identically

Recommended Mitigation

Read the user-provided value from the struct instead of recalculating. Align calculateUnwindParams() to use the same buffer strategy:

- uint256 collateralToWithdraw = (
- _amount * debtTokenPrice * (10 ** IERC20(unwindParams.collateralToken).decimals()) * LTV_PRECISION
- ) / (collateralTokenPrice * (10 ** IERC20(_asset).decimals()) * liqThreshold);
- withdrawnAmount = aavePool.withdraw(unwindParams.collateralToken, collateralToWithdraw, address(this));
+ withdrawnAmount = aavePool.withdraw(
+ unwindParams.collateralToken, unwindParams.collateralToWithdraw, address(this)
+ );

Support

FAQs

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

Give us feedback!