Stratax Contracts

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

Slippage check in _call1InchSwap can be bypassed when 1inch router returns empty bytes and the contract holds a pre-existing token balance

Author Revealed upon completion

Description

  • Stratax::_call1InchSwap executes a swap through the 1inch router and enforces a minimum return amount to protect the user against bad swap rates. When the router returns properly encoded data, returnAmount is decoded directly from the response.

  • When the router call succeeds but returns empty bytes, the function falls back to IERC20(_asset).balanceOf(address(this)) as returnAmount. This value includes any tokens already held by the contract before the swap, not only the amount actually received from it. If the pre-existing balance is large enough to push the total above _minReturnAmount, the slippage check passes even when the actual swap output is below the user's tolerance.

function _call1InchSwap(bytes memory _swapParams, address _asset, uint256 _minReturnAmount)
internal
returns (uint256 returnAmount)
{
(bool success, bytes memory result) = address(oneInchRouter).call(_swapParams);
require(success, "1inch swap failed");
if (result.length > 0) {
(returnAmount,) = abi.decode(result, (uint256, uint256));
} else {
// @> returnAmount = IERC20(_asset).balanceOf(address(this));
}
require(returnAmount >= _minReturnAmount, "Insufficient return amount from swap");
}

Risk

Likelihood:

  • The 1inch router returns empty bytes on a successful call — this is a legitimate response from certain router versions and integration modes, not an error condition.

  • The contract accumulates a residual token balance from prior operations (partial unwinds, leftover swap amounts, tokens sent directly), which is a realistic state for any actively-used contract.

Impact:

  • A swap that returns fewer tokens than the user's _minReturnAmount proceeds without revert, silently delivering less value than the user specified as acceptable.

  • In the extreme case, the router transfers zero tokens to the contract (complete swap failure with an empty-bytes success response), and the check still passes as long as the pre-existing balance exceeds _minReturnAmount, leaving the user's debt unrepaid and their collateral stuck.

Proof of Concept

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test} from "forge-std/Test.sol";
import {Stratax} from "../../src/Stratax.sol";
import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol";
import {BeaconProxy} from "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol";
contract MockERC20 {
mapping(address => uint256) public balanceOf;
function mint(address to, uint256 amount) external { balanceOf[to] += amount; }
function decimals() external pure returns (uint8) { return 18; }
function transfer(address to, uint256 amount) external returns (bool) {
balanceOf[msg.sender] -= amount; balanceOf[to] += amount; return true;
}
function transferFrom(address from, address to, uint256 amount) external returns (bool) {
balanceOf[from] -= amount; balanceOf[to] += amount; return true;
}
function approve(address, uint256) external pure returns (bool) { return true; }
}
/// @notice 1inch mock: transfers actualOutput tokens and returns empty bytes.
contract MockOneInchEmpty {
MockERC20 public token;
uint256 public actualOutput;
constructor(MockERC20 _token, uint256 _out) { token = _token; actualOutput = _out; }
fallback() external {
if (actualOutput > 0) token.transfer(msg.sender, actualOutput);
assembly { return(0, 0) }
}
}
contract StrataxHarness is Stratax {
function exposed_call1InchSwap(bytes memory _swapParams, address _asset, uint256 _min)
external returns (uint256)
{ return _call1InchSwap(_swapParams, _asset, _min); }
}
contract SlippageBypassPoCTest is Test {
uint256 constant PRE_EXISTING = 200e18;
uint256 constant ACTUAL_OUTPUT = 600e18;
uint256 constant MIN_RETURN = 700e18;
/// @notice actual_output (600) < MIN_RETURN (700), but check passes due to pre-existing 200.
function test_slippageBypass() public {
MockERC20 token = new MockERC20();
MockOneInchEmpty router = new MockOneInchEmpty(token, ACTUAL_OUTPUT);
token.mint(address(router), ACTUAL_OUTPUT);
StrataxHarness impl = new StrataxHarness();
UpgradeableBeacon beacon = new UpgradeableBeacon(address(impl), address(this));
bytes memory init = abi.encodeWithSelector(
Stratax.initialize.selector, address(0), address(0), address(router), address(token), address(0)
);
StrataxHarness harness = StrataxHarness(address(new BeaconProxy(address(beacon), init)));
// Pre-existing balance seeds the contract
token.mint(address(harness), PRE_EXISTING);
// actual_output < MIN_RETURN, but should NOT revert — slippage check bypassed
uint256 returned = harness.exposed_call1InchSwap(abi.encodeWithSignature("swap()"), address(token), MIN_RETURN);
assertLt(ACTUAL_OUTPUT, MIN_RETURN, "actual output is below user minimum");
assertEq(returned, PRE_EXISTING + ACTUAL_OUTPUT, "returnAmount inflated by pre-existing balance");
assertTrue(returned >= MIN_RETURN, "slippage check passed despite bad swap rate");
}
}

Recommended Mitigation

function _call1InchSwap(bytes memory _swapParams, address _asset, uint256 _minReturnAmount)
internal
returns (uint256 returnAmount)
{
+ uint256 balanceBefore = IERC20(_asset).balanceOf(address(this));
(bool success, bytes memory result) = address(oneInchRouter).call(_swapParams);
require(success, "1inch swap failed");
if (result.length > 0) {
(returnAmount,) = abi.decode(result, (uint256, uint256));
} else {
- returnAmount = IERC20(_asset).balanceOf(address(this));
+ returnAmount = IERC20(_asset).balanceOf(address(this)) - balanceBefore;
}
require(returnAmount >= _minReturnAmount, "Insufficient return amount from swap");
}

Support

FAQs

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

Give us feedback!