Research

Protect Your Protocol Against Zero-Collateral Hacks

Feb 12, 2026

Every smart contract that handles money relies on rules that must always hold true — like a user can't hold debt without sufficient collateral. When one of those rules can be bypassed, the system breaks. The Abracadabra hack is a case study of exactly that: a logic flaw sat in production code for nearly two years, survived multiple audits, and drained $1.8M in minutes. We break down how the attack worked and how Shepherd autonomously reproduced the exploit.


What Happened


On October 4, 2025, an attacker stole $1.8 million in MIM stablecoins from Abracadabra Money, a decentralized lending protocol on Ethereum. The attacker took out massive loans worth $1.8 million without putting up any collateral.


The Solvency Invariant


This exploit is easiest to understand as a single broken invariant: a transaction should never finalize with debt that isn’t safely backed by collateral. Borrowing is just “create debt / mint credit,” and collateral is the buffer that makes that debt legitimate. So protocols gate any risk-increasing action behind a solvency check: if debt goes up (or collateral goes down), recompute the position under the protocol’s parameters (LTV/thresholds/oracle value) and revert the entire tx if it would end undercollateralized.


How Abracadabra's Lending Contracts Work


Abracadabra uses smart contracts (marketed as Cauldrons) to manage its lending. Each Cauldron is a self-contained lending mechanism for a specific type of collateral.


Each Cauldron has a function called cook(). Normally, interacting with a smart contract means sending one instruction per transaction. If you want to deposit collateral and borrow tokens, this is booked as two separate transactions, which means each will cost a gas fee. cook() solves this by letting you bundle multiple actions into a single transaction. You pass it a numbered list of actions, and it processes them one by one, in order. For example, Action 5 allows you to take out a loan from the protocol. The user receives MIM tokens and takes on a corresponding debt, and so on.


As cook() processes the list of actions, it carries a small status tracker called CookStatus. This tracker has a boolean field called needsSolvencyCheck to trigger a check on the user's collateral amount.


In the scenario where the contact works as intended, this is how things work:

  1. The user calls cook() with a list of actions. For example, Borrow.

  2. The Borrow action executes: the contract creates the user's debt and mints MIM tokens to them.

  3. As part of executing the Borrow action, the contract sets needsSolvencyCheck = true, marking that a debt-creating action occurred during this transaction.

  4. Moves on to the next action.

  5. After all actions in the list have finished executing, cook() checks the flag if (needsSolvencyCheck == true), and runs the solvency check.

  6. The solvency check verifies the user's collateral covers their debt. If it doesn't, the entire transaction is reversed.


This is called a flag-based design, meaning that the solvency check only runs once, at the very end, regardless of how many actions were bundled together. You can think of it as a final verification step before the transaction is finalized.


What happens when something resets the flag before the final check?


When cook() encounters an action ID it doesn't explicitly handle i.e. any number not mapped to a defined operation, it calls an internal function named _additionalCookAction().

function _additionalCookAction(CookStatus memory, bytes memory)
    internal pure returns (CookStatus memory) {
    return CookStatus(false);
}
function _additionalCookAction(CookStatus memory, bytes memory)
    internal pure returns (CookStatus memory) {
    return CookStatus(false);
}
function _additionalCookAction(CookStatus memory, bytes memory)
    internal pure returns (CookStatus memory) {
    return CookStatus(false);
}


To ‘de-code’ the function, this is how it works:

  1. It receives the current CookStatus (which may have needsSolvencyCheck = true from a prior Borrow action).

  2. It ignores the current CookStatus entirely.

  3. It creates a new CookStatus with every field set to false.

  4. It returns that new CookStatus, which replaces the previous one.

This means any security flags that were set by previous actions are erased.


Two Actions for $1.8M


The exploit required just two actions passed to cook(), in this order:

Step 1: Action 5 (for Borrow):

  • The contract creates a debt entry for the attacker.

  • The contract mints MIM tokens and sends them to the attacker.

  • The contract sets needsSolvencyCheck = true.


Step 2: Action 100 (for Unhandled):

  • The contract doesn't recognize this action ID, so it calls _additionalCookAction().

  • _additionalCookAction() returns a new CookStatus with needsSolvencyCheck = false.

  • This new CookStatus overwrites the one from Step 1.


Runs the check:

  • cook() finishes processing all actions and checks. If (needsSolvencyCheck == true) → run solvency check.

  • The flag reads false.

  • The solvency check never runs.

  • The transaction is finalized. Now the attacker now holds 1,793,755 MIM in debt and backed by 0 collateral.


The attacker repeated this across six different Cauldron contracts on Ethereum mainnet. All six had been labeled deprecated by the Abracadabra team but were never actually disabled. This means they were still live, still accepting transactions, and of course still capable of minting MIM.


After collecting the MIM, the attacker swapped it for ETH and laundered the funds through Tornado Cash.


How did audits miss this?


The vulnerable CauldronV4 contracts were last audited in November 2023, which is nearly two years before the exploit. In the time between that audit and the October 2025 attack, Abracadabra commissioned security reviews for four newer features (MIMSwap, BoundSpell, LockingMultiRewards, GMXV2 CauldronV4). Relatedly, a fork of Abracadabra called Synnax Labs shared the same vulnerable codebase. The Synnax's contract was reviewed in November 2024 and missed the vulnerability entirely.


Most audits are still primarily manual review under a fixed scope and timebox. Auditors read the code, reason through flows, and test the edges. That catches a ton of real bugs, it just isn’t an exhaustive proof over every state/action sequence.


The limit is coverage: a time-boxed review can’t enumerate every reachable state and action ordering. Fuzzing helps, but randomness doesn’t reliably hit the exact “bad sequence + bad state” combos that these exploits often require.


In this case, for example, an auditor reading _additionalCookAction in isolation might think this function doesn't do much and continue onwards. The vulnerability only happens, say, when you consider what happens if it's called immediately after a Borrow action, which some auditors can miss and others may not.


Our Approach


Shepherd deploys smart contracts into a sandboxed environment and attempts to exploit them live, using a multi-agent system where specialized agents each handle a different phase of the security analysis.


To demonstrate this to you, we simulated the Abracadabra exploit live in a sandbox to show how our system works to protect the codebase.



  1. Our system discerns: "The cook() function allows a solvency bypass: a user can create debt without posting collateral."

  2. Establishes a Pre-state: Confirm the attacker starts with no position: userBorrowPart = 0 and userCollateralShare = 0.

  3. Check for Causality: It calls cook() with only Action 5 (Borrow) for a borrow amount of 1,000,000 (sandbox units). The transaction correctly reverts with "Cauldron: user insolvent." This confirms the solvency check works under normal conditions — the invariant holds when the contract operates as designed.

  4. The Exploit Sequence: Call cook() with Action 5 followed by Action 100 (a custom action with an empty payload). This routes through the contract's custom cook-action path (_additionalCookAction) and bypasses the condition that would normally force the solvency check. The transaction succeeds.

  5. Post-State Verification: userBorrowPart = 1,000,500 while userCollateralShare remains 0. This means debt was created and collateral was not required. Therefore the invariant is broken.


Shepherd's system identified the vulnerability, executed the exploit path(s), proved the invariant break with before-and-after state evidence, and generated a formal report with the transaction hash.


Takeaways


This hack carries 3 lessons that extend beyond Abracadabra:


A deprecated contract does not mean a safe contract


Labeling a contract as deprecated while leaving it live and functional is careless. If a contract can still process transactions and mint tokens, it needs active security monitoring. Otherwise, it needs to be disabled.


Auditing new features while ignoring old contracts can be dangerous


Abracadabra paid for audits for newer products while the core lending went unreviewed for two years. A good security coverage should include existing deployed contracts and not just new code.


Code review alone has structural limitations


Manual code review depends on auditors mentally simulating every possible input combination. Vulnerabilities that emerge from specific sequences of actions are easy to miss. Simulation-based testing, where AI agents actively execute exploit attempts against live contract deployments, can help bridge context between new modules and the core contracts during testing.


We built Shepherd to close these gaps and help you stay secure as you scale.


Get in touch with us