Dexible selfSwap Router-Validation Failure / Approval Drain (2023-02-17)

  • Name: Dexible selfSwap router-validation failure / approval drain
  • Date: 2023-02-17
  • Protocol: Dexible
  • Chain: Ethereum + Arbitrum
  • Category: router approval abuse / arbitrary external call / token-approval drain / route-validation failure
  • Loss: about 1.5 million on Ethereum and about $450,000 on Arbitrum
  • Summary: Dexible’s v2 selfSwap() flow let any caller supply arbitrary route targets, spender addresses, and calldata. The implementation then approved the attacker-chosen spender and executed rr.router.call(rr.routerData) for each route step without proving that the target was a legitimate DEX or that the calldata matched an intended swap. That meant an attacker could make Dexible call token contracts directly with transferFrom(victim, attacker, amount) payloads. Because victims had already approved Dexible as spender, Dexible’s own allowance authority became the theft primitive.
  • Trigger / entry point:
    • Public exploit writeups identified Dexible’s vulnerable v2 contract at 0xde62e1b0edaa55aac5ffbe21984d321706418024; Sourcify-verified source for the v2 implementation at 0x33e690aea97e4ef25f0d140f1bf044d663091daf preserves the critical logic.
    • Dexible.selfSwap() accepted a SwapTypes.SelfSwap struct whose routes array let the caller choose each route’s router, spender, routeAmount, and raw routerData.
    • SwapHandler.fill() then looped those route entries, called safeApprove(rr.spender, rr.routeAmount.amount), and executed (bool s, ) = rr.router.call(rr.routerData).
    • No onchain allowlist or selector binding forced rr.router to be a real DEX or routerData to encode a legitimate swap.
  • Root cause:
    • Dexible treated attacker-supplied route instructions as if they were trustworthy swap policy, but selfSwap() exposed that generic execution surface directly to arbitrary callers.
    • The intended invariant was effectively the router path may only execute approved swap integrations for the declared trade; the deployed code only enforced broad shape constraints like fee-token validity and then trusted arbitrary route targets, spender identities, and calldata.
    • In practice the user-supplied routes array became a live onchain route-policy oracle for who gets approval, which contract is called, and what that call is allowed to mean, even though Dexible already held wallet-spender authority.
    • Because Dexible already sat in many users’ ERC-20 allowance tables, the missing route validation turned a normal aggregator contract into a wallet-drain primitive.
    • This was not a custody compromise or oracle failure. It was a call-surface authorization failure amplified by stale approvals.
  • Exploit path:
    1. The attacker identified wallets that had already approved Dexible’s contracts as ERC-20 spenders.
    2. They called the vulnerable v2 selfSwap() path and supplied a crafted routes array.
    3. selfSwap() wrapped that attacker-controlled data into a generic SwapRequest and forwarded it into fill().
    4. preCheck() only enforced fee-token relationships and transferred the first route amount from the attacker; it did not authenticate downstream route targets.
    5. Inside fill(), Dexible approved the attacker-chosen spender and then executed an arbitrary low-level call to the attacker-chosen router using attacker-chosen calldata.
    6. By pointing router at token contracts and encoding transferFrom(victim, attacker, amount) in routerData, the attacker made Dexible spend victims’ approved balances directly.
    7. The attacker repeated the pattern across approved wallets on Ethereum and Arbitrum until the contracts were paused and users began revoking approvals.
  • What the attacker needed to be true:
    • Victims needed existing token approvals to Dexible large enough to monetize.
    • selfSwap() and fill() had to remain live long enough to sweep approved wallets.
    • Dexible could not enforce an allowlist for route targets or a selector / semantic check on routerData.
    • The attacker needed only minimal legitimate input for the route setup; the real value came from the victims’ pre-existing approvals, not from attacker-owned inventory.
  • Technical anatomy worth remembering:
    • The dangerous line was not some obscure math bug; it was the generic execution pair of approve(attacker-chosen spender) followed by call(attacker-chosen target, attacker-chosen calldata) inside an approved router.
    • selfSwap() is the preserved lesson here. A convenience path that lets users bring their own route data silently inherits the full trust burden of a generic execution engine.
    • Dexible is the user-supplied-route cousin of the later Revert-style family: instead of mutable offchain aggregator metadata crossing the trust boundary, the route array itself crossed it and directly became wallet-funded execution policy.
    • The exploit family later reappeared in Socket and LI.FI, but Dexible is an earlier clean example of the route meaning was never authenticated before an approved contract executed it variant.
    • The protocol did not need to hold victim funds in escrow. The victims’ wallets were the balance sheet, and Dexible’s allowance slot was the authorization boundary that failed.
  • Representative transactions and addresses:
    • Attacker address used across public writeups: 0x684083f312ac50f538cc4b634d85a2feafaab77a
    • Vulnerable v2 proxy / entry contract: 0xde62e1b0edaa55aac5ffbe21984d321706418024
    • Sourcify-verified v2 implementation preserving selfSwap() and fill(): 0x33e690aea97e4ef25f0d140f1bf044d663091daf
    • Example Ethereum exploit transaction: 0x138daa4cbeaa3db42eefcec26e234fc2c89a4aa17d6b1870fc460b2856fd11a6
    • Example heavily impacted victim highlighted in public coverage: BlockTower-associated address 0x58f5f0684c381fcfc203d77b2bba468ebb29b098

Three approval-route promotions this incident collapsed

  1. Dexible already held real spender reach into victim wallets

    • Users had granted Dexible live ERC-20 allowances, often well beyond the amount involved in any one honest swap.
    • That meant the raw approved spender can ask tokens to honor transferFrom(...) precondition was already true before the attacker touched route calldata.
  2. Attacker-shaped route data was still promoted into canonical swap authority

    • selfSwap() accepted caller-chosen router, spender, and routerData fields and forwarded them into fill() as if they still described one legitimate route step.
    • Dexible therefore promoted some user-supplied route object exists into this exact route object may exercise Dexible's spender authority, even though the route had never been bound to an allowlisted DEX, selector family, or recipient meaning.
  3. That accepted route object still inherited ordinary downstream approval-and-call power

    • Once the route survived ingress, fill() gave it two authority edges in sequence: approve the attacker-chosen spender, then call the attacker-chosen router with attacker-chosen calldata.
    • That final promotion is what turned stale wallet approvals into immediate theft. A route object that should only have described one swap step was still allowed to exercise near-generic downstream execution authority against victim-approved balances.
  • Response / mitigation:
    • Dexible paused affected contracts after the exploit and warned users through its front end and public channels.
    • The practical user mitigation was immediate approval revocation, because the exploit monetized lingering wallet approvals rather than assets already escrowed inside Dexible.
    • The engineering fix for this family is to make route validation mandatory at the contract boundary: allowlisted targets, allowlisted selectors, tighter route binding, and narrower approval scope.

Pattern tags

  • arbitrary-call-token-approval-drain
  • User-supplied route array became live approval policy: selfSwap() accepted attacker-chosen router, spender, routeAmount, and raw routerData, so Dexible let mutable route description choose both who received approval and who got called
  • approve(...) plus arbitrary call(...) lived in the same approved spender: SwapHandler.fill() first granted the attacker-shaped spender allowance over the route amount and then executed rr.router.call(rr.routerData) without proving that either value belonged to a legitimate swap integration
  • Existing wallet approvals funded the theft path: victims had already approved Dexible, so once the route array could redefine downstream execution meaning, Dexible’s own spender identity became the mass-drain primitive
  • Aggregator flexibility outran route authentication: Dexible treated bring your own route as a product feature, but never preserved one narrower route-local object tying payer scope, approved router target, approved spender target, selector family, and final recipient semantics together before execution

Why the exploit worked

  • Dexible’s architecture already put it inside users’ allowance tables, which meant route validation was not a nice-to-have parser concern. It was the real custody boundary. Once selfSwap() let arbitrary callers author the route array, Dexible gave attacker input the right to choose both who would receive approval and which contract Dexible itself would talk to next.
  • The dangerous primitive was not one line in isolation, but the paired sequence of safeApprove(rr.spender, rr.routeAmount.amount) followed by rr.router.call(rr.routerData). Those two steps only make sense when one authenticated route object proves that the spender, router, calldata, payer, and recipient all belong to the same quoted swap story. Dexible never built that object.
  • The attacker therefore did not need to counterfeit liquidity, manipulate prices, or compromise admins. They only needed Dexible to truthfully exercise the allowance authority it already had while speaking attacker-shaped calldata to attacker-shaped targets.
  • Dexible is useful because the route-policy oracle was visible and explicit: the exploit did not require offchain metadata corruption or a hidden helper. The mutable route array itself crossed the trust boundary and became onchain execution policy.
  • That makes Dexible a clean early exemplar of the broader approval-funded route problem later seen in Revert, Socket, and LI.FI: route meaning remained more flexible than spender authority was allowed to be.

Detection and audit pivots

  • Trace every path where user-provided route arrays or swap-step structs influence both an approval target and a later external call target. The specific red flag is router, spender, or equivalent fields surviving from calldata into approve(...) and .call(...) without a shared allowlist or route commitment.
  • Audit bring your own route convenience functions as wallet-drain surfaces, not just swap helpers. If the contract commonly holds user approvals, any user-authored integration tuple should be reviewed as potential delegated transferFrom() authority.
  • Add a negative test for the exact failure shape: can a token contract itself occupy the router slot and can attacker-crafted routerData make the already-approved contract execute transferFrom(victim, attacker, amount)? If that test can ever pass, the router is already a wallet drainer.
  • Treat approval-target choice as route authority, not plumbing. If user input can choose spender separately from router, review whether the contract is letting attacker input define two different external authority edges in the same route step.
  • Monitor for aggregator execution where downstream targets are token contracts rather than expected DEX routers, and for route plans where the approval target is not the same small immutable set of known integrations. In Dexible-style systems, those are high-signal anomaly pivots.

Failure-prevention checklist

  • Force every approval-consuming swap path through one mandatory validation funnel. No selfSwap(), helper, facet, or protocol-step shortcut should be able to reach approve(...) plus low-level external call execution without the same route-policy checks firing.
  • Bind one route-local authority object before execution: payer, token in, amount in, approved spender, approved router target, approved selector family, recipient semantics, and replay / expiry boundary should travel together rather than as loosely related fields.
  • Deny token contracts and generic token-pull helpers as route targets by default. If the protocol ever needs to call them directly, that path should live behind a separate, tightly scoped flow rather than inside ordinary swap routing.
  • Do not let callers choose spender and router independently unless the protocol can prove the pair belongs to one reviewed integration step. A healthy router should not hand attacker input two separate authority edges in the same route entry.
  • Pair route validation with narrow approval scope: exact-amount pulls, short-lived permits, or approval-reset patterns reduce the blast radius when one route path slips.
  • Pin integration identity at the code level where possible. If a route allowlist only names addresses, an upgradeable or repointed endpoint can silently turn an approved route into attacker logic later.
  • Add negative tests for the exact Dexible failure shape: token contract occupying the router slot, attacker calldata encoding transferFrom(victim, attacker, amount), and the router already holding victim allowance.
  • Keep spender-exposure inventory and emergency disable paths ready. For approval drains, fast route shutdown and fast revoke guidance are part of the design, not just incident response polish.

Audit prompts

  • Which public entrypoints can make the contract both approve a downstream spender and then execute a low-level external call while acting as an already-approved wallet spender?
  • Can user-controlled route structs choose router, spender, and raw calldata independently, or does one authenticated route object bind those fields together?
  • Can a token contract itself occupy the route target slot, or can route calldata reach an internal helper that ultimately performs transferFrom() with attacker-shaped arguments?
  • Do convenience paths such as selfSwap() bypass the main integration-validation middleware that more curated swap flows use?
  • Where is the exact relationship enforced between quoted trade meaning and executed calldata meaning? Could the UI describe one swap while settlement honors a completely different external call story?
  • Are route targets allowlisted only by address, or also by selector family and code identity?
  • If the protocol already sits in users’ allowance tables, what is the shortest path from that spender identity to an arbitrary .call(...) under attacker input?
  • During incident response, can operators quickly answer which contracts hold the largest aggregate user approvals and which reachable functions can exercise them?

Lessons that should already be standard

  • Bring your own route is not harmless UX flexibility when the contract is an approved spender. It is a generic execution engine unless route meaning is authenticated onchain.
  • Approval drains are not meaningfully separate from custody risk. If the protocol can already pull from user wallets, route validation is a wallet-custody boundary.
  • approve(attacker-shaped spender) plus call(attacker-shaped target, attacker-shaped calldata) inside the same approved contract is already enough to classify the path as a potential wallet drainer.
  • Mutable route description should never outrun spender authority. The route array is not just metadata; in designs like Dexible it becomes the real policy object deciding who the approved contract may talk to and why.
  • We do not hold user deposits is not a defense when lingering approvals are the balance sheet. User-wallet exposure inventory and revoke playbooks belong in the protocol security model.
  • Router hardening has to survive convenience helpers and future modules. One flexible shortcut can silently undo the validation discipline of the main path.

Why this incident matters

  • It is one of the cleanest pre-2024 examples of an aggregator draining user wallets by abusing allowance authority rather than by losing custody or compromising admins.
  • It shows how bring your own route product flexibility can silently collapse into arbitrary-call execution if the route is not authenticated onchain.
  • The preserved healthy-side lesson is sharper than validate routers better: Dexible needed one route-local authority object that kept payer scope, approved targets, approved selectors, spender identity, and recipient semantics fused above raw wallet allowance before any approve + call pair was allowed to execute.
  • Dexible is also a useful family anchor because the policy object was visibly the user-supplied route array itself. Later cases such as Revert, Socket, and LI.FI changed the packaging — offchain metadata, nested helper calldata, or skipped middleware — but kept the same underlying mistake of letting mutable route meaning outrun wallet-spender authority.

Internal linkages

  • Core recurring pattern: arbitrary-call-token-approval-drain.
  • Positive-design companion: approval-funded-route-authority-truth-security-object — the sharper follow-up is what route-local authorization object should have bound Dexible’s approved spender surface, victim payer scope, allowed route targets/selectors, and final recipient meaning before selfSwap() could spend any wallet allowance.
  • Broader spender-boundary companion: spender-authority-truth-security-object — useful when the sharper question is not just router validation but why any already-approved contract may exercise a wallet allowance at all.
  • Adjacent pattern: privileged-message-dispatcher-abuse — both revolve around overpowered generic execution surfaces, but Dexible monetized wallet approvals rather than protocol-owned admin privileges.

Control surface