โ† 0xrivet

maya protocol โ€” how a security system became the exploit

2026-08-19 ยท exploit analysis ยท portland, or

i posted a thread on this yesterday, but 280 characters at a time doesn't do it justice. this one needs room to breathe because the mechanism is genuinely novel. i haven't seen this pattern before.

maya protocol lost ~20.83 BTC. the attacker didn't find a reentrancy bug. didn't exploit a price oracle. didn't even need a flash loan. they made maya's own slashing mechanism โ€” the system designed to protect the protocol โ€” drain the reserve into a pool they controlled.

the security system was the exploit.


background: trade accounts

maya is a thorchain fork. mid-2025, they ported over a feature called Trade Accounts โ€” essentially off-pool deposit slots where users can park assets for faster trading. assets in trade accounts sit outside the main vault accounting structure.

this is what caught my attention. when you add a new asset custody mechanism to a protocol, every security system that references asset balances needs to know about it. solvency checkers, slashing logic, outbound validation โ€” all of it.

maya ported the feature. they did not update the security systems.


the attack

eight steps. i'll walk through each one because the sequencing matters โ€” this wasn't a smash-and-grab, it was choreographed.

step 1: deposit into the blind spot

attacker deposits 8 ETH and 2 LINK into Trade Accounts. these assets are now held by the vault but stored outside vault.Coins โ€” the data structure that the solvency checker reads.

// handler_solvency.go โ€” what gets checked
// vault.Coins is the ONLY source of truth
// trade account balances? not referenced. not once.
// grep the file yourself โ€” zero hits for "trade"

the vault has the assets. the solvency checker can't see them. already a problem, but it gets worse.

step 2: pre-position the pool

attacker donates 27,789 CACAO to the ARB.LINK pool. this pool was near-empty โ€” low liquidity, negligible volume. by donating into it, the attacker is setting up the pool they'll extract from later. nobody noticed because donating to a pool is a normal, permissionless operation.

step 3: the 23-message transaction

this is the core of the exploit. one transaction. 23 MsgDeposit messages packed inside it.

messagespurpose
22trade account withdrawals
1DONATE:ARB.LINK

the 22 withdrawals are the interesting part. each withdrawal amount is incremented by approximately 263 units from the previous one. this isn't random โ€” it prevents maya's transaction batching logic from combining them into a single outbound. each withdrawal must be processed as a separate L1 transaction.

22 individual outbound transactions, all originating from the vault.

step 4: the misclassification

here's where the security system breaks. nodes observe 22 L1 outbound transactions leaving the vault. they check these against the scheduled TxOutItems โ€” the list of outbounds the protocol authorized.

trade account withdrawals aren't tracked in TxOutItems.

so the nodes see 22 outbounds that don't match any scheduled item. the logic in handler_observed_txout.go has a name for outbounds that leave the vault without authorization: theft.

// handler_observed_txout.go โ€” slashObservedOutbound()
// outbound observed but not in TxOutItems?
// โ†’ classified as unauthorized
// โ†’ trigger SlashVaultToLP(subsidize=true)

the protocol thinks it's being robbed. it isn't. these are legitimate withdrawals from a feature the security system doesn't know about. but the slashing logic doesn't ask questions. it acts.

step 5: slash and subsidize

SlashVaultToLP fires. the vault's bond gets slashed for the "theft." then comes the subsidize=true flag.

when a vault is slashed for an unauthorized outbound, the protocol tries to make LPs whole. it calls subsidizePoolWithSlashBondV92() in helpers.go. this function transfers CACAO from the Reserve module to affected pools.

the function has no cap.

// helpers.go โ€” subsidizePoolWithSlashBondV92()
// line 175
//
// calculates subsidy amount based on slash value
// transfers from Reserve to pool
// NO maximum transfer limit
// NO check against reserve balance
// NO rate limiting

22 "unauthorized" outbounds. 22 slash events. each one pumping CACAO from the Reserve into pools. the Reserve held roughly 50M CACAO. by the time the slashing finished, 49.42M CACAO had been transferred to pools โ€” with the ARB.LINK pool (the one the attacker pre-positioned) receiving a massive share.

step 6: extraction

the attacker adds 100 CACAO as liquidity to the now-bloated ARB.LINK pool. then withdraws 99% of their LP position.

directionamount
LP deposit100 CACAO
LP withdrawal48,869,502 CACAO
multiplier488,695x

100 CACAO in, 48.8M CACAO out. the pool ratio was so distorted from the subsidy flood that this withdrawal was mathematically valid.

step 7: cash out

10 consecutive CACAO-to-BTC swaps. 20.83 BTC extracted to an external wallet. clean, sequential, no rush.

# attacker addresses
maya:  maya1dl3yrfpedyr5jfr0r86s2apjltnjqgszmwsv8x
btc:   bc1q0hsgwunccczelq05ucpmfz268eyy5jr2y5l646

three bugs, one chain

no single bug here is catastrophic on its own. it's the combination that kills.

bug 1: solvency blind spot

handler_solvency.go checks vault solvency by comparing vault.Coins against actual on-chain balances. trade account assets exist on-chain but aren't in vault.Coins. the solvency checker has no reference to trade accounts โ€” the string "trade" doesn't appear in the file.

in BTS terms, this is an L1 verification failure โ€” the system's model of reality doesn't match reality. the vault holds assets that the verification layer can't see.

bug 2: outbound misclassification

handler_observed_txout.go classifies any observed outbound not matching a scheduled TxOutItem as unauthorized. trade account withdrawals are legitimate but unscheduled. the handler can't distinguish "feature i don't know about" from "someone is stealing funds."

this is the bridge equivalent of a guard who shoots anyone leaving the building who isn't on today's visitor log โ€” including employees who entered through a door the guard doesn't monitor.

bug 3: uncapped reserve subsidy

subsidizePoolWithSlashBondV92() in helpers.go transfers CACAO from Reserve to pools with no maximum limit. no percentage cap. no per-event ceiling. no rate limiter. the function assumes that slash events are rare and legitimate โ€” that the amount being transferred is proportional to an actual theft.

when the input is 22 fake "theft" events in rapid succession, the function drains the Reserve.

BTS L5 circuit breaker โ€” nonexistent. no rate limiting on reserve outflows. 49.42M CACAO moved in one sequence without any mechanism hitting the brakes.


the porting problem

trade accounts were a thorchain feature ported to maya mid-2025. the feature itself worked correctly โ€” deposits, withdrawals, accounting, all fine. what wasn't ported was the awareness of trade accounts across the security stack.

this pattern shows up more than people admit. protocol forks a feature from upstream. feature code works. security code doesn't know the feature exists. the gap sits there until someone finds it.

systemtrade account awareness
deposit handleryes
withdrawal handleryes
trade accountingyes
solvency checkerno
outbound classifierno
subsidy calculatornot applicable (no cap regardless)

three systems that needed updating. zero were updated.


current state

as of writing:

networkglobally halted (HALTCHAINGLOBAL: 1)
reserve419,808 CACAO (was ~50M)
CACAO pricedown ~89%
extracted20.83 BTC

what this maps to in BTS

i've been building the bridge trust score framework around the idea that bridge security is multiplicative โ€” one zero makes the product zero. maya is a clean case study.

BTS layermaya scorewhy
L1 verificationfailedsolvency model incomplete โ€” trade accounts invisible
L5 circuit breakerabsentno cap on reserve subsidy, no rate limiting

the attack surface wasn't in the trade account code. it was in the gap between the feature and the security systems that should have known about it. this is the kind of thing BTS is designed to catch โ€” not code bugs, but model completeness. does the verification layer's view of the world match the actual world?

at maya, it didn't. the solvency checker saw a vault that looked correct while the vault held assets the checker couldn't account for. the outbound classifier saw "theft" where there was a legitimate withdrawal. the subsidy function saw a crisis and emptied the treasury.

every system did exactly what it was programmed to do. the bug wasn't in any one system. it was in the fact that they were programmed against an incomplete model of the protocol.


takeaway

when you fork a feature, you're not just porting code. you're porting a set of assumptions that every other system in the protocol needs to share. trade accounts assumed the solvency checker would know they exist. the solvency checker assumed vault.Coins was complete. the outbound classifier assumed every legitimate outbound would be scheduled. the subsidy function assumed slash events reflected real theft.

every assumption was reasonable in isolation. together, they created a pipeline that converted legitimate withdrawals into a reserve drain.

the attacker understood this better than the developers did. that's usually how it goes.


expanded from the thread on x. the thread had the what โ€” this has the why. bridge-security-toolkit is being updated to flag model-completeness gaps like this.

written while the hawthorne bridge was up for a boat and my coffee was getting cold. โ€” 0xrivet