Stratax Contracts

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

Shared Health Factor Across All Positions Enables Cross-Position Liquidation

Author Revealed upon completion

All leveraged positions created through the Stratax contract share a single Aave account (`address(this)`), meaning one position’s unwind can degrade the health factor of all other positions, potentially triggering cascading liquidations and loss of user funds.

Description

  • In a properly isolated leveraged position protocol, each position should have its own independent health factor on the lending platform. This ensures that managing one position (opening, closing, or adjusting) does not affect the risk profile of other positions.

  • In Stratax, every call to `aavePool.supply()`, `aavePool.borrow()`, `aavePool.repay()`, and `aavePool.withdraw()` uses `address(this)` as the user. This means Aave sees all positions as belonging to a single account. When the owner unwinds one position by withdrawing collateral and repaying debt, the global health factor changes, directly impacting every other open position.

// In _executeOpenOperation:
aavePool.supply(_asset, totalCollateral, address(this), 0); // @> All positions supply to same address
aavePool.borrow(
flashParams.borrowToken,
flashParams.borrowAmount,
2,
0,
address(this) // @> All positions borrow from same address
);
// In _executeUnwindOperation:
aavePool.repay(_asset, _amount, 2, address(this)); // @> Repaying one position's debt
withdrawnAmount = aavePool.withdraw(
unwindParams.collateralToken,
collateralToWithdraw,
address(this) // @> Withdrawing collateral affects ALL positions' health factor
);

Risk

Likelihood: High

  • Every time the owner unwinds any position, the collateral backing all other positions is reduced, changing the global health factor. No special conditions are required — this occurs during normal protocol usage

  • The protocol is designed to manage multiple concurrent positions (the code explicitly acknowledges this: “There might be other positions open”), so this issue manifests on every unwind operation.

Impact: High

  • Funds are directly at risk. An improperly calculated unwind pushes the global health factor below 1, causing Aave to liquidate the entire account — destroying all positions, not just the one being unwound.

  • Users have no way to isolate their position risk. A position that was healthy when opened becomes liquidatable due to another position’s unwind.

Proof of Concept

Command to run : orge test --mt testOnePositionImpactingOther --fork-url https://ethereum-rpc.publicnode.com -vvv

function testOnePositionImpactingOther() public {
// STEP 1: Position A — Supply 10 ETH + Borrow 5000 USDC
vm.startPrank(ownerTrader);
IERC20(WETH).transfer(address(stratax), 10 ether);
vm.stopPrank();
vm.startPrank(address(stratax));
IERC20(WETH).approve(AAVE_POOL, 10 ether);
IPoolMinimal(AAVE_POOL).supply(WETH, 10 ether, address(stratax), 0);
IPoolMinimal(AAVE_POOL).borrow(USDC, 5000e6, 2, 0, address(stratax));
vm.stopPrank();
uint256 hfAfterPosA = _getHealthFactor();
// STEP 2: Position B — Supply 10 ETH + Borrow 5000 USDC
deal(WETH, address(stratax), 10 ether);
vm.startPrank(address(stratax));
IERC20(WETH).approve(AAVE_POOL, 10 ether);
IPoolMinimal(AAVE_POOL).supply(WETH, 10 ether, address(stratax), 0);
IPoolMinimal(AAVE_POOL).borrow(USDC, 5000e6, 2, 0, address(stratax));
vm.stopPrank();
uint256 hfAfterPosAB = _getHealthFactor();
// STEP 3: Unwind Position A
vm.startPrank(address(stratax));
IERC20(USDC).approve(AAVE_POOL, 5000e6);
IPoolMinimal(AAVE_POOL).repay(USDC, 5000e6, 2, address(stratax));
IPoolMinimal(AAVE_POOL).withdraw(WETH, 10 ether, address(stratax));
vm.stopPrank();
uint256 hfAfterUnwindA = _getHealthFactor();
// Health factor changed after unwinding Position A, proving cross-position impact
// HF after A+B: 3252472797736274362
// HF after unwind A: 3252472796434334997
assertTrue(hfAfterUnwindA != hfAfterPosAB, "Health factor should change");
}

Recommended Mitigation

Deploy a separate proxy contract per position so that each position has its own isolated Aave account. The existing Beacon Proxy pattern already supports this — each position should be a new `BeaconProxy` instance.

- // Single contract holds all positions
- aavePool.supply(_asset, totalCollateral, address(this), 0);
+ // Deploy a new proxy per position for isolation
+ BeaconProxy positionProxy = new BeaconProxy(address(beacon), initData);
+ Stratax(address(positionProxy)).createLeveragedPosition(...)

Support

FAQs

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

Give us feedback!