root/cause All incidents →
Protocol
Cindervault
Date
Chain
EVM-compatible L2 (illustrative)

$41.2M ($41,200,000) drained from Cindervault

Reentrancy — state written after the external call

The line
(bool ok, ) = msg.sender.call{value: amount}("");

A lending vault sent ETH to the caller before decrementing that caller's balance. A contract with a fallback function re-entered the same withdrawal path 118 times in a single transaction, each time reading a balance that had already been paid out.

Illustrative sample — not a real incident

Get the next one by email

02 — Timeline

Block by block

Cindervault

2026-05-14 · times in UTC

root/cause
  1. 14:02:11 Attacker wallet funded from an unattributed source. 0x0000…3e91 sample
  2. 14:04:58 Helper contract deployed. It exposes a payable fallback and no other public method. 0x0000…31bd sample
  3. 14:06:44 First probe. 0.5 ETH deposited and immediately withdrawn. The fallback does nothing. 0x0000…2e63 sample
  4. 14:08:20 Probe repeated. The fallback now calls withdraw() once more before returning. It succeeds. 0x0000…cb18 sample
  5. 14:09:02 Drain. A single transaction re-enters withdraw() 118 times before the first call unwinds. 0x0000…93ca sample
  6. 14:09:02 Vault ETH balance reaches zero. Later withdrawal attempts by depositors revert. 0x0000…f0e8 sample
  7. 14:31:47 Proceeds consolidated across three wallets. No further interaction with the vault. 0x0000…027d sample
  8. 16:05:12 Vault paused by the operator. Deposits and withdrawals disabled. 0x0000…8fd6 sample
  9. 09:40:33 (+1d) Patched implementation deployed. Balance is decremented before the external call, behind a guard. 0x0000…8b93 sample

Synthetic hashes. Nothing on this timeline occurred.

03 — Root cause

The diff

Vulnerable as deployed

                
                function withdraw(uint256 amount) external {
              
                
                    require(balances[msg.sender] >= amount, "insufficient");
              
                
                 
              
                
                    (bool ok, ) = msg.sender.call{value: amount}("");
              
                
                    require(ok, "transfer failed");
              
                
                 
              
                
                    balances[msg.sender] -= amount;
              
                
                }
              
Fixed as patched

                
                function withdraw(uint256 amount) external nonReentrant {
              
                
                    require(balances[msg.sender] >= amount, "insufficient");
              
                
                 
              
                
                    balances[msg.sender] -= amount;
              
                
                 
              
                
                    (bool ok, ) = msg.sender.call{value: amount}("");
              
                
                    require(ok, "transfer failed");
              
                
                }
              
  1. The external call hands control to the caller. Until balances is decremented, every re-entrant read of it returns the pre-withdrawal figure — the vault has already paid, and still believes it owes.

  2. Reordering to checks-effects-interactions closes this window. The nonReentrant guard closes the class.

  3. The guard alone would have stopped this instance. It would not have stopped a cross-function variant that re-enters a different, unguarded method touching the same balances mapping.

The vault tracked deposits in a balances mapping and paid withdrawals with a low-level call. That call is not a transfer — it is an invitation. It hands execution to the recipient, and the recipient decides when to give it back.

Because the balance was decremented after the call returned, the re-entrant frames all observed the same pre-withdrawal figure. The require check passed every time. The vault paid out 118 times against a balance it had credited once, and each frame was, on its own terms, correct.

Nothing here is novel. This is the first vulnerability in every Solidity curriculum, and it still clears eight figures — not because engineers do not know the pattern, but because the pattern hides inside a function that looks finished.

04 — Self-audit

Would your code have caught this?

Six questions. Answer them against your own repository, not against the one above. The cost line is what a “no” buys you.

  1. Does every function that sends value write its state changes before the external call?

    If no If not, a fallback function is all an attacker needs to read a balance you have already agreed to pay out.

  2. Is there a reentrancy guard on every externally callable path that touches shared balances — not only the obvious one?

    If no Cross-function reentrancy comes back in through the method you did not guard.

  3. Does your price oracle reject a stale round, and do you check the answer is positive?

    If no A stale or zero answer prices collateral at whatever the caller needs it to be.

  4. Is the upgrade path behind a timelock and a multisig, and can the implementation's initializer be called twice?

    If no An uninitialized implementation is an ownership takeover that needs no exploit at all.

  5. Do your invariant tests assert that the vault's balance equals the sum of user balances after every call?

    If no Every drain in this class breaks that one invariant, usually inside the first fuzz run.

  6. Does your monitoring alert on withdrawal count per transaction, not only on value moved?

    If no This drain is 118 calls inside one block. A value threshold fires after the money is gone.

05 — The next one

The next one lands in your inbox before the timeline catches up.

One teardown per incident. No digest, no roundup, no newsletter about the newsletter. Unsubscribe with the link in any issue.

06 — Standards

What this publication will and will not do

Mechanism, never character

A teardown describes code. It does not describe people. No individual is named, no team's competence is assessed, no attacker is identified or speculated about, and no organisation is accused of wrongdoing. If a fact cannot be established from the chain, the contract, or a public disclosure, it does not appear here.

Disclosed and resolved only

Nothing is published before public disclosure. Teardowns show the vulnerability pattern and its standard fix — never a runnable exploit, a proof-of-concept attack contract, or any material aimed at a live, unpatched system.

Published within 48 hours

Our standard is to publish within 48 hours of public disclosure, and to correct the record in place — with the change noted — whenever a better account of the mechanism emerges. That is a commitment about how we work, not a claim about how we compare.

Disclosure Root Cause is an educational publication about software defects. It is not investment advice, and nothing here is a recommendation to buy, sell, or hold any asset. It is not a security audit and does not certify that any codebase is safe. Digital assets carry risk, including total loss of capital. The checklist is a prompt for your own review — it is not a substitute for one.

Read the full editorial standards