DeFiFoundry
50,000 USDC
View results
Submission Details
Severity: medium
Valid

Misdirected Execution Fee Refund in _cancelFlow()

Root Cause & issue LOC.

Within the _cancelFlow() function, when flow == FLOW.WITHDRAW, the contract attempts to refund execution fees by referencing depositInfo[counter] instead of using the deposit ID stored in flowData. However, for a withdrawal flow, the active deposit ID to be refunded is flowData, not counter.

Excerpt from _cancelFlow():

function _cancelFlow() internal {
if (flow == FLOW.DEPOSIT) {
// Correctly references depositInfo[counter] because a deposit in progress uses the latest depositId == counter.
...
} else if (flow == FLOW.WITHDRAW) {
// BUG: This references depositInfo[counter]
try IGmxProxy(gmxProxy).refundExecutionFee(
depositInfo[counter].owner,
depositInfo[counter].executionFee
) {} catch {}
}
flow = FLOW.LIQUIDATION;
nextAction.selector = NextActionSelector.FINALIZE;
}

In contrast, when a user calls withdraw(...), the relevant deposit ID is stored in:

function withdraw(address recipient, uint256 depositId) public payable nonReentrant {
...
flow = FLOW.WITHDRAW;
flowData = depositId; // The deposit being withdrawn
...
}

Hence, under FLOW.WITHDRAW, the deposit ID of interest is flowData, not counter. Using counter here will refund the wrong deposit’s fee or potentially revert if no deposit info is stored at depositInfo[counter].

Attack / Impact Scenario

  1. Setup:

    • Let’s say user A has an active withdrawal in progress for depositId = 5.

    • Meanwhile, counter might be 6 or 7 after other users deposited.

  2. Keeper (or any authorized caller) calls cancelFlow() while flow == FLOW.WITHDRAW.

    • The code incorrectly executes:

      refundExecutionFee(
      depositInfo[counter].owner,
      depositInfo[counter].executionFee
      );
    • This refunds the execution fee to the owner of depositInfo[counter] instead of the owner of depositInfo[flowData].

    • If depositInfo[counter] is a different deposit or does not even exist, the outcome ranges from:

      • Sending the fee to the wrong user, effectively stealing fee refunds from the rightful owner of deposit #5.

      • Or reverting the call if depositInfo[counter] has never been set or is invalid, getting the protocol stuck.

  3. Impact:

    User A (with deposit #5) never receives their rightful refund of execution fees.

    Another deposit’s owner might get those fees undeservedly, or the transaction reverts.

Because _cancelFlow() is restricted by gmxLock and onlyKeeper, the practical exploit could come from:

  • A malicious keeper, who can systematically siphon or misdirect refunds.

  • An incorrect operational call by the legitimate keeper, causing refunds to go to the wrong deposit.

Either way, the bug is real and reproducible whenever cancelFlow() is called during a withdrawal flow.

Severity

This is a medium severity issue. It does not directly let an attacker drain the entire vault but can cause a misdirection or loss of user funds (the execution fee refunds). It also breaks the invariant that each withdrawing user should recover any leftover fee they posted.

FIX

In _cancelFlow(), when flow == FLOW.WITHDRAW, use flowData instead of counter:

else if (flow == FLOW.WITHDRAW) {
uint256 depositId = flowData; // The correct deposit ID for a withdrawal
try IGmxProxy(gmxProxy).refundExecutionFee(
depositInfo[depositId].owner,
depositInfo[depositId].executionFee
) {} catch {}
}
//This ensures the refund is always tied to the correct deposit ID under withdrawal.
Updates

Lead Judging Commences

n0kto Lead Judge 3 months ago
Submission Judgement Published
Validated
Assigned finding tags:

finding_counter_invalid_during_cancelFlow_after_withdrawing

Likelihood: Low, contract has to call cancelFlow after a withdraw, and the settle action is already executed by GMX. Impact: High, the fees will be distributed to the last depositor and not the withdrawer.

Support

FAQs

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