On Ethereum mainnet, transaction 0xebaaab69baa3cd2543eb80ecfb8e3ed226b9e5a6f5694891a8adf4edbcbd8107 succeeded at block 24981717 on 2026-04-28T23:01:11Z. The attacker deployed helper contracts and exploited an unauthenticated execute() batch-action function on contract 0x143a737bffc6414b61134f513ceed1a64390181a, which held a prior max allowance over the victim’s Yearn WETH Vault shares (yvWETH, 0xa258c4606ca8206d8aa700ce2143d7db854d168c). The execute() function (selector 0x49650044) exposes 8 action types including arbitrary low-level calls and token transfers with no access control, allowing the attacker to weaponize the contract’s approval to pull the victim’s yvWETH, redeem it through the vault, and forward the resulting WETH. The victim lost 384.667252984919210375 yvWETH, which redeemed into 429.210570004163139903 WETH/ETH proceeds for the attacker, estimated by the prompt at roughly $1M.
0x143a737bffc6414b61134f513ceed1a64390181a.0x143a737bffc6414b61134f513ceed1a64390181a/recovered.sol). The contract is a general-purpose batch action executor, not a yvWETH-specific vault wrapper.eth_call owner() (0x8da5cb5b) at the pre-exploit block returned 0x98289e90d6fc92a8769bc892d006a2baa7705afe.victim_approval_logs.json shows the victim approved 0x143a... for uint256.max yvWETH in tx 0x52b4e5703a2d06cc6bf6842edb04961cafdf5357178813a5606e5ea9c1846fae at block 24909652 (2026-04-18T22:10:11Z).execute(Action[] calldata actions) — selector 0x49650044, resolved from Dedaub decompilation as execute((uint8,bytes)[]).0xcc9be93051e8ad00a70eba3df2571a18f94d5856.Action structs ({uint8 actionType, bytes data}), dispatching each to one of 8 operation types — with no access control check:| Type | Operation | Risk |
|---|---|---|
| 0 | transferFrom(msg.sender, this, amount) | Pull tokens from caller |
| 1 | approve(token, spender, amount) | Set arbitrary approvals from the contract |
| 2 | target.call{value}(callData) | Arbitrary low-level call from contract context |
| 3 | transfer(token, receiver, amount) | Drain tokens held by the contract |
| 4 | Send ETH to receiver | Drain ETH held by the contract |
| 5 | ERC4626 deposit to address(this) | Deposit into vaults |
| 6 | ERC4626 deposit to any receiver | Deposit into vaults for any address |
| 7 | ERC4626 redeem to any receiver | Redeem vault shares for any address |
The critical actions are type 1 (arbitrary approvals) and type 2 (arbitrary calls from the contract’s context). Together they let any caller execute arbitrary logic as the contract — including calling transferFrom on tokens where the contract holds allowances.
// Decompiled source via app.dedaub.com (Solidity 0.8.34)
contract Contract {
address private constant OWNER = 0x98289e90d6FC92A8769BC892D006A2BaA7705AFE;
struct Action {
uint8 actionType;
bytes data;
}
function execute(Action[] calldata actions) public payable { // <-- NO ACCESS CONTROL
for (uint256 i = 0; i < actions.length; i++) {
uint8 t = actions[i].actionType;
if (t == 0) {
// transferFrom(msg.sender, this, amount) — pull from caller
} else if (t == 1) {
// approve(token, spender, amount) — arbitrary approval
} else if (t == 2) {
// target.call{value}(callData) — ARBITRARY CALL FROM CONTRACT CONTEXT
(address target, uint256 value, bytes memory callData) =
abi.decode(actions[i].data, (address, uint256, bytes));
(bool ok, bytes memory ret) = target.call{value: value}(callData);
if (!ok) revert CallFailed(ret);
} else if (t == 3) {
// transfer(token, receiver, amount) — drain contract tokens
}
// ... types 4-7: ETH send, ERC4626 deposit/redeem
emit ActionExecuted(i, t);
}
}
}
The execute() function is fully public with no require(msg.sender == OWNER) guard. Action type 2 is the critical gadget: it allows any caller to make the contract execute an arbitrary low-level call to any target with any calldata. Since the contract was the approved spender for the victim’s yvWETH, the attacker used type 2 to call yvWETH.transferFrom(victim, contract, amount), which succeeded because msg.sender (the contract) had uint256.max allowance. The contract then used further actions to redeem the shares and forward the WETH proceeds.
Expected behavior: a contract that has been granted allowance over a user’s tokens must only spend those shares when authorized by that user/owner, or must constrain callable targets/functions to safe flows. The execute() function should gate access with require(msg.sender == OWNER) — the rescueERC20 and rescueETH functions already enforce this check, showing the contract author understood the need for access control.
Actual behavior: execute() has no access control at all. The attacker called it directly from an EOA-deployed child contract and supplied three actions:
yvWETH.transferFrom(victim, 0x143a..., 384.667... yvWETH) — weaponizes the contract’s uint256.max approval to pull the victim’s shares.yvWETH.withdraw(384.667..., 0x143a..., 10000) — redeems shares into WETH (note: withdraw is not a native action type, so arbitrary call is used here too).WETH.transfer(attackerChild, 429.210... WETH) — sends proceeds to the attacker.This is an access-control failure: the contract author gated rescueERC20/rescueETH but left execute() — a far more powerful function — completely open. The victim’s prior approval was not itself sufficient to transfer funds; the loss required an unauthenticated function that let any third party execute arbitrary calls from the approved spender’s context.
The contract is not yvWETH-specific. Any user who had approved 0x143a... for any token was vulnerable to the same drain. Action type 1 (approve) also lets any caller set new approvals from the contract, enabling recursive exploitation of additional approvals. Action type 2 is effectively an unrestricted proxy — any on-chain action the contract could take (governance votes, staking, swapping) was available to any caller.
0x64c589f3ef894678e46af3b851aa08be3f40a674.0xcc9be93051e8ad00a70eba3df2571a18f94d5856.0x143a... via STATICCALLs to balanceOf and allowance.0x143a... execute(Action[]) (selector 0x49650044) with a 3-action payload:yvWETH.transferFrom(victim, 0x143a..., 384.667...) — pulls the victim’s shares.yvWETH.withdraw(384.667..., 0x143a..., 10000) — redeems shares into WETH.WETH.transfer(attackerChild, 429.210...) — sends proceeds to attacker.0xdc24316b...), wrapping to WETH.0x6a818c673b098621e9bfb2adc80060906cf7b327 CREATEs helper 0x64c589f3ef894678e46af3b851aa08be3f40a674..0: helper CREATEs child 0xcc9be93051e8ad00a70eba3df2571a18f94d5856..0.0: child STATICCALLs yvWETH balanceOf(address) (0x70a08231) for victim 0x98289e...; output 384667252984919210375..0.1: child STATICCALLs yvWETH allowance(address,address) (0xdd62ed3e) for (victim, 0x143a...); output is uint256.max..0.2: child -> vulnerable 0x143a..., CALL, selector 0x49650044 (execute(Action[]))..0.2.0 (action type 2 — arbitrary call): vulnerable -> yvWETH, transferFrom(address,address,uint256) (0x23b872dd) from victim to vulnerable for 384.667252984919210375 yvWETH..0.2.1 (action type 2 — arbitrary call): vulnerable -> yvWETH, withdraw(uint256,address,uint256) (0xe63697c8) with shares 384.667252984919210375, recipient 0x143a..., and maxLoss = 10000.0x85907b1a... and 0x740e59f1...; the latter exchanged stETH for ETH through Curve pool 0xdc24316b... and wrapped ETH to WETH..0.2.1.0.11: yvWETH -> WETH, transfer(0x143a..., 429.210570004163139885)..0.2.2 (action type 2 — arbitrary call, internal to vault withdrawal): not visible as a top-level sub-call of .0.2 since it’s nested within .0.2.1’s Yearn strategy unwinding..0.2.3 (action type 3 — token transfer): vulnerable -> WETH, transfer(0xcc9b..., 429.210570004163139903)..0.4: child -> WETH, withdraw(uint256) (0x2e1a7d4d) for 429.210570004163139903 WETH..0.5: child forwards 429.210570004163139903 ETH to helper..1: helper forwards 429.210570004163139903 ETH to attacker EOA.The victim-side loss was 384.667252984919210375 yvWETH from 0x98289e90d6fc92a8769bc892d006a2baa7705afe. The exploit redeemed that position into 429.210570004163139903 WETH/ETH proceeds, and the attacker EOA received 429.210570004163139903 ETH. The prompt estimates the position at roughly $1M; this report records the exact on-chain token/ETH quantities and does not rely on a live USD oracle.
Gas cost was 709,674 gas at 2.3307872 gwei, or approximately 0.0016540990753728 ETH. Net of gas, the attacker retained about 429.208915905087767103 ETH.
0x1.0x64c589f3ef894678e46af3b851aa08be3f40a674.Approval(victim, 0x143a..., uint256.max) in tx 0x52b4e5703a2d06cc6bf6842edb04961cafdf5357178813a5606e5ea9c1846fae, block 24909652.274: yvWETH transfer from victim 0x98289e... to 0x143a..., amount 384667252984919210375.285: yvWETH burn from 0x143a..., amount 384667252984919210375.286: WETH transfer from yvWETH vault to 0x143a..., amount 429210570004163139885.288: WETH transfer from 0x143a... to attacker child 0xcc9b..., amount 429210570004163139903.290: WETH Withdrawal(address,uint256) by attacker child for 429210570004163139903 WETH.transferFrom(address,address,uint256) = 0x23b872dd, withdraw(uint256,address,uint256) = 0xe63697c8, transfer(address,uint256) = 0xa9059cbb, withdraw(uint256) = 0x2e1a7d4d, allowance(address,address) = 0xdd62ed3e, and balanceOf(address) = 0x70a08231.0x49650044 resolves to execute((uint8,bytes)[]) per Dedaub decompilation of 0x143a.... The contract is a general-purpose batch action executor with no access control on execute().ActionExecuted(uint256, uint8) (selector 0x337596d5) was emitted by the contract after each action in the batch, confirming the action type dispatch.