All incidents

Spectra Router KYBERSWAP arbitrary call drains SdCrvCompounder

Share
Jul 23, 2024 14:53 UTCAttackLoss: 188,013.37 SdCrvCompounder-shareManually checked2 exploit txWindow: 7m 24s
Estimated Impact
188,013.37 SdCrvCompounder-share
Label
Attack
Exploit Tx
2
Addresses
2
Attack Window
7m 24s
Jul 23, 2024 14:53 UTC → Jul 23, 2024 15:01 UTC

Exploit Transactions

TX 1Ethereum
0x491cf8b2a5753fdbf3096b42e0a16bc109b957dc112d6537b1ed306e483d0744
Jul 23, 2024 14:53 UTCExplorer
TX 2Ethereum
0x004f3cea20431562b64ad365762e36221966e0235bec300b04073c83c6aa8396
Jul 23, 2024 15:01 UTCExplorer

Victim Addresses

0x279a7dbfae376427ffac52fcb0883147d42165ffEthereum
0x43e54c2e7b3e294de3a155785f52ab49d87b9922Ethereum

Loss Breakdown

188,013.37SdCrvCompounder-share

Similar Incidents

Root Cause Analysis

Spectra Router KYBERSWAP arbitrary call drains SdCrvCompounder

1. Incident Overview TL;DR

An unprivileged adversary exploited the legacy Spectra Router KYBER_SWAP command to spend a victim's SdCrvCompounder allowance and drain all of the victim's SdCrvCompounder shares into an adversary-controlled aggregator, then swept those shares to an adversary EOA in a follow-up transaction. The attack uses only public state (balances and allowances), standard Ethereum transaction rules, and existing Router behavior.

Router's KYBER_SWAP handler in Dispatcher::_dispatch allows an arbitrary kyberRouter address and arbitrary call data, enabling adversaries to call SdCrvCompounder.transferFrom(victim, adversary, allowance) under the Router's identity and thereby abuse user allowances without the victim submitting any transaction.

2. Key Background

  • SdCrvCompounder is a vault-like ERC20 share token over sdCRV where users deposit assets and receive shares; the share token exposes standard balanceOf and allowance/transferFrom semantics.

  • Spectra Router (proxy 0x3d20601ac0ba9cae4564ddf7870825c505b69f1a) interprets byte-encoded command sequences via Dispatcher::_dispatch and may hold ERC20 allowances from users who authorize it to move their assets across complex strategies.

  • In the legacy Router implementation at 0x51bdbfcd7656e2c25ad1bc8037f70572b7142ecc, the KYBER_SWAP command (Commands.KYBER_SWAP = 0x12) forwards arbitrary call data to an attacker-controlled kyberRouter address using kyberRouter.call, while using Router-held allowances to approve tokenIn where applicable.

  • Aggregator contract 0xba8ce86147ded54c0879c9a954f9754a472704aa is an unverified helper used by the observed adversary to craft Router commands and later sweep accumulated SdCrvCompounder shares to an EOA, but the same KYBER_SWAP pattern can be invoked directly by any EOA calling Router::execute.

3. Vulnerability Analysis & Root Cause Summary

The root cause is an arbitrary external-call primitive in Router's KYBER_SWAP implementation: kyberRouter and targetData are taken directly from user-provided inputs and executed via kyberRouter.call under the Router's identity, so any contract for which Router is an approved spender (such as SdCrvCompounder) can be instructed to transfer tokens from a victim to an adversary.

In the affected deployment, users deposit into SdCrvCompounder and often grant large or full allowances of their SdCrvCompounder shares to the Spectra Router proxy 0x3d20601ac0ba9cae4564ddf7870825c505b69f1a so Router can orchestrate complex multi-step strategies. In the legacy Router logic at 0x51bdbfcd7656e2c25ad1bc8037f70572b7142ecc, the KYBER_SWAP command is implemented as a generic external-call primitive: given inputs (kyberRouter, tokenIn, amountIn, tokenOut, expectedAmountOut, targetData), Dispatcher::_dispatch optionally approves tokenIn to kyberRouter and unconditionally invokes kyberRouter.call(targetData) (or with msg.value for ETH), with kyberRouter and targetData taken directly from calldata. There is no restriction that kyberRouter be a Kyber router nor that targetData match any specific function signature. An adversary can therefore set kyberRouter to the SdCrvCompounder proxy and choose targetData to encode SdCrvCompounder.transferFrom(victim, adversary, allowance), where victim is any address that has previously granted SdCrvCompounder.allowance(victim, routerProxy) >= balanceOf(victim). When Router executes this KYBER_SWAP, SdCrvCompounder sees msg.sender = routerProxy (the approved spender) and executes transferFrom, moving the victim's entire share balance to an adversary-controlled contract or EOA. The observed attack uses an unverified aggregator 0xba8ce86147ded54c0879c9a954f9754a472704aa to construct such a command targeting victim 0x279a7dbfae376427ffac52fcb0883147d42165ff and then a sweep transaction to transfer the stolen shares from the aggregator to EOA 0x53635bf7b92b9512f6de0eb7450b26d5d1ad9a4c, but any unprivileged adversary can replicate the core pattern by calling Router::execute directly with an appropriate KYBER_SWAP command. The upgraded Router logic at 0x7dcdea738c2765398baf66e4dbbcd2769f4c00dc removes this KYBER_SWAP branch and instead reverts on command id 0x12, confirming that the arbitrary-call primitive was recognized and removed as unsafe.

Key vulnerable components:

  • Dispatcher::_dispatch KYBER_SWAP branch in legacy Router implementation at 0x51bdbfcd7656e2c25ad1bc8037f70572b7142ecc (Commands.KYBER_SWAP = 0x12, arbitrary kyberRouter.call(targetData) under Router identity).

  • Router.execute in proxy 0x3d20601ac0ba9cae4564ddf7870825c505b69f1a, which interprets attacker-supplied command sequences and sets msgSender to the original caller, while SdCrvCompounder allowances are granted to the proxy address.

  • SdCrvCompounder share token 0x43e54c2e7b3e294de3a155785f52ab49d87b9922, which follows standard ERC20 allowance semantics and therefore honors transferFrom calls by Router proxy 0x3d20601ac0ba9cae4564ddf7870825c505b69f1a up to the approved allowance for each depositor.

Invariant and concrete breakpoint:

  • Invariant: Router-driven command sequences must not allow arbitrary external contracts to spend ERC20 allowances that users have granted to the Router beyond user-authored strategies for their own balances; in particular, a third party must not be able to cause Router to execute transferFrom(victim, adversary, allowance(victim, Router)) for a victim who did not author the command sequence.

  • Breakpoint: Dispatcher::_dispatch in legacy Router implementation 0x51bdbfcd7656e2c25ad1bc8037f70572b7142ecc (artifacts/root_cause/data_collector/iter_2/contract/1/0x51bdbfcd7656e2c25ad1bc8037f70572b7142ecc/source/src/router/Dispatcher.sol:322-343) handles Commands.KYBER_SWAP = 0x12 by decoding an unconstrained kyberRouter address and arbitrary targetData from _inputs and then calling kyberRouter.call{value: msg.value}(targetData) or kyberRouter.call(targetData) without any allowlist or semantic checks. Because Router proxy 0x3d20601ac0ba9cae4564ddf7870825c505b69f1a is the spender for SdCrvCompounder allowances, an adversary can set kyberRouter to the SdCrvCompounder proxy and targetData to SdCrvCompounder.transferFrom(0x279a7dbfae376427ffac52fcb0883147d42165ff, 0xba8ce86147ded54c0879c9a954f9754a472704aa, 188013365080870249823427), breaking the invariant by deterministically spending the victim's allowance and transferring all of their shares to an adversary-controlled address.

4. Detailed Root Cause Analysis

4.1 Pre-state at block 20369957

Ethereum mainnet state immediately before block 20369957, where (a) SdCrvCompounder share token 0x43e54c2e7b3e294de3a155785f52ab49d87b9922 records victim 0x279a7dbfae376427ffac52fcb0883147d42165ff with balance 188013365080870249823427 and an allowance of the same amount to Spectra Router proxy 0x3d20601ac0ba9cae4564ddf7870825c505b69f1a; (b) aggregator 0xba8ce86147ded54c0879c9a954f9754a472704aa and EOA 0x53635bf7b92b9512f6de0eb7450b26d5d1ad9a4c hold zero SdCrvCompounder shares; and (c) Router logic 0x51bdbfcd7656e2c25ad1bc8037f70572b7142ecc is the implementation used by proxy 0x3d20601ac0ba9cae4564ddf7870825c505b69f1a.

Key on-chain evidence for the pre-state includes:

  • artifacts/root_cause/seed/1/0x491cf8b2a5753fdbf3096b42e0a16bc109b957dc112d6537b1ed306e483d0744/metadata.json

  • artifacts/root_cause/seed/1/0x491cf8b2a5753fdbf3096b42e0a16bc109b957dc112d6537b1ed306e483d0744/balance_diff.json

  • artifacts/root_cause/seed/1/0x491cf8b2a5753fdbf3096b42e0a16bc109b957dc112d6537b1ed306e483d0744/trace.cast.log

  • artifacts/root_cause/data_collector/iter_2/balance_diff/1/0x004f3cea20431562b64ad365762e36221966e0235bec300b04073c83c6aa8396/balance_diff.json

  • artifacts/root_cause/data_collector/iter_2/tx/1/0x004f3cea20431562b64ad365762e36221966e0235bec300b04073c83c6aa8396/debug_trace_callTracer.json

  • artifacts/root_cause/seed/1/0x606e2d9ba601662881b260bb6c2dc3b10d0abb2e/src/concentrator/stakedao/SdCrvCompounder.sol

  • artifacts/root_cause/data_collector/iter_2/contract/1/0x51bdbfcd7656e2c25ad1bc8037f70572b7142ecc/source/src/router/Dispatcher.sol

Representative SdCrvCompounder source excerpt (ERC20-style share token):

contract SdCrvCompounder is AladdinCompounder, SdCRVLocker, ISdCrvCompounder {
  using SafeMathUpgradeable for uint256;
  using SafeERC20Upgradeable for IERC20Upgradeable;
  // ... vault and reward logic over an ERC20 share token ...
}

KYBER_SWAP implementation excerpt from the legacy Router Dispatcher, showing the arbitrary external call primitive:

} else if (command == Commands.KYBER_SWAP) {
    (
        address kyberRouter,
        address tokenIn,
        uint256 amountIn,
        address tokenOut,
        ,
        bytes memory targetData
    ) = abi.decode(_inputs, (address, address, uint256, address, uint256, bytes));
    if (tokenOut == Constants.ETH) {
        revert AddressError();
    }
    if (tokenIn == Constants.ETH) {
        if (msg.value != amountIn) {
            revert AmountError();
        }
        (bool success, ) = kyberRouter.call{value: msg.value}(targetData);
        if (!success) {
            revert CallFailed();
        }
    } else {
        amountIn = _resolveTokenValue(tokenIn, amountIn);
        IERC20(tokenIn).forceApprove(kyberRouter, amountIn);
        (bool success, ) = kyberRouter.call(targetData);
        if (!success) {
            revert CallFailed();
        }
        IERC20(tokenIn).forceApprove(kyberRouter, 0);
    }
}

Caption: Legacy Spectra Router Dispatcher KYBER_SWAP branch (logic 0x51bd…2ecc), which decodes an unconstrained kyberRouter address and targetData and executes kyberRouter.call under the Router's identity.

5. Adversary Flow Analysis

The adversary executes a two-transaction strategy on Ethereum mainnet: first, a Router::execute call (wrapped via aggregator 0xba8ce86147ded54c0879c9a954f9754a472704aa) that abuses KYBER_SWAP to transfer all SdCrvCompounder shares from victim 0x279a7dbfae376427ffac52fcb0883147d42165ff to the aggregator using the victim's allowance; second, a sweepToken call that transfers those shares from the aggregator to adversary EOA 0x53635bf7b92b9512f6de0eb7450b26d5d1ad9a4c, realizing profit in SdCrvCompounder shares.

Adversary-related cluster accounts:

  • 0x53635bf7b92b9512f6de0eb7450b26d5d1ad9a4c on Ethereum mainnet (EOA=True, contract=False): Originates the KYBER_SWAP theft transaction and ultimately receives all stolen SdCrvCompounder shares after the sweep, matching the profit-recipient role in traces and balance diffs.

  • 0xce97ebfb32291895878f3094310e70c488954d86 on Ethereum mainnet (EOA=True, contract=False): Sends the sweepToken transaction that moves SdCrvCompounder shares from aggregator 0xba8ce86147ded54c0879c9a954f9754a472704aa to EOA 0x53635bf7b92b9512f6de0eb7450b26d5d1ad9a4c, indicating coordinated control with the primary profit EOA.

  • 0xba8ce86147ded54c0879c9a954f9754a472704aa on Ethereum mainnet (EOA=False, contract=True): Unverified aggregator contract that receives the victim's SdCrvCompounder shares in the first transaction and sends them to EOA 0x53635bf7b92b9512f6de0eb7450b26d5d1ad9a4c in the sweep; bytecode and traces show tx.origin/CALLER-based gating consistent with adversary control.

Victim-related accounts and contracts:

  • SdCrvCompounder vault depositor at 0x279a7dbfae376427ffac52fcb0883147d42165ff on Ethereum mainnet (EOA=True, contract=False, verified=unknown)

  • SdCrvCompounder share token at 0x43e54c2e7b3e294de3a155785f52ab49d87b9922 on Ethereum mainnet (EOA=False, contract=True, verified=true)

5.1 Transaction sequence b and traces

Seed / theft transaction (index 1): chainid 1, tx 0x491cf8b2a5753fdbf3096b42e0a16bc109b957dc112d6537b1ed306e483d0744

Role: adversary-crafted KYBER_SWAP theft via aggregator.

Inclusion feasibility: Unprivileged EOA 0x53635bf7b92b9512f6de0eb7450b26d5d1ad9a4c submits a normal Ethereum transaction to aggregator 0xba8ce86147ded54c0879c9a954f9754a472704aa with selector 0x18e299ee and encoded arguments specifying Router proxy 0x3d20601ac0ba9cae4564ddf7870825c505b69f1a, SdCrvCompounder token 0x43e54c2e7b3e294de3a155785f52ab49d87b9922, victim 0x279a7dbfae376427ffac52fcb0883147d42165ff and a deadline; gas is paid in ETH and no privileged roles, whitelists, or non-standard rules are required for inclusion. Aggregator gating is based only on tx.origin / CALLER checks and the aggregator itself belongs to the adversary-related cluster.

Effect summary: This transaction causes Router::execute (via proxy 0x3d20601ac0ba9cae4564ddf7870825c505b69f1a) to process a single KYBER_SWAP command (Commands.KYBER_SWAP = 0x12) whose inputs set kyberRouter to the SdCrvCompounder proxy and targetData to encode SdCrvCompounder.transferFrom(0x279a7dbfae376427ffac52fcb0883147d42165ff, 0xba8ce86147ded54c0879c9a954f9754a472704aa, 188013365080870249823427), moving the victim's entire share balance to the aggregator while the sender only pays gas in ETH to fee recipient 0x95222290dd7278aa3ddd389cc1e1d165cc4bafe5.

Seed transaction balance diff for SdCrvCompounder:

{
  "erc20_balance_deltas": [
    {
      "token": "0x43e54c2e7b3e294de3a155785f52ab49d87b9922",
      "holder": "0x279a7dbfae376427ffac52fcb0883147d42165ff",
      "before": "188013365080870249823427",
      "after": "0",
      "delta": "-188013365080870249823427",
      "balances_slot": "151",
      "slot_key": "0xd3895df6205a019dd5a3cdc1670f3f5ae822b83bc6129fca72a5ce822f798234",
      "layout_address": "0x606e2d9ba601662881b260bb6c2dc3b10d0abb2e",
      "contract_name": "SdCrvCompounder"
    },
    {
      "token": "0x43e54c2e7b3e294de3a155785f52ab49d87b9922",
      "holder": "0xba8ce86147ded54c0879c9a954f9754a472704aa",
      "before": "0",
      "after": "188013365080870249823427",
      "delta": "188013365080870249823427",
      "balances_slot": "151",
      "slot_key": "0xda245bea878ce891275319ad48477f8f427ee0dd8dbd4f15fd6c4079192d3d61",
      "layout_address": "0x606e2d9ba601662881b260bb6c2dc3b10d0abb2e",
      "contract_name": "SdCrvCompounder"
    }
  ]
}

Caption: Seed transaction balance diff showing SdCrvCompounder shares debited from victim 0x279a…65ff and credited to aggregator 0xba8c…704aa.

Sweep / profit-realization transaction (index 2): chainid 1, tx 0x004f3cea20431562b64ad365762e36221966e0235bec300b04073c83c6aa8396

Role: adversary-crafted sweepToken to move stolen shares to the profit EOA.

Inclusion feasibility: Unprivileged EOA 0xce97ebfb32291895878f3094310e70c488954d86 sends a standard transaction to aggregator 0xba8ce86147ded54c0879c9a954f9754a472704aa calling sweepToken([0x43e54c2e7b3e294de3a155785f52ab49d87b9922]). The call passes usual gas and value checks and relies only on aggregator-internal tx.origin / CALLER gating, consistent with the adversary cluster.

Effect summary: The sweep transaction queries SdCrvCompounder.balanceOf(0xba8ce86147ded54c0879c9a954f9754a472704aa) and then calls SdCrvCompounder.transfer(0x53635bf7b92b9512f6de0eb7450b26d5d1ad9a4c, 188013365080870249823427) so that all previously stolen SdCrvCompounder shares move from the aggregator to EOA 0x53635bf7b92b9512f6de0eb7450b26d5d1ad9a4c, realizing profit in the reference asset.

Sweep transaction callTracer excerpt (SdCrvCompounder balance query and transfer):

{"calls":[{"calls":[{"from":"0x43e54c2e7b3e294de3a155785f52ab49d87b9922","gas":"0x8162","gasUsed":"0xa74","input":"0x70a08231000000000000000000000000ba8ce86147ded54c0879c9a954f9754a472704aa","output":"0x0000000000000000000000000000000000000000000027d03995eb8cff1bfcc3","to":"0x606e2d9ba601662881b260bb6c2dc3b10d0abb2e","type":"DELEGATECALL","value":"0x0"}],"from":"0xba8ce86147ded54c0879c9a954f9754a4...

Caption: callTracer JSON for 0x004f…8396 showing aggregator 0xba8c…704aa querying SdCrvCompounder.balanceOf and then calling transfer to EOA 0x5363…9a4c.

6. Impact & Losses

Total quantified loss:

  • 188013365080870249823427 units of SdCrvCompounder-share

Victim depositor 0x279a7dbfae376427ffac52fcb0883147d42165ff loses 188013365080870249823427 SdCrvCompounder shares, which represent their position in the SdCrvCompounder vault, while adversary-controlled addresses (aggregator and EOA 0x53635bf7b92b9512f6de0eb7450b26d5d1ad9a4c) gain the same amount. There is no compensating inflow of SdCrvCompounder shares to the victim or protocol in the observed transactions, so the user's position is fully drained.

The profit predicate is expressed in SdCrvCompounder-share units:

  • Reference asset: SdCrvCompounder-share
  • Adversary address: 0x53635bf7b92b9512f6de0eb7450b26d5d1ad9a4c
  • Value before: 0
  • Value after: 188013365080870249823427
  • Delta: 188013365080870249823427 (fees in reference asset = 0)

We measure profit in units of SdCrvCompounder share token 0x43e54c2e7b3e294de3a155785f52ab49d87b9922. Before the sequence, EOA 0x53635bf7b92b9512f6de0eb7450b26d5d1ad9a4c holds 0 shares. After executing txs 0x491cf8b2a5753fdbf3096b42e0a16bc109b957dc112d6537b1ed306e483d0744 and 0x004f3cea20431562b64ad365762e36221966e0235bec300b04073c83c6aa8396, it holds exactly 188013365080870249823427 shares, transferred deterministically from victim 0x279a7dbfae376427ffac52fcb0883147d42165ff via aggregator 0xba8ce86147ded54c0879c9a954f9754a472704aa. Gas fees are paid in ETH by EOAs 0x53635bf7b92b9512f6de0eb7450b26d5d1ad9a4c and 0xce97ebfb32291895878f3094310e70c488954d86, so when the reference asset is SdCrvCompounder-share the fee component is 0 and the net change in the reference asset equals the full received share amount.

7. References

  • [1] Seed transaction metadata, trace, and balance diff — artifacts/root_cause/seed/1/0x491cf8b2a5753fdbf3096b42e0a16bc109b957dc112d6537b1ed306e483d0744

  • [2] Spectra Router legacy Dispatcher and Router source (logic 0x51bdbfcd7656e2c25ad1bc8037f70572b7142ecc) — artifacts/root_cause/data_collector/iter_2/contract/1/0x51bdbfcd7656e2c25ad1bc8037f70572b7142ecc/source/src/router

  • [3] Sweep transaction trace and balance diff — artifacts/root_cause/data_collector/iter_2/tx/1/0x004f3cea20431562b64ad365762e36221966e0235bec300b04073c83c6aa8396

  • [4] SdCrvCompounder share token source — artifacts/root_cause/seed/1/0x606e2d9ba601662881b260bb6c2dc3b10d0abb2e/src/concentrator/stakedao/SdCrvCompounder.sol