DePay Router Double-Plugin Drain
Exploit Transactions
0x9a036058afb58169bfa91a826f5fcf4c0a376e650960669361d61bef99205f35Victim Addresses
0xae60ac8e69414c2dc362d0e6a03af643d1d85b92EthereumLoss Breakdown
Similar Incidents
V3Utils Arbitrary Call Drain
37%WBTC Drain via Insecure Router transferFrom Path
35%Orion Pool Double-Count Exploit
35%0x7CAE Approved-Spender Drain
35%VINU Reserve Drain
35%GPv2Settlement allowance leak lets router drain WETH and USDC
35%Root Cause Analysis
DePay Router Double-Plugin Drain
1. Incident Overview TL;DR
On Ethereum mainnet block 18281130, transaction 0x9a036058afb58169bfa91a826f5fcf4c0a376e650960669361d61bef99205f35 exploited DePayRouterV1 at 0xae60ac8e69414c2dc362d0e6a03af643d1d85b92 and drained its entire USDC balance. The attacker used a flash-borrowed USDC position, deployed a helper contract plus a fake ERC-20, created an attacker-controlled USDC/fake-token pool, then called the public route() entrypoint with the approved DePayRouterV1Uniswap01 plugin duplicated in the plugin array. The first plugin execution spent the attacker-funded USDC chunk; the second execution spent the router's pre-existing 877961918 raw USDC balance. Because the router validated only the final output-token balance, the call succeeded even though the router's valuable input asset was gone. The attacker then removed liquidity from the malicious pair, repaid the flash swap, and transferred 872342961 raw USDC to the originating EOA.
The root cause is an accounting failure in DePayRouterV1: route() funds amounts[0] exactly once, _execute() reuses the same amounts array across an arbitrary approved plugin loop, and _ensureBalance() protects only path[path.length - 1]. In combination with an approved delegate-called plugin that always spends amounts[0], this lets a caller convert router-held assets into attacker-minted junk and still satisfy the router's postcondition.
2. Key Background
DePay's router architecture separates orchestration from swap execution. DePayRouterV1 is a public routing contract. For each call, it pulls the initial input token from msg.sender, then executes an arbitrary ordered list of approved plugins supplied by the caller. Approved plugins are tracked in DePayRouterV1Configuration at 0x6ef8833d250f2df4e7b66eca01ca5a0d2a34b2ff, where approvedPlugins[plugin] = plugin marks a plugin as active.
The critical plugin here is DePayRouterV1Uniswap01 at 0xe04b08dfc6caa0f4ec523a3ae283ece7efe00019. The router calls approved plugins through delegatecall when plugin.delegate() returns true. That means the plugin executes in the router's storage and balance context, so address(this) inside the plugin is the router itself. Any router-held ERC-20 balance and any allowance granted from the router become directly spendable by the plugin.
The attack matters because the target router already held a valuable residual balance: 877961918 raw USDC before the exploit transaction. Once a public router can be induced to spend assets it already holds, a permissionless adversary can turn protocol leftovers into an ACT drain.
3. Vulnerability Analysis & Root Cause Summary
This is an ATTACK-category ACT exploit against DePay's router accounting, not against Uniswap or USDC. The router assumes that a single call-funded input amount will back the entire route. That assumption is false because _execute() loops over an attacker-controlled plugin list and passes the unchanged amounts array into every plugin execution. The approved Uniswap plugin uses amounts[0] as the exact input amount every time it executes, so repeating the same plugin repeats the spend. The router never decrements a per-call spend budget and never checks whether cumulative spending stays bounded by what was transferred in for the current call. Its only postcondition is that the final output token balance is not lower than before the route. By choosing an attacker-minted output token and an attacker-controlled pool, the adversary can satisfy that output-only check while draining a different valuable token held by the router.
The violated invariant is straightforward: a successful route() call must not reduce the router's pre-existing valuable asset balances beyond what the caller explicitly funded for that call, unless the route is explicitly authorized to consume those balances and preserve equivalent value. The concrete breakpoint is the combination of route() funding once, _execute() reusing the same amounts for every plugin iteration, and _ensureBalance() validating only the output token.
4. Detailed Root Cause Analysis
The verified DePayRouterV1 source shows the vulnerable control flow:
function route(
address[] calldata path,
uint[] calldata amounts,
address[] calldata addresses,
address[] calldata plugins,
string[] calldata data
) external payable returns (bool) {
uint balanceBefore = _balanceBefore(path[path.length - 1]);
_ensureTransferIn(path[0], amounts[0]);
_execute(path, amounts, addresses, plugins, data);
_ensureBalance(path[path.length - 1], balanceBefore);
return true;
}
function _execute(...) internal {
for (uint i = 0; i < plugins.length; i++) {
require(_isApproved(plugins[i]), "DePay: Plugin not approved!");
IDePayRouterV1Plugin plugin =
IDePayRouterV1Plugin(configuration.approvedPlugins(plugins[i]));
if (plugin.delegate()) {
(bool success, bytes memory returnData) = address(plugin).delegatecall(
abi.encodeWithSelector(plugin.execute.selector, path, amounts, addresses, data)
);
require(success, string(returnData));
} else {
plugin.execute(path, amounts, addresses, data);
}
}
}
function _ensureBalance(address tokenOut, uint balanceBefore) private view {
require(_balance(tokenOut) >= balanceBefore, "DePay: Insufficient balance after payment!");
}
That source establishes the first half of the bug: the router transfers in amounts[0] once, then loops over an arbitrary plugin array while reusing the same amounts and only protecting tokenOut.
The verified DePayRouterV1Uniswap01 source establishes the second half:
function execute(
address[] calldata path,
uint[] calldata amounts,
address[] calldata,
string[] calldata
) external payable returns (bool) {
if (path[0] == ETH) {
IUniswapV2Router01(UniswapV2Router02).swapExactETHForTokens{ value: amounts[0] }(
amounts[1], uniPath, address(this), amounts[2]
);
} else if (path[path.length - 1] == ETH) {
Helper.safeApprove(path[0], UniswapV2Router02, amounts[0]);
IUniswapV2Router01(UniswapV2Router02).swapExactTokensForETH(
amounts[0], amounts[1], uniPath, address(this), amounts[2]
);
} else {
Helper.safeApprove(path[0], UniswapV2Router02, amounts[0]);
IUniswapV2Router02(UniswapV2Router02).swapExactTokensForTokens(
amounts[0], amounts[1], uniPath, address(this), amounts[2]
);
}
}
Every plugin execution spends amounts[0] as exact input. Because the router delegate-calls this plugin, each spend comes from the router's own balance. Nothing in the router tracks that one plugin execution already consumed the funded input.
The seed trace shows the exploit in exactly that shape. The attacker helper contract 0xba2aa7426ec6529c25a38679478645b2db5fa19b creates a malicious USDC/fake-token pair, then calls route() with the approved plugin twice. In the trace, the approved plugin executes twice, and the router transfers 877961918 raw USDC into the attacker-controlled pair twice:
0xe04b08Dfc6CaA0F4Ec523a3Ae283Ece7efE00019::execute(
[USDC, 0xbA2AA7426Ec6529C25A38679478645b2DB5fa19B],
[877961918, 0, 1696472503],
[...]
) [delegatecall]
FiatTokenV2_1::transferFrom(
0xae60aC8e69414C2Dc362D0e6a03af643d1D85b92,
0xe5Da410c963C53eEcEE1Ec8EC549901d986bCC8c,
877961918
)
0xe04b08Dfc6CaA0F4Ec523a3Ae283Ece7efE00019::execute(
[USDC, 0xbA2AA7426Ec6529C25A38679478645b2DB5fa19B],
[877961918, 0, 1696472503],
[...]
) [delegatecall]
FiatTokenV2_1::transferFrom(
0xae60aC8e69414C2Dc362D0e6a03af643d1D85b92,
0xe5Da410c963C53eEcEE1Ec8EC549901d986bCC8c,
877961918
)
The first transfer is backed by the attacker-funded input. The second transfer drains the router's pre-existing USDC. The malicious pair returns attacker-minted fake tokens to the router, so the router ends the route with a larger output-token balance and _ensureBalance() passes even though its USDC balance has gone to zero.
The balance diff confirms the router-side loss and attacker-side profit:
{
"token": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
"holder": "0xae60ac8e69414c2dc362d0e6a03af643d1d85b92",
"before": "877961918",
"after": "0",
"delta": "-877961918"
}
{
"token": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
"holder": "0x7f284235aef122215c46656163f39212ffa77ed9",
"before": "0",
"after": "872342961",
"delta": "872342961"
}
The profit predicate is now deterministic. The receipt shows 5114071 gas used at 6356438015 wei effective gas price, for 32507275315809065 wei gas cost. A reserve query against the canonical Uniswap V2 USDC/WETH pair 0xb4e16d0168e52d35cacd2c6185b44281ec28c9dc at block 18281129 returns reserves of 28076044396331 raw USDC and 17025683668211132613163 wei WETH, which implies a gas cost of 53605818 raw USDC at the pre-state spot rate. With attacker EOA USDC changing from 0 to 872342961, the net delta is 818737143 raw USDC.
5. Adversary Flow Analysis
The adversary flow is a single-transaction ACT sequence:
- The attacker EOA
0x7f284235aef122215c46656163f39212ffa77ed9sends transaction0x9a036058afb58169bfa91a826f5fcf4c0a376e650960669361d61bef99205f35. - The transaction deploys helper contract
0xba2aa7426ec6529c25a38679478645b2db5fa19b. - The helper flash-borrows
1755923836raw USDC from the public Uniswap V2 USDC/WETH pair at0xb4e16d0168e52d35cacd2c6185b44281ec28c9dc. - The helper deploys an attacker-controlled ERC-20, seeds a malicious USDC/fake-token pool with dust USDC liquidity, and approves the DePay router and Uniswap router.
- The helper calls
DePayRouterV1.route()with path[USDC, fakeToken]and the approved plugin array[uniswapPlugin, uniswapPlugin]. - The first plugin execution consumes the attacker-funded
877961918raw USDC chunk. The second execution consumes the router's pre-existing877961918raw USDC balance. - The router receives attacker-minted fake tokens, so the route's output-token-only postcondition passes.
- The helper removes liquidity from the malicious pair, recovers the drained USDC, repays the Uniswap flash swap, and transfers the remaining
872342961raw USDC to the originating EOA.
The trace captures the unwind and profit realization explicitly:
0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D::removeLiquidity(
0xbA2AA7426Ec6529C25A38679478645b2DB5fa19B,
FiatTokenProxy: [0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48],
999999999999000,
1,
1,
0xbA2AA7426Ec6529C25A38679478645b2DB5fa19B,
1696471503
)
...
FiatTokenV2_1::transfer(0x7f284235aEF122215c46656163f39212Ffa77ED9, 872342961)
This sequence is permissionless from end to end. It requires no privileged DePay role, no attacker reuse of privileged contracts, and no private orderflow assumption.
6. Impact & Losses
The direct victim is the public DePayRouterV1 instance at 0xae60ac8e69414c2dc362d0e6a03af643d1d85b92. Its entire USDC balance was drained:
- Token: USDC
- Raw loss:
877961918 - Decimals:
6 - Human-readable loss:
877.961918 USDC
The router finished the transaction holding attacker-minted fake tokens instead of the USDC it previously owned. The attacker EOA realized 872342961 raw USDC gross, equivalent to 872.342961 USDC. After subtracting the gas cost converted at the pre-state USDC/WETH rate (53605818 raw USDC), the deterministic net value delta is 818737143 raw USDC, or 818.737143 USDC.
The scope of impact is limited to router instances that both hold valuable residual balances and still permit repeated approved delegate-called plugins under the same accounting model.
7. References
- Seed transaction:
https://etherscan.io/tx/0x9a036058afb58169bfa91a826f5fcf4c0a376e650960669361d61bef99205f35 - Seed trace:
/workspace/session/artifacts/collector/seed/1/0x9a036058afb58169bfa91a826f5fcf4c0a376e650960669361d61bef99205f35/trace.cast.log - Seed balance diff:
/workspace/session/artifacts/collector/seed/1/0x9a036058afb58169bfa91a826f5fcf4c0a376e650960669361d61bef99205f35/balance_diff.json - Seed metadata and receipt fields:
/workspace/session/artifacts/collector/seed/1/0x9a036058afb58169bfa91a826f5fcf4c0a376e650960669361d61bef99205f35/metadata.json - Verified
DePayRouterV1source:https://etherscan.io/address/0xae60ac8e69414c2dc362d0e6a03af643d1d85b92#code - Verified
DePayRouterV1Uniswap01source:https://etherscan.io/address/0xe04b08dfc6caa0f4ec523a3ae283ece7efe00019#code - Verified
DePayRouterV1Configurationsource:https://etherscan.io/address/0x6ef8833d250f2df4e7b66eca01ca5a0d2a34b2ff#code - Auditor summary of deterministic profit conversion:
/workspace/session/artifacts/auditor/iter_1/current_analysis_result.json