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:
This divides by liqThreshold (8250 = 82.5%), producing a 1.21x buffer. Meanwhile calculateUnwindParams() uses a 1.05x 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
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;
function test_FormulasMismatch() public pure {
uint256 debt = 1 ether; uint256 debtPrice = 3000e8;
uint256 colPrice = 1e8; uint256 colDec = 1e6; uint256 debtDec = 1e18;
uint256 internal_ = (debt * debtPrice * colDec * LTV_PRECISION) / (colPrice * debtDec * 8250);
uint256 external_ = (debtPrice * debt * colDec) / (colPrice * debtDec);
external_ = (external_ * 1050) / 1000;
assertGt(internal_, external_);
assertGt(internal_ - external_, 400e6);
}
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);
}
}
[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)
+ );