$41.2M ($41,200,000) drained from Cindervault
Reentrancy — state written after the external call
(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 email02 — Timeline
Block by block
Cindervault
2026-05-14 · times in UTC
- 14:02:11 Attacker wallet funded from an unattributed source. 0x0000…3e91 sample
- 14:04:58 Helper contract deployed. It exposes a payable fallback and no other public method. 0x0000…31bd sample
- 14:06:44 First probe. 0.5 ETH deposited and immediately withdrawn. The fallback does nothing. 0x0000…2e63 sample
- 14:08:20 Probe repeated. The fallback now calls withdraw() once more before returning. It succeeds. 0x0000…cb18 sample
- 14:09:02 Drain. A single transaction re-enters withdraw() 118 times before the first call unwinds. 0x0000…93ca sample
- 14:09:02 Vault ETH balance reaches zero. Later withdrawal attempts by depositors revert. 0x0000…f0e8 sample
- 14:31:47 Proceeds consolidated across three wallets. No further interaction with the vault. 0x0000…027d sample
- 16:05:12 Vault paused by the operator. Deposits and withdrawals disabled. 0x0000…8fd6 sample
- 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
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;
}
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");
}
-
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.
-
Reordering to checks-effects-interactions closes this window. The nonReentrant guard closes the class.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.