On Ethereum mainnet, transaction 0x770bc9a1f7c32cb63a5002b9ceb5c7994cd3af0fc6b2309cb32d3c46f629daa0 succeeded at block 25030409 on 2026-05-05T17:50:35Z. The attacker repeatedly invoked EkuboCore.lock(), withdraw(), and pay(), making the transaction appear tied to Ekubo’s flash-accounting path. The on-chain balance deltas show a narrower loss source: Ekubo Core finished the transaction with zero net WBTC change, while EOA 0x765decf4fa157756e850c1079f60801b9219edd1 lost 17 WBTC after previously granting malicious contract 0x8ccb1ffd5c2aa6bd926473425dea4c8c15de60fd an unlimited WBTC allowance. The attacker used Ekubo’s flash-accounting flow as an execution rail to pull WBTC from that approved victim and deliver the same amount to attacker EOA 0xa911ff351b143634dbc5af3e204ea074583a83e3, for an approximate loss of $1.4M.

Root Cause

Vulnerable Contract

  • No verified vulnerability is evidenced in EkuboCore 0xe0e0e08a6a4b9dc7bd67bcb7aade5cf48157d444 in this transaction.
  • The primary loss source is malicious callback contract 0x8ccb1ffd5c2aa6bd926473425dea4c8c15de60fd (AttackLockCallback in the recovered artifacts).
  • Proxy status: none for both 0x8ccb... and 0xe0e0....
  • Source type:
    • 0x8ccb1ffd5c2aa6bd926473425dea4c8c15de60fd: recovered [approximation]
    • 0xe0e0e08a6a4b9dc7bd67bcb7aade5cf48157d444: verified
  • Attack vector classification: access_control
    • More precisely, this is a standing approval drain. The decisive authorization was the victim’s pre-existing WBTC allowance to the malicious contract, not a missing check in Ekubo Core.

Vulnerable Function

  • Primary drain primitive: payCallback(uint256,address) selector 0x599d0714 on 0x8ccb1ffd5c2aa6bd926473425dea4c8c15de60fd [recovered - approximation]
  • Supporting protocol functions:
    • withdraw(address,address,uint128) selector 0x03a65ab6
    • pay(address) selector 0x0c11dedd
    • lock() selector 0xf83d08ba
  • These supporting functions live in verified file src/base/FlashAccountant.sol under the Ekubo Core source tree.

Vulnerable Code

Recovered malicious callback logic:

// [recovered — approximation]
function payCallback(uint256 id, address token) external {
    require(msg.sender == address(CORE), "core only");
    id;

    if (msg.data.length >= 132) {
        uint256 amount;
        address source;
        assembly ("memory-safe") {
            amount := calldataload(0x64)
            source := shr(96, calldataload(0x84))
        }

        if (amount != 0) {
            IERC20Like(token).transferFrom(source, address(CORE), amount); // <-- VULNERABILITY
            return;
        }
    }

    revert("unrecovered callback branch");
}

Verified Ekubo flash-accounting path used by the malicious contract:

function pay(address token) external returns (uint128 payment) {
    (uint256 id,) = _getLocker();

    assembly ("memory-safe") {
        let free := mload(0x40)

        mstore(20, address())
        mstore(0, 0x70a08231000000000000000000000000)
        let tokenBalanceBefore :=
            mul(
                mload(free),
                and(
                    gt(returndatasize(), 0x1f),
                    staticcall(gas(), token, 0x10, 0x24, free, 0x20)
                )
            )

        mstore(free, shl(224, 0x599d0714))
        mstore(add(free, 4), id)
        mstore(add(free, 36), token)

        calldatacopy(add(free, 68), 36, sub(calldatasize(), 36)) // <-- attacker-controlled tail calldata forwarded

        if iszero(call(gas(), caller(), 0, free, add(32, calldatasize()), 0, 0)) {
            returndatacopy(free, 0, returndatasize())
            revert(free, returndatasize())
        }

        let tokenBalanceAfter :=
            mul(
                mload(0x20),
                and(
                    gt(returndatasize(), 0x1f),
                    staticcall(gas(), token, 0x10, 0x24, 0x20, 0x20)
                )
            )

        payment := sub(tokenBalanceAfter, tokenBalanceBefore) // <-- only Core balance delta is enforced
    }

    unchecked {
        _accountDebt(id, token, -int256(uint256(payment)));
    }
}

function withdraw(address token, address recipient, uint128 amount) external {
    (uint256 id,) = _requireLocker();

    _accountDebt(id, token, int256(uint256(amount)));

    if (token == NATIVE_TOKEN_ADDRESS) {
        SafeTransferLib.safeTransferETH(recipient, amount);
    } else {
        SafeTransferLib.safeTransfer(token, recipient, amount); // <-- withdrawn WBTC is sent to attacker-controlled recipient
    }
}

Why It’s Vulnerable

The decisive weakness in this transaction is not a broken Ekubo invariant; it is the victim’s standing approval to the malicious callback contract.

Expected behavior:

  • A caller that wants to use Ekubo flash accounting should repay its own temporary debt with its own funds, or with funds from an address that intentionally approved that caller.
  • If no third party has approved the callback contract, the callback cannot fund EkuboCore.pay() from that third party.
  • If Ekubo itself were losing funds, funds_flow.json would show a negative net WBTC change for 0xe0e0....

Actual behavior:

  • At block 25030408, allowance(0x765decf4fa157756e850c1079f60801b9219edd1, 0x8ccb1ffd5c2aa6bd926473425dea4c8c15de60fd) was 2^256 - 1.
  • At the same block, allowance(0x765decf4fa157756e850c1079f60801b9219edd1, 0xe0e0e08a6a4b9dc7bd67bcb7aade5cf48157d444) was 0.
  • The decoded trace shows payCallback(uint256,address) calling WBTC.transferFrom(0x765d..., 0xe0e0..., 0.2 WBTC) 85 times, while EkuboCore.withdraw(WBTC, attackerEOA, 0.2 WBTC) sends the matching 0.2 WBTC to the attacker EOA each time.
  • funds_flow.json shows the resulting net deltas exactly: attacker EOA +17 WBTC, victim EOA -17 WBTC, Ekubo Core 0 WBTC net.

Normal flow vs attack flow:

  • Normal flow:

    1. Locker enters lock().
    2. Locker temporarily withdraws tokens from Core.
    3. Locker’s callback repays Core from the locker’s own wallet or a deliberately approved funding address.
    4. Debt returns to zero and the lock exits.
  • Attack flow:

    1. Attacker-controlled AttackDispatcher calls AttackLockCallback 85 times.
    2. AttackLockCallback withdraws 0.2 WBTC from Ekubo Core to attacker EOA on each iteration.
    3. In payCallback, the same contract uses the victim EOA’s pre-existing unlimited WBTC approval to refill Core with 0.2 WBTC from 0x765d....
    4. Ekubo’s debt accounting is satisfied because Core’s own WBTC balance is restored, but the victim has funded the repayment and the attacker keeps the withdrawn WBTC.

This matters because it makes the transaction look like an Ekubo exploit while the actual economic loss is an authorization drain from the victim EOA. The verified Ekubo code is functioning consistently with its flash-accounting model in this trace; the malicious callback contract abuses an off-protocol approval to settle its Ekubo debt with third-party funds.

Validation Against Alternative Root Causes

Primary on-chain artifacts and historical chain state support the approval-drain conclusion independently of the recovered attacker-contract pseudocode:

  • Receipt-level WBTC transfers show exactly two repeated legs: 85 transfers from Ekubo Core to attacker EOA, and 85 transfers from victim EOA back to Ekubo Core. Each transfer is 20_000_000 raw WBTC units (0.2 WBTC), for a total of 17 WBTC in each direction.
  • Historical WBTC balances around the exploit block confirm the same accounting: victim 0x765d... changed from 1,701,484,735 to 1,484,735 raw WBTC (-17 WBTC), attacker 0xa911... changed from 0 to 1,700,000,000 raw WBTC (+17 WBTC), and Ekubo Core stayed unchanged at 32,349,396 raw WBTC.
  • Historical allowance checks identify the authorization source. At block 25030408, the victim had unlimited WBTC allowance to 0x8ccb..., zero allowance to Ekubo Core, and after the exploit the victim-to-0x8ccb... allowance decreased by exactly 1,700,000,000 raw units (17 WBTC).
  • The decoded trace independently matches the mechanism: every EkuboCore.pay(WBTC) call invokes payCallback(uint256,address) on 0x8ccb..., and that callback immediately calls WBTC.transferFrom(victim, EkuboCore, 20_000_000).
  • The verified Ekubo source explains why the transaction succeeds without making Ekubo net-negative: withdraw() records debt and sends tokens to the chosen recipient, while pay() only requires Core’s token balance to increase during the payer callback.

These checks reject two alternative explanations. First, Ekubo Core was not the economic loss source in this transaction because its WBTC balance and net transfer delta are both unchanged. Second, the victim did not approve Ekubo Core; the consumed approval belonged to the malicious callback contract 0x8ccb....

Attack Execution

High-Level Flow

  1. The attacker EOA calls a small dispatcher contract with a payload pointing at a malicious callback contract.
  2. The dispatcher invokes that callback contract 85 times in a loop.
  3. On each iteration, the callback contract opens an Ekubo flash-accounting lock, tries an irrelevant forwarding branch that reverts, then withdraws 0.2 WBTC from Ekubo Core to the attacker EOA.
  4. The callback contract immediately asks Ekubo Core to settle the temporary WBTC debt.
  5. During the settlement callback, the malicious contract uses a victim EOA’s standing WBTC approval to pull 0.2 WBTC from the victim back into Ekubo Core.
  6. After 85 iterations, the attacker EOA has accumulated 17 WBTC, the victim EOA has lost 17 WBTC, and Ekubo Core’s WBTC balance is unchanged.

Detailed Call Trace

The trace-derived call flow is:

0xa911ff351b143634dbc5af3e204ea074583a83e3
  -> 0x61b0dad9628d3e644eb560a5c9b0f960430e3a75  func_0x718a549d(...) [CALL]
     -> repeated 85 times:
        -> 0x8ccb1ffd5c2aa6bd926473425dea4c8c15de60fd  0x00090905 [CALL]
           -> 0xe0e0e08a6a4b9dc7bd67bcb7aade5cf48157d444  lock() [CALL]
              -> 0x8ccb1ffd5c2aa6bd926473425dea4c8c15de60fd  locked(uint256) [CALL]
                 -> 0xe0e0e08a6a4b9dc7bd67bcb7aade5cf48157d444  forward(address) [CALL, reverts]
                    -> 0x2260fac5e5542a773aa44fbcfedf7c193bc2c599  forwarded(uint256,address) [CALL, reverts]
                 -> 0xe0e0e08a6a4b9dc7bd67bcb7aade5cf48157d444  withdraw(address,address,uint128) [CALL]
                    -> 0x2260fac5e5542a773aa44fbcfedf7c193bc2c599  transfer(address,uint256) [CALL]
                 -> 0xe0e0e08a6a4b9dc7bd67bcb7aade5cf48157d444  pay(address) [CALL]
                    -> 0x2260fac5e5542a773aa44fbcfedf7c193bc2c599  balanceOf(address) [STATICCALL]
                    -> 0x8ccb1ffd5c2aa6bd926473425dea4c8c15de60fd  payCallback(uint256,address) [CALL]
                       -> 0x2260fac5e5542a773aa44fbcfedf7c193bc2c599  transferFrom(address,address,uint256) [CALL]
                    -> 0x2260fac5e5542a773aa44fbcfedf7c193bc2c599  balanceOf(address) [STATICCALL]

Important trace facts:

  • forward(address) reverts 85 times, but the parent locked(uint256) call does not revert. The malicious contract ignores that branch and continues with withdraw() and pay().
  • The first decoded withdraw() call is withdraw(WBTC, attackerEOA, 20_000_000), i.e. 0.2 WBTC to 0xa911....
  • The first decoded pay() call includes attacker-controlled trailing calldata after the token argument.
  • The first decoded transferFrom() call is transferFrom(0x765decf4fa157756e850c1079f60801b9219edd1, 0xe0e0e08a6a4b9dc7bd67bcb7aade5cf48157d444, 20_000_000).

Financial Impact

  • Attacker EOA 0xa911ff351b143634dbc5af3e204ea074583a83e3: +17 WBTC
  • Victim/source EOA 0x765decf4fa157756e850c1079f60801b9219edd1: -17 WBTC
  • Ekubo Core 0xe0e0e08a6a4b9dc7bd67bcb7aade5cf48157d444: net 0 WBTC
  • Approximate USD impact: ~$1.4M
  • Gas used: 1,735,786
  • Effective gas price: 0.249694514 gwei
  • Gas cost: 0.000433416241678004 ETH

Protocol solvency impact:

  • This transaction does not show Ekubo Core losing custody of WBTC.
  • The economic loss is borne by the approved victim EOA, not by Ekubo liquidity or treasury balances.
  • The incident is better classified as a malicious approval drain routed through Ekubo than as an Ekubo insolvency event.

Evidence

  • funds_flow.json summary:
    • attacker EOA gained 17 WBTC
    • victim/source EOA lost 17 WBTC
    • Ekubo Core net WBTC change is 0
  • Pre-transaction allowance checks at block 25030408:
    • allowance(0x765d..., 0x8ccb...) = 115792089237316195423570985008687907853269984665640564039457584007913129639935
    • allowance(0x765d..., 0xe0e0...) = 0
  • Post-transaction allowance checks at block 25030409:
    • allowance(0x765d..., 0x8ccb...) = 115792089237316195423570985008687907853269984665640564039457584007911429639935
    • The decrease is exactly 1_700_000_000 raw WBTC units (17 WBTC), matching the victim’s loss.
  • Historical WBTC balance deltas from block 25030408 to block 25030409:
    • victim/source EOA: 1_701_484_735 -> 1_484_735 raw units (-17 WBTC)
    • attacker EOA: 0 -> 1_700_000_000 raw units (+17 WBTC)
    • Ekubo Core: 32_349_396 -> 32_349_396 raw units (0 WBTC)
  • Receipt log aggregation:
    • 85 WBTC transfers from 0xe0e0... to 0xa911..., total 1_700_000_000 raw units
    • 85 WBTC transfers from 0x765d... to 0xe0e0..., total 1_700_000_000 raw units
  • The decoded transferFrom() calldata resolves to:
    • from = 0x765DECF4Fa157756e850C1079F60801b9219Edd1
    • to = 0xe0e0e08A6A4b9Dc7bD67BCB7aadE5cF48157d444
    • amount = 20_000_000 raw WBTC units (0.2 WBTC)
  • The transaction receipt has status = 0x1 and 170 logs.
  • No Tornado Cash interaction occurs inside the analyzed transaction. Later Tornado Cash deposits, if any, occur in subsequent transactions.