53022 sc critical funds are not properly refunded to user which calls for swap on the dex aggregator
Submitted on: Aug 14th 2025 at 17:13:54 UTC by @valkvalue for Attackathon | Plume Network
Report ID: #53022
Report Type: Smart Contract
Report severity: Critical
Target: https://github.com/immunefi-team/attackathon-plume-network-nucleus-boring-vault/blob/main/src/helper/DexAggregatorWrapperWithPredicateProxy.sol
Impacts: Permanent freezing of funds
Description
Brief / Intro
Funds are not properly refunded to a user who initiates a swap via the dex aggregator.
Vulnerability Details
The contract attempts to refund leftover ETH/native token balance, but it does not account for refunds that originate from the swap operation itself when those refunds are returned as ERC20 tokens. The code only handles native ETH refunds and has no logic to capture and re-send ERC20 tokens accidentally left in the aggregator contract by the routed swap.
Relevant excerpt from the 0x (Ork) dex router's swap implementation:
function swap(
IAggregationExecutor executor,
SwapDescription calldata desc,
bytes calldata data
)
external
payable
whenNotPaused()
returns (
uint256 returnAmount,
uint256 spentAmount
)
{
if (desc.minReturnAmount == 0) revert ZeroMinReturn();
IERC20 srcToken = desc.srcToken;
IERC20 dstToken = desc.dstToken;
bool srcETH = srcToken.isETH();
if (desc.flags & _REQUIRES_EXTRA_ETH != 0) {
if (msg.value <= (srcETH ? desc.amount : 0)) revert RouterErrors.InvalidMsgValue();
} else {
if (msg.value != (srcETH ? desc.amount : 0)) revert RouterErrors.InvalidMsgValue();
}
if (!srcETH) {
srcToken.safeTransferFromUniversal(msg.sender, desc.srcReceiver, desc.amount, desc.flags & _USE_PERMIT2 != 0);
}
returnAmount = _execute(executor, msg.sender, desc.amount, data);
spentAmount = desc.amount;
if (desc.flags & _PARTIAL_FILL != 0) {
uint256 unspentAmount = srcToken.uniBalanceOf(address(this));
if (unspentAmount > 1) {
// we leave 1 wei on the router for gas optimisations reasons
unchecked { unspentAmount--; }
spentAmount -= unspentAmount;
srcToken.uniTransfer(payable(msg.sender), unspentAmount);
}
if (returnAmount * desc.amount < desc.minReturnAmount * spentAmount) revert RouterErrors.ReturnAmountIsNotEnough(returnAmount, desc.minReturnAmount * spentAmount / desc.amount);
} else {
if (returnAmount < desc.minReturnAmount) revert RouterErrors.ReturnAmountIsNotEnough(returnAmount, desc.minReturnAmount);
}
address payable dstReceiver = (desc.dstReceiver == address(0)) ? payable(msg.sender) : desc.dstReceiver;
dstToken.uniTransfer(dstReceiver, returnAmount);
}Note: when the order is partially filled, the router returns unspent srcToken to msg.sender (the dex aggregator contract in this flow). The aggregator contract's only refund logic is:
function _refundExcessEth(address payable _recipient) internal {
uint256 balance = address(this).balance;
if (balance > 0) {
(bool success,) = _recipient.call{ value: balance }("");
if (!success) {
revert DexAggregatorWrapper__EthRefundFailed();
}
}
// If balance is 0, do nothing.
}This only handles native ETH. It does not handle ERC20 srcToken refunds that the router may have transferred to the aggregator contract, so those ERC20 tokens can remain stuck inside the aggregator indefinitely.
Impact Details
Permanent funds stuck in the aggregator contract — users' leftover ERC20 tokens (from partial fills or other swap-side refunds) are not returned and thus become unrecoverable through the intended flow.
References
https://vscode.blockscan.com/42161/0x111111125421ca6dc452d289314280a0f8842a65
https://vscode.blockscan.com/ethereum/0x2E1Dee213BA8d7af0934C49a23187BabEACa8764
Severity: Critical — permanent freezing of user funds is possible.
Proof of Concept
Suggested Remediation (high level)
Detect and forward any ERC20 token balances that belong to the user (or were part of the swap flow) back to the user when appropriate, similar to how ETH refunds are handled.
After a swap call, check balances of relevant ERC20 tokens (srcToken and possibly intermediary tokens) and transfer any unexpected balances to the intended recipient.
Ensure the router's behavior (especially when using flags like PARTIAL_FILL) is accounted for: the aggregator should not assume only ETH/native refunds are possible.
Consider explicit handling for all tokens involved in the swap (srcToken, dstToken, and any tokens the executor may refund) to avoid leaving tokens in the aggregator.
(Do not add any behavior not described here without further design review — the above are high-level mitigations derived from the observed issue.)
Was this helpful?