On August 28, 2026, someone emptied about $1.1 million out of crypto card accounts run by a company called Rain. They started the day with none of the keys that were supposed to protect the funds. In under three hours, they walked away with $1,118,597.
The strange part is how they did it. The winning move was a signature made entirely of the same repeated byte, basically a signature of garbage. It should have been rejected instantly. Instead, the system waved it through and counted it as approval from the real account owners.
Here is the whole story, in plain terms.
What Rain does
Rain issues Visa cards backed by crypto. You lock up some stablecoins (USDC, USDT) as collateral, and you can spend against that balance with a normal card. On the blockchain, each user’s locked money sits in its own little vault.
Money going in is easy, anyone can send funds to a vault. Money coming out is the guarded part. To pull money out of a vault, the system demands approval from a set of admin keys. A minimum number of them (a “threshold”) have to sign off. No signatures, no withdrawal.
So the attacker’s whole problem was simple to state: fake the approval of admins whose keys you don’t have.
A quick tour of the program
Under the hood, Rain’s whole card system is a single program on Solana. One program handles about forty different actions: opening an account, posting a card charge, publishing a monthly statement, moving collateral, and so on. A few names are worth knowing:
A Coordinator is one partner (one neobank). Each partner gets its own copy of the program.
A Collateral is one user’s vault, the balance sitting behind their card.
A group of admin keys guards each vault, and a minimum number of them (the “threshold”) must approve any withdrawal.
The program has two completely different ways of checking permission. For routine admin work, like a partner changing its own settings, it uses a normal Solana signature: you sign the transaction, done. But for anything that touches user collateral, it uses a heavier system: a set of admin keys sign an approval off the chain, and enough of them have to agree. That second system is the one the attacker broke.
And to actually empty a vault, the attacker chained three of the program’s commands together, over and over:
SubmitSignatures, to hand in the forged approval.AddCollateralAdmin, to use that approval to make their own key an admin.WithdrawCollateralAsset, to take the money now that they counted as an admin.
The one check that was missing
Here is the piece you need to understand the trick.
Solana has a built-in signature checker, a small, trusted helper that confirms “yes, this signature really was made by this key, over this message.” Apps lean on it so they don’t have to do the math themselves.
But the helper doesn’t just take a key and a signature handed to it directly. Instead, each signature comes with a little set of pointers that say where to find the key, where to find the signature, and where to find the message, pointing at specific instructions inside the transaction.
Normally, every pointer points back at itself: “the key is right here, the signature is right here.” There’s even a special marker value that means “this instruction.” Rain’s code read the public key out of these instructions and trusted it as a real signer, but it never checked that the pointers actually pointed back home.
That missing check is the entire hole.
Here is the heart of it, simplified from the program’s real code. This loop runs over every signature handed in with a transaction:
for each entry in the attached signatures {
// Each entry has small pointer fields that say WHERE to find
// the key, the signature, and the message. Normally each one
// points back at itself, meaning "look right here."
// The program reads the public key straight out of the entry
// and treats it as a verified signer:
let key = entry.public_key
approved.push(key) // trusted, no questions asked
// THE BUG: it never checks that entry.public_key_index actually
// says "myself." So the key it just trusted can belong to a
// different entry, one that was never really verified.
}
The real function is named get_ed25519_signatures. The whole vulnerability is the equality check that should be sitting in that loop and isn’t. It is a ten-second fix that is very easy to miss.
The trick
The attacker built a transaction with two signature entries.
Entry one was honest. They made a brand-new throwaway key that controlled nothing, and signed the message with it for real. This entry passes the checker cleanly, because it is a real signature.
Entry two was the con. It carried a real admin’s public key, a garbage signature (64 bytes of the same value, 0x09 over and over), and pointers that were quietly redirected to point at entry one instead of itself.
Now watch what happens:
The checker looks at entry two’s pointers, follows them to entry one, and re-checks entry one’s real signature. It passes. The checker never looks at the garbage bytes in entry two.
Rain’s program then reads the public key sitting in entry two, a real admin, and counts that admin as having signed.
Public keys aren’t secret. Anyone can copy them off the blockchain. So the attacker just stacked up enough of these fake entries, each one naming a real admin, each one leaning on that single honest throwaway signature, until they hit the threshold.
A rough real-world version: imagine a guard who checks IDs. You hand him one genuine ID that belongs to a nobody. Then you hand him a stack of cards printed with real VIPs’ names, and next to each one you write “see the genuine ID.” The guard keeps glancing back at that one real nobody’s ID, nods, and lets the whole VIP list through.
You can see this in a real transaction on the chain. In the forging transaction 2ofQrrK3S6BTva4EJdBfoUBQaaFenhScCnxXcTsgQoaappfDbQpvFGuKhrBVi6Aa5rVrjw3kHwYBwiDQAFuEoo1J, entry two carries a genuine admin’s public key, a signature that is just the byte 0x09 repeated 64 times, and pointer fields set to 0 (pointing at entry one) instead of the “myself” marker. The transaction still succeeded. Researchers later replayed that exact transaction against both the broken and the fixed version of the program: it goes through on the broken one and gets rejected on the fixed one.
With a forged “yes” from the admins, the attacker added their own key as an admin, then drained the vault. They repeated this thousands of times.
The damage
$1,118,597 taken, $825,770 in USDC and $292,827 in USDT.
2,682 user collateral accounts emptied.
5,288 separate withdrawals, because each vault had to be drained on its own.
All of it in roughly two and a half hours.
Almost all of it came from just two of Rain’s card programs. One held about $628,000 (all in USDC) and was hit across 3,279 withdrawals. Another held about $434,000 (a mix of USDC and USDT) across 1,312 withdrawals. A third gave up about $56,000, and a fourth just $64. That is the entire haul.
The attacker signed 21,405 transactions that day. About 3,845 of them failed, the fingerprint of a script hammering away, trying vault after vault. Even the failures are revealing: the rejected transactions carry the program’s own error messages (”Invalid Signature.”, “Duplicated admin signature.”) and even leak the exact source-code file and line numbers, the kind of trail an automated draining script leaves behind.
Where the money went
The trail is clean and easy to follow on-chain:
The stolen stablecoins were swapped into Solana’s native coin, SOL.
10,000 SOL was moved into one collection wallet, then split, about 7,000 SOL to one address and 3,000 to another.
Using a cross-chain bridge, the funds crossed from Solana to Ethereum, arriving as about 418 ETH.
On Ethereum, the wallet gathered around 456 ETH total and then sent 455.9 ETH out in exactly 23 transactions, every single one into Tornado Cash, the mixing service people use to break the money trail.
After that, the trail goes cold by design. That’s what Tornado Cash is for.
It was one person, and they stopped on their own
Everything points to a single wallet behind the theft. Every dollar flowed to the same place, then down the same laundering path. There was no second thief riding along.
There’s also a detail worth correcting, because it’s tempting to assume the good guys saved the day: Rain patched the bug fast, within a couple of hours. But if you line up the timing, the attacker had already quit before the patch landed on each program. On the biggest target they stopped at 18:37 and the fix arrived at 19:18, a 41 minute gap. On the others the gaps were 22, 24, and 78 minutes. Every single time, the attacker was already gone before the patch. So the patch didn’t cut the attack short. The attacker most likely just ran out of vaults worth draining and stopped.
What the patch did do was slam the door on anyone else. Once the fix was live, the same trick returns nothing. And no copycat ever managed to repeat it.
The scope: a lot more was exposed than was taken
Here’s the uncomfortable part. Rain didn’t run this code once. It ran the same vulnerable program 25 times, one deployment per partner, all controlled by a single key. Every one of those 25 had the same hole.
Only four were actually drained. Why not all 25?
Not because of any defense. It’s because the money was concentrated, and because the attacker’s list was incomplete. The four drained programs held almost everything. The other 21 were nearly empty (thirteen had no user accounts at all), and just as importantly, the attacker never even visited them. One untouched program had over 34,000 user accounts and, when researchers pulled its actual code off the chain, it was byte-for-byte the same broken program as the ones that got drained. It carried the identical flaw. It survived for two dull reasons at once: it held only about $4,800, and the attacker’s script simply never reached it. Nothing protected it.
One more governance note that should make anyone nervous: all 66 of Rain’s programs, the 25 vulnerable ones and 41 others, were controlled by a single ordinary wallet holding about a tenth of a SOL. Not a multi-signature setup, not a hardware-secured committee. One key to update everything.
The fix
The repair was tiny, which is the whole lesson. Rain added the check that should have been there from the start: every signature entry must point back at itself. If a pointer aims anywhere else, the transaction is rejected.
In code, the fix is two lines added inside that same loop, right before the key is trusted:
// insist that this entry points at ITSELF, not somewhere else
require(entry.public_key_index == "myself")
require(entry.message_index == "myself")
If either pointer aims elsewhere, the transaction is thrown out. That’s it. Two comparisons. In the broken version of the program those checks appear zero times. In the fixed version, they are there.
Rain also cleaned up the mess the attack left behind, rogue admin keys and forged approvals don’t vanish on their own, and moved every account onto a simpler model where a single owner controls their own vault. Affected users were reimbursed.
If you want to check it yourself
None of this is a matter of trust. It is all sitting on public blockchains, and anyone can pull it up:
The thief’s wallet: FVNFzqAny8spWdPmYw6RQ9TkYa29ueFFiqCFD1gQnCEj on Solana. It was first funded at 13:40 UTC with about 1.79 SOL and never held a single admin key.
The forging transaction: 2ofQrrK3S6BTva4EJdBfoUBQaaFenhScCnxXcTsgQoaappfDbQpvFGuKhrBVi6Aa5rVrjw3kHwYBwiDQAFuEoo1J, the fake approval that started a drain.
The 10,000 SOL move: 27hZesVseFJ71rduvfYfJPnu1fwEEtsMXFjHYjypfvZKzHGTrpU7nTb8kZP9DmDAHUd8THg95SR6iHebnYZTCije, where the stolen funds were gathered up.
The Ethereum wallet: 0x2cE21E4921d3Eb116526c3651Dac0257657338D5, which sent 455.9 ETH into Tornado Cash in 23 transactions.
The four drained programs: CWgkFB7ngUc9cGD1LryyhP7h6xYWtwrAjhSKKCoR1gkz, 26DkA98jjctzPkBEteUsN935CR4dsKx3XvjrtE7MeL4a, 3zVB27Gap6fbxpAcV2hsBBUcV3vRjkCikBXREiyBzDuc, and 8r2jms1vAnHhtCWxDNSgiHvQNSzzJvGa9oBRCbBPCBNN.
If you go a step further and download the two versions of the program straight from the chain, you can line them up side by side. The broken one has zero copies of the safety check. The fixed one has it. That is the whole difference between a normal Tuesday and a $1.1 million loss.
The takeaway
The bug wasn’t exotic. It was a trust gap: the program trusted a name it was handed without confirming that name had actually been verified. The blockchain’s signature checker did its job perfectly, it verified the one real signature it was pointed at. The mistake was human, in the glue code that read the results and forgot to ask, “wait, is this the key you actually checked?”
Two plain lessons fall out of it:
When you rely on a verifier, bind its result to the exact thing you’re acting on. Reading a key from an instruction the verifier never confirmed is trusting a stranger’s paperwork.
One key controlling everything is a single point of failure, whether or not this particular bug existed.
The money that was stolen is gone into a mixer. But the whole event is written permanently into the blockchain: the fake signature, the drained vaults, the laundering hops, and the one-line fix. Anyone can go read it. That permanence is the double edge of this world: it’s why the theft worked at scale, and it’s why the entire crime scene is still sitting there in the open for anyone who wants to trace it.

