Reproduced Exploit
Laundromat Exploit — Free Ring-Membership Drains a Dormant Mixer
Laundromat is an ancient on-chain mixer that implements a linkable ring signature (the academic "Möbius"/ring-signature ETH-mixing scheme). It is parameterised by a fixed ring size participants and a fixed payment amount. Each participant is expected to deposit() their fixed payment together with a…
Loss
~$1.5K — 1.0 ETH (the entire pool, deposited by one honest participant)
Chain
Ethereum
Category
Access Control
Date
Apr 2025
Source & credit. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2025-04-Laundromat_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/Laundromat_exp.sol.
Vulnerability classes: vuln/access-control/missing-auth · vuln/auth/signature-validation
One-line summary: a 2016-era ring-signature ETH mixer left its deposit fee check commented out, so an attacker registered as 4 of the 5 ring members for free, forged the ring signature it now controlled, and walked off with the 1 ETH a real participant had deposited ~9 years earlier.
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder (the umbrella DeFiHackLabs repo contains many unrelated PoCs that do not all compile, so this one was extracted). Full verbose trace: output.txt. Verified vulnerable source: sources/Laundromat_934cbb/Laundromat.sol.
Key info#
| Loss | ~$1.5K — 1.0 ETH (the entire pool, deposited by one honest participant) |
| Vulnerable contract | Laundromat — 0x934cbbE5377358e6712b5f041D90313d935C501C |
| Victim / pool | The Laundromat mixer itself; the funded ring slot belonged to one real depositor |
| Helper library | ArithLib (secp256k1 jacobian EC math) — 0x600ad7b57f3e6aeee53acb8704a5ed50b60cacd6 |
| Attacker EOA | 0xd6BE07499d408454D090c96bd74A193F61f706F4 |
| Attacker contract | 0x2E95CFC93EBb0a2aACE603ed3474d451E4161578 |
| Attack tx | 0x08ffb5f7ab6421720ab609b6ab0ff5622fba225ba351119c21ef92c78cb8302c |
| Chain / block / date | Ethereum mainnet / 22,222,687 (forked at 22,222,686) / 2025-04-08 |
| Compiler (victim) | Solidity v0.4.4, optimizer on (200 runs) |
| Bug class | Missing access/payment validation → free ring membership → forgeable ring signature |
TL;DR#
Laundromat is an ancient on-chain mixer that implements a linkable ring signature (the
academic "Möbius"/ring-signature ETH-mixing scheme). It is parameterised by a fixed ring size
participants and a fixed payment amount. Each participant is expected to deposit() their fixed
payment together with a curve public key (pubkey1, pubkey2); once the ring is full, any one
participant can later prove ring membership with a ring signature and withdraw exactly payment.
The whole security model rests on the assumption that each ring slot is backed by one real,
fee-paying, independent participant whose private key the others do not know. That assumption is
destroyed by a single commented-out line in deposit():
function deposit(uint _pubkey1, uint _pubkey2) payable {
//if(msg.value != payment) throw; // ⚠️ payment check DISABLED
if(gotParticipants >= participants) throw;
pubkeys1.push(_pubkey1);
pubkeys2.push(_pubkey2);
gotParticipants++;
}
(sources/Laundromat_934cbb/Laundromat.sol:82-89)
With the payment check gone, deposit() accepts public keys of the attacker's choosing for free.
At the fork block, the contract had been sitting dormant since ~2016 with:
participants = 5, payment = 1 ETH, gotParticipants = 1, and a 1 ETH balance — one honest
person had filled slot 0 and deposited 1 ETH, and the ring was never completed.
The attacker simply:
- Called
deposit()4 times for free, filling slots 1-4 with key pairs it generated itself (so it knows the corresponding "private keys" / ring secrets). The ring is now full (5/5). - Ran the withdraw protocol (
withdrawStart→ 5×withdrawStep→withdrawFinal) producing a ring signature. Because the attacker now controls 4 of the 5 ring members and the verification only checks that the ring closes back on itself (ring1[n] == ring1[0]), it forged a closing signature without ever knowing the honest participant's secret. withdrawFinal()verified the closed ring and sentpayment = 1 ETHto the attacker.
Net result: attacker ETH balance 0.05 ETH → 1.05 ETH = +1.0 ETH, exactly the honest participant's deposit.
Background — what Laundromat does#
Laundromat (source) is a fixed-anonymity-set ETH mixer
built around a linkable ring signature over secp256k1. The EC arithmetic is delegated to a
separate already-deployed library, ArithLib
(:11-22), which exposes jacobian-coordinate
primitives jmul (scalar mul), jsub, jdecompose, and hash_pubkey_to_pubkey.
The lifecycle is three phases:
- Deposit phase. Up to
participantspeople calldeposit(pubkey1, pubkey2), each supposed to sendpaymentETH and register their curve public key. Keys accumulate in the public arrayspubkeys1/pubkeys2;gotParticipantscounts how many slots are filled (:82-89). - Withdraw phase (multi-step). Once the ring is full (
gotParticipants == participants), a withdrawer callswithdrawStart(signature[], x0, Ix, Iy)to seed a per-senderWithdrawInfo(the candidate ring signature, the key imageI = (Ix, Iy), and the ring's initial commitmentx0) (:92-110). They then callwithdrawStep()exactlyparticipantstimes; each step advances the ring by one member, recomputing the next challenge from the previous one using the EC math (:112-156). - Finalisation.
withdrawFinal()checks that afterparticipantssteps the ring closed (the last challenge equals the first), marks the key image asconsumedto prevent double-spend, and sendspaymentto the withdrawer (:158-178).
On-chain parameters at the fork block (read via cast against block 22,222,686):
| Parameter | Value |
|---|---|
participants (ring size) | 5 |
payment | 1 ETH (1e18 wei) |
gotParticipants | 1 (one honest slot filled) |
| Contract ETH balance | 1.0 ETH ← the prize |
| Attacker EOA balance | 0.05 ETH |
ArithLib codesize | 1,883 bytes (library present) |
That gotParticipants = 1 with a 1 ETH balance is the whole game: one real user deposited and the
ring was never completed, leaving the mixer holding funds that anyone who can complete and forge the
ring can claim.
The vulnerable code#
1. deposit() — the payment check is commented out#
function deposit(uint _pubkey1, uint _pubkey2) payable {
//if(msg.value != payment) throw; // ⚠️ DISABLED
if(gotParticipants >= participants) throw;
pubkeys1.push(_pubkey1);
pubkeys2.push(_pubkey2);
gotParticipants++;
}
(sources/Laundromat_934cbb/Laundromat.sol:82-89)
There is no msg.value enforcement and no per-depositor identity / key-uniqueness check. A
single actor can occupy every remaining ring slot at zero cost, choosing public keys for which it
knows the discrete logs.
2. withdrawFinal() — verification only checks the ring closes#
function withdrawFinal() returns (bool) {
WithdrawInfo withdraw = withdraws[uint(msg.sender)];
if(withdraw.step != (participants + 1)) throw;
if(consumed[uint(sha3([withdraw.Ix, withdraw.Iy]))]) throw;
if(withdraw.ring1[participants] != withdraw.ring1[0]) {
LogDebug("Wrong signature");
return false;
}
if(withdraw.ring2[participants] != withdraw.ring2[0]) {
LogDebug("Wrong signature");
return false;
}
withdraw.step++;
consumed[uint(sha3([withdraw.Ix, withdraw.Iy]))] = true;
safeSend(withdraw.sender, payment); // ⚠️ pays out the fixed amount
return true;
}
(sources/Laundromat_934cbb/Laundromat.sol:158-178)
The ring signature is "valid" iff, after walking all participants members, the recomputed challenge
loops back to the seed (ring1[n] == ring1[0] and ring2[n] == ring2[0]). The soundness of that
check depends entirely on the signer knowing the secret for at least one ring member that it does
not otherwise control — i.e., on ring slots being filled by independent, fee-paying participants.
Once the attacker owns 4 of the 5 slots (because deposits were free), it has enough degrees of
freedom to choose signature[], x0, and I so that the ring closes — a standard ring-signature
forgery when you control all-but-one (here, all the meaningful) ring keys.
3. The step recurrence (where the EC math runs)#
(k1x,k1y,k1z) = arithContract.jmul(Gx, Gy, 1, withdraw.signature[withdraw.prevStep % participants]);
(k2x,k2y,k2z) = arithContract.jmul(pubkeys1[withdraw.step % participants],
pubkeys2[withdraw.step % participants], 1,
withdraw.ring2[withdraw.prevStep % participants]);
(k1x,k1y,k1z) = arithContract.jsub(k1x,k1y,k1z, k2x,k2y,k2z);
...
withdraw.ring1.push(uint(sha3([uint(withdraw.sender), pub1x, pub1y, k1x, k1y])));
withdraw.ring2.push(uint(sha3(uint(sha3([...])))));
withdraw.step++; withdraw.prevStep++;
(sources/Laundromat_934cbb/Laundromat.sol:129-155)
Each withdrawStep() consumes one ring public key and one signature scalar to derive the next
challenge. The trace shows exactly this: per step there are two jmuls with the generator and with a
ring pubkey, a jsub, a jdecompose, and a hash_pubkey_to_pubkey, all against the attacker-chosen
ring keys.
Root cause — why it was possible#
The contract conflates "a slot in the ring" with "an independent, fee-paying participant." Two compounding defects:
- No deposit validation (the disabled
msg.value != paymentcheck). This single commented-out line means filling a ring slot costs nothing and requires no proof that the public key belongs to a distinct, honest party. The attacker can deterministically generate(pubkey1, pubkey2)pairs whose discrete logs it knows and push all of them in. - The withdraw verifier trusts the ring's composition.
withdrawFinal()only verifies the ring closes; it has no way to ensure the ring contains at least one member whose secret the withdrawer doesn't know. The anonymity-set assumption ("only a true member can close the ring") collapses the moment the withdrawer controls the keys of every-but-the-honest slot — and with 4 of 5 controlled keys plus freedom oversignature[],x0, and the key imageI, closing the ring is mechanical.
In short: the security of a ring mixer is "you can withdraw only if you are one of the depositors who actually paid in." Because depositing was free and unauthenticated, the attacker became (almost) the entire ring, and the cryptographic check that was supposed to stop a non-depositor became trivially satisfiable.
This is best understood as a classic missing-input-validation bug (the absent payment/identity
check) that cascades into a cryptographic-soundness failure. It is not a flaw in ArithLib's EC
math — the math executed correctly; it was fed an adversary-controlled ring.
Preconditions#
- The mixer is deployed and funded but its ring is incomplete —
gotParticipants < participantswith at leastpaymentETH already in the contract. Here:1/5slots filled,1 ETHheld. (If the ring were already full,deposit()reverts ongotParticipants >= participantsand the attacker cannot insert its keys.) - The honest deposit's key image has not been
consumed(the legitimate depositor never withdrew). - The attacker can construct a ring signature that closes given control of all-but-one ring keys —
i.e., it can pick
signature[],x0,Ix,Iy(and the 4 free pubkeys) consistently. In the live attack these values were precomputed off-chain and hard-coded into the attacker contract (test/Laundromat_exp.sol:43-78). - No working capital is required beyond gas: the 4 deposits send 0 ETH each.
Attack walkthrough (with on-chain numbers from the trace)#
All figures are taken directly from output.txt. Slots refer to pubkeys1/pubkeys2
array indices; storage slots 6/7/8 are gotParticipants / pubkeys1.length / pubkeys2.length.
| # | Call | Effect (from trace storage changes) | Value sent |
|---|---|---|---|
| 0 | Initial state (block 22,222,686) | participants=5, payment=1e18, gotParticipants=1, balance 1 ETH | — |
| 1 | deposit(pk1, pk2) | slots 6/7/8: 1 → 2; pubkey written to keccak(7)+1 / keccak(8)+1 | 0 ETH |
| 2 | deposit(pk1, pk2) | 2 → 3; ring slot 2 filled | 0 ETH |
| 3 | deposit(pk1, pk2) | 3 → 4; ring slot 3 filled | 0 ETH |
| 4 | deposit(pk1, pk2) | 4 → 5; ring full (gotParticipants == participants) | 0 ETH |
| 5 | withdrawStart(sig[5], x0, Ix, Iy) | seeds WithdrawInfo: stores signature[], Ix/Iy, x0, ring1[0], step=1 | — |
| 6 | withdrawStep() #1 | EC recurrence (2×jmul, jsub, jdecompose, hash_pubkey_to_pubkey); step 1→2 | — |
| 7 | withdrawStep() #2 | step 2→3 | — |
| 8 | withdrawStep() #3 | step 3→4 | — |
| 9 | withdrawStep() #4 | step 4→5 | — |
| 10 | withdrawStep() #5 | step 5→6 (= participants + 1); ring fully walked, ring1[5]/ring2[5] computed | — |
| 11 | withdrawFinal() | ring1[5]==ring1[0] ✓ and ring2[5]==ring2[0] ✓ → marks consumed, safeSend(attacker, 1 ETH) | −1 ETH out |
| 12 | selfdestruct(attacker) | attacker contract self-destructs, forwarding its 1 ETH to the EOA | — |
Note: the same four free deposits used the identical key pair
(pk1 = 0x53fc1ed6…45ca8a, pk2 = 0xdd3a0e94…52d3b6) in this PoC — the contract performs no
duplicate-key rejection, underscoring the absence of any participant validation.
The single observable payout in the trace:
[38985] 0x934cbbE5…501C::withdrawFinal()
├─ [0] 0x2E95CFC9…1578::fallback{value: 1000000000000000000}() // 1 ETH to attacker contract
└─ ← [Return] true
Profit / loss accounting (ETH)#
| Amount | |
|---|---|
Deposit fees paid by attacker (4 × deposit) | 0 |
| Attacker EOA balance before | 0.05 |
Payout from withdrawFinal() | +1.0 |
| Attacker EOA balance after (post-selfdestruct) | 1.05 |
| Net attacker profit | +1.0 ETH |
| Victim (mixer) loss | −1.0 ETH (the honest depositor's entire stake) |
Confirmed by the test logs: before attack: 0.05 → after attack: 1.05
(output.txt:1568, 1802).
Diagrams#
Sequence of the attack#
Pool / state evolution#
Why the forgery works (trust collapse)#
Remediation#
- Re-enable (and never comment out) the deposit payment check.
deposit()must enforcerequire(msg.value == payment). A ring mixer where slots can be claimed for free is fundamentally broken — the economic backing of each slot is what the ring signature is supposed to protect. - Authenticate / de-duplicate ring membership. Reject duplicate public keys and ideally require a proof-of-knowledge of the discrete log at deposit time, so a single actor cannot stuff the ring with keys it controls. (Here, four identical key pairs were accepted without complaint.)
- Do not let one party own the whole ring. Even with the payment check, a single funder could complete a thin ring. Mitigations: minimum distinct depositors, time-locked deposit windows, or binding each slot to a distinct, externally-attested identity. The cryptographic soundness of the ring signature is only as strong as the independence of its members.
- Treat dormant funded contracts as live risk. This contract sat exploitable for ~9 years. Mixers/escrows that hold funds with an incomplete quorum should expire and refund honest depositors rather than leave value claimable indefinitely.
- Audit commented-out security checks. A disabled
require/throwis a code smell that should be caught in review and CI — the single//here was the entire vulnerability.
How to reproduce#
The PoC was extracted into a standalone Foundry project (the umbrella DeFiHackLabs repo does not build
as a whole under forge test):
_shared/run_poc.sh 2025-04-Laundromat_exp -vvvvv
- RPC: an Ethereum mainnet archive endpoint is required (the fork block 22,222,686 is from
2025-04-08).
foundry.tomluses an Infura archive endpoint, which serves historical state at that block. - The victim
Laundromatwas compiled with Solidity v0.4.4; the PoC itself targets^0.8.10and reaches the contract purely via low-levelcallselectors, so no cross-version compilation is needed. - Result:
[PASS] testPoC()with the attacker balance rising from0.05to1.05ETH.
Expected tail:
Ran 1 test for test/Laundromat_exp.sol:ContractTest
[PASS] testPoC() (gas: 7330994)
before attack: balance of attacker: 0.050000000000000000
after attack: balance of attacker: 1.050000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped
References: PoC header (TenArmor post-mortem — https://x.com/TenArmorAlert/status/1909814943290884596).
On-chain state (participants=5, payment=1e18, gotParticipants=1, balance 1 ETH) read via cast
against fork block 22,222,686.
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2025-04-Laundromat_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
Laundromat_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- Original alert / thread: post on X.
- DeFiHackLabs incident explorer: search "Laundromat Exploit".
- Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.