Skip to main content

Abracadabra's $1.8M incident: what broke, why it matters, and how Bug Hunter caught it

· 10 min read
Smart Contract Security Engineers

TL;DR

Abracadabra, the multi-chain lending protocol behind the MIM stablecoin, was exploited in early October 2025 for about 1.7 to 1.8 million dollars. The attacker abused a logic bug in Cauldron V4's cook() batch flow that let the final solvency check be skipped.

After the news surfaced, we ran the same code through our Bug Hunter engine, which independently flagged the exact flaw in three separate findings.


What is Abracadabra, and why does it matter

Abracadabra lets users post collateral to mint or borrow Magic Internet Money (MIM), a USD-pegged stablecoin. Many collateral types are interest-bearing tokens, for example vault shares, so positions can earn yield while being used as collateral. Markets are isolated into Cauldrons that plug into a token vault, and users can chain multiple actions into one transaction through a batch function called cook().

Because MIM circulates across chains and integrations, a flaw that lets undercollateralized borrows succeed can propagate risk to other protocols that hold or route MIM. That is why the batch executor and its invariants, such as solvency and oracle freshness, are critical.


What actually happened

Short version: a solvency-check bypass in Cauldron V4's cook() allowed the attacker to borrow MIM while skipping the final solvency assertion. The bug comes from how cook() tracks a small status struct across a sequence of actions, then accidentally overwrites that status with a fresh default that clears the solvency flag.

Failure flow in plain English

  1. cook() lets a user batch actions such as add collateral, borrow, and withdraw. Internally it tracks status.needsSolvencyCheck.
  2. Sensitive actions such as BORROW and REMOVE_COLLATERAL set status.needsSolvencyCheck = true.
  3. A later "extra" or unrecognized action falls into an extension hook and returns a default status.
  4. The code assigns status = returnStatus. The default clears the flag, so the final solvency check at the end of cook() does not run.
  5. The transaction succeeds even if the position is insolvent.

The vulnerable function, cook

This is the exact control flow that allowed the solvency check to be bypassed. Notice the two places that set the flag to true, and the later assignment that replaces the whole status struct.

function cook(
uint8[] calldata actions,
uint256[] calldata values,
bytes[] calldata datas
) external payable returns (uint256 value1, uint256 value2) {
CookStatus memory status;

for (uint256 i = 0; i < actions.length; i++) {
uint8 action = actions[i];

if (!status.hasAccrued && action < 10) {
accrue();
status.hasAccrued = true;
}

if (action == ACTION_ADD_COLLATERAL) {
(int256 share, address to, bool skim) = abi.decode(datas[i], (int256, address, bool));
addCollateral(to, skim, _num(share, value1, value2));
} else if (action == ACTION_REPAY) {
(int256 part, address to, bool skim) = abi.decode(datas[i], (int256, address, bool));
_repay(to, skim, _num(part, value1, value2));
} else if (action == ACTION_REMOVE_COLLATERAL) {
(int256 share, address to) = abi.decode(datas[i], (int256, address));
_removeCollateral(to, _num(share, value1, value2));
status.needsSolvencyCheck = true;
} else if (action == ACTION_BORROW) {
(int256 amount, address to) = abi.decode(datas[i], (int256, address));
(value1, value2) = _borrow(to, _num(amount, value1, value2));
status.needsSolvencyCheck = true;
} else if (action == ACTION_UPDATE_EXCHANGE_RATE) {
(bool must_update, uint256 minRate, uint256 maxRate) = abi.decode(datas[i], (bool, uint256, uint256));
(bool updated, uint256 rate) = updateExchangeRate();
require((!must_update || updated) && rate > minRate && (maxRate == 0 || rate < maxRate), "Cauldron: rate not ok");
} else if (action == ACTION_BENTO_SETAPPROVAL) {
(address user, address _masterContract, bool approved, uint8 v, bytes32 r, bytes32 s) =
abi.decode(datas[i], (address, address, bool, uint8, bytes32, bytes32));
bentoBox.setMasterContractApproval(user, _masterContract, approved, v, r, s);
} else if (action == ACTION_BENTO_DEPOSIT) {
(value1, value2) = _bentoDeposit(datas[i], values[i], value1, value2);
} else if (action == ACTION_BENTO_WITHDRAW) {
(value1, value2) = _bentoWithdraw(datas[i], value1, value2);
} else if (action == ACTION_BENTO_TRANSFER) {
(IERC20 token, address to, int256 share) = abi.decode(datas[i], (IERC20, address, int256));
bentoBox.transfer(token, msg.sender, to, _num(share, value1, value2));
} else if (action == ACTION_BENTO_TRANSFER_MULTIPLE) {
(IERC20 token, address[] memory tos, uint256[] memory shares) = abi.decode(datas[i], (IERC20, address[], uint256[]));
bentoBox.transferMultiple(token, msg.sender, tos, shares);
} else if (action == ACTION_CALL) {
(bytes memory returnData, uint8 returnValues) = _call(values[i], datas[i], value1, value2);
if (returnValues == 1) {
(value1) = abi.decode(returnData, (uint256));
} else if (returnValues == 2) {
(value1, value2) = abi.decode(returnData, (uint256, uint256));
}
} else if (action == ACTION_GET_REPAY_SHARE) {
int256 part = abi.decode(datas[i], (int256));
value1 = bentoBox.toShare(magicInternetMoney, totalBorrow.toElastic(_num(part, value1, value2), true), true);
} else if (action == ACTION_GET_REPAY_PART) {
int256 amount = abi.decode(datas[i], (int256));
value1 = totalBorrow.toBase(_num(amount, value1, value2), false);
} else if (action == ACTION_LIQUIDATE) {
_cookActionLiquidate(datas[i]);
} else {
(bytes memory returnData, uint8 returnValues, CookStatus memory returnStatus) =
_additionalCookAction(action, status, values[i], datas[i], value1, value2);

// Problem: this overwrites previously set flags, including needsSolvencyCheck
status = returnStatus;

if (returnValues == 1) {
(value1) = abi.decode(returnData, (uint256));
} else if (returnValues == 2) {
(value1, value2) = abi.decode(returnData, (uint256, uint256));
}
}
}

if (status.needsSolvencyCheck) {
(, uint256 _exchangeRate) = updateExchangeRate();
require(_isSolvent(msg.sender, _exchangeRate), "Cauldron: user insolvent");
}
}

We passed the project Abracadabra to Bug Hunter, an automated Solidity code reviewer for security vulnerabilities. The Bug Hunter report explains why the line status = returnStatus; is dangerous and how the flag gets cleared by a default return from the extension hook. The snapshots of the Findings 6, 26, and 27 are shown below.

`CauldronV4.cook()` accumulates a `CookStatus` memory struct (status) and sets `status.needsSolvencyCheck = true` when performing sensitive operations `(ACTION_REMOVE_COLLATERAL and ACTION_BORROW)`. Later, for actions not handled by the main `if/else` chain, `cook()` calls `_additionalCookAction(...)` and assigns the returned `CookStatus` memory to status with the line: `status = returnStatus;` (in the final else branch).
The base implementation of `_additionalCookAction (CauldronV4._additionalCookAction)` is an empty virtual function that returns a default-initialized `CookStatus (needsSolvencyCheck == false)`. As a result, a malicious caller can
* perform a borrow or remove collateral to become undercollateralised which sets `status.needsSolvencyCheck = true`,
* then append an unrecognized / generic action that ends up in the else branch so `_additionalCookAction` returns a default `CookStatus`, and `status = returnStatus` clears needsSolvencyCheck back to false.
* The final solvency check at the end of `cook()` only runs when `status.needsSolvencyCheck` is true, so clearing the flag skips `_isSolvent(msg.sender, _exchangeRate)` and leaves the protocol in an insolvent state.

Relevant code flow:

  • CauldronV4.cook(): sets status.needsSolvencyCheck = true in ACTION_REMOVE_COLLATERAL and ACTION_BORROW branches.
  • CauldronV4.cook(): else branch calls (bytes returnData, uint8 returnValues, CookStatus memory returnStatus) = _additionalCookAction(...); then status = returnStatus; which unconditionally overwrites the accumulated status.
  • CauldronV4._additionalCookAction(...) base impl is empty and returns default CookStatus (needsSolvencyCheck == false), so calling the base will clear the flag.
  • GmxV2CauldronV4 overrides _additionalCookAction and sets needsSolvencyCheck = true for some custom actions, but any action falling through to the base default (or any override that returns a struct with needsSolvencyCheck == false) will clear previously-set flags.

Exploit scenario (concrete)

  • Attacker calls cook with actions sequence: [ACTION_BORROW, UNKNOWN_ACTION_X]
  • ACTION_BORROW runs: _borrow(...) executes and sets status.needsSolvencyCheck = true. The attacker now holds borrowed funds and may be insolvent.
  • The second action is unrecognized by the main if/else and dispatches to _additionalCookAction. If the implementation returns a default CookStatus (e.g., base implementation or any override that doesn't preserve the flag), then status = returnStatus clears needsSolvencyCheck.
  • Loop ends; because status.needsSolvencyCheck is false, the final require(_isSolvent(...)) is skipped and the attacker leaves the protocol under-collateralised.

This is a logic/state-management bug, assigning a returned struct overwrites previously asserted flags rather than merging them, allowing bypass of the final critical invariant check.

The extension hook that feeds the overwrite

The call site above expects _additionalCookAction(...) to return a CookStatus. The base implementation is an empty stub that returns a default-initialized struct, which means all fields are false. Assigning that struct to status clears needsSolvencyCheck unless an override preserves or raises the flag.


Minimal safe patch

The essential change is to make safety flags monotonic within a batch. Do not overwrite the whole struct. Merge only the fields that represent safety requirements. The report proposes the following minimal fix.

// Before
status = returnStatus;

// After: preserve any previously set safety requirement
status.needsSolvencyCheck = status.needsSolvencyCheck || returnStatus.needsSolvencyCheck;
status.hasAccrued = status.hasAccrued || returnStatus.hasAccrued;

Many teams also add an immediate solvency check directly after BORROW and REMOVE_COLLATERAL, not only at the end. The report recommends guarding oracle freshness as well, so the final check does not use a cached optimistic price.


Our validation with Bug Hunter

As mentioned, we ran the Cauldron V4 code through Bug Hunter. Three findings map directly to the exploited pathway and the unsafe overwrite.

  • Finding 6: solvency flag can be cleared by the return value from _additionalCookAction.

Solvency check flag can be cleared by _additionalCookAction

  • Finding 26: custom action can reset the solvency flag in the cook() flow, recommendation to merge flags.

Cook flow: custom action can reset solvency-check flag

  • Finding 27: clearing of the solvency flag via custom action overwrite, with concrete exploit flow.

Cook flow allows clearing solvency check flag


Other relevant code paths to keep an eye on

The report also calls out related issues that can amplify impact.

  • Oracle freshness and deferred checks can let borrow plus withdraw pass with a cached rate.
  • The ACTION_CALL branch forwards native ETH and can leak funds if msg.value accounting is not enforced. The report proposes a remaining-value counter and stricter call approvals.
  • Rounding mismatches between parts and amounts can create underpayment during repay flows.

Mitigations that generalize to other protocols

  • Make safety flags monotonic. Never allow an auxiliary step to downgrade safety. Merge flags instead of replacing them.
  • Enforce solvency immediately after risk-increasing operations. Add a check after BORROW and REMOVE_COLLATERAL, not only at the end of the batch.
  • Require fresh oracles when a solvency check is due. Refuse stale reads and set a maximum age.
  • Constrain arbitrary external calls. Approve callees explicitly and reconcile any forwarded native value with msg.value.
  • Harden or retire legacy markets. If a market is deprecated, restrict powerful batch features or disable borrow while users migrate.

Why automated analysis matters, and how to use Bug Hunter

Stateful bugs often hide in specific sequences of actions. Humans are great at reading functions line by line. Machines are great at exploring many sequences without fatigue. Bug Hunter excels at this kind of path exploration and invariant checking, and in this case it reported the solvency-flag reset three different ways with a clear merge-not-overwrite patch. If your code uses batch execution, deferred checks, or custom action hooks, you are in the risk set.

Call to action Run Bug Hunter on your lending markets, batch routers, and vault controllers before the next release. You will get deterministic findings with concrete patch guidance. If you want a sample report or help setting up a scan, reach out and we will get you started.


Conclusion

This incident was not only about one function, it was about how complex flows create blind spots that are hard to see in isolation. The fix is straightforward once identified, yet finding the path required systematic exploration. Combine careful engineering with automated analysis, keep safety flags monotonic, and verify solvency at the moments that matter. If your contracts use similar patterns, now is the time to run Bug Hunter and close the gap.