|
| 1 | +# THORChain Router — Ethereum / BSC / Base / Bitcoin — 2026-05-15 |
| 2 | + |
| 3 | +**Loss:** ~$7.4M (ERC20 + ETH) on Ethereum; ~$3M on BSC/Base/BTC combined. |
| 4 | +**OAK Techniques observed:** OAK-T10.008 (Bridge Observer Signature Scope Truncation) — primary. |
| 5 | +**Attribution:** **pseudonymous** — single attacker operating as a malicious proposer (node operator) in THORChain's Bifrost observer network. Vanity addresses with suffix pattern `bDA` (destination address) and `90bd` (laundering hops) were incidental, not causal. |
| 6 | + |
| 7 | +**Key teaching point:** T10.008 is the first OAK technique targeting the "signature scope boundary" — the architectural seam between what a bridge observer signs and what the consensus layer executes. In THORChain's Bifrost, `GetSignablePayload()` signed only the inner `Tx` data; the `ObservedTx` wrapper's direction flag (inbound/outbound) was outside the signed boundary. A malicious proposer flipped the direction flag on a legitimate deposit observation from inbound to outbound, and the existing observer signatures remained valid because the flag was never part of the signed payload. L1 consensus accepted the forged observation as a valid outbound migration, executing `transferAllowance` to the attacker's address. The attacker then operated stealthily for ~48 hours — processing only ETH outbounds (bypassing allowance checks via `msg.value`) while holding ERC20 allowances idle — before draining in a single transaction. The `thornode` patch (`af46db22`) fixed this by expanding `GetSignablePayload()` to cover the full `ObservedTx` wrapper. |
| 8 | + |
| 9 | +## Summary |
| 10 | + |
| 11 | +On 2026-05-15 at 08:12 UTC, THORChain's Router contract was drained of ~$7.4M in ERC20 tokens on Ethereum. The attacker exploited a **signature scope truncation** vulnerability in Bifrost, THORChain's cross-chain observer protocol. |
| 12 | + |
| 13 | +Bifrost observers sign observations of external chain events so that L1 consensus can authorize on-chain execution (deposits, swaps, vault migrations). The function `GetSignablePayload()` returned only the inner transaction (`Tx`) bytes for signing — it did **not** include the `ObservedTx` wrapper fields, including the `direction` flag that distinguishes inbound observations (deposits) from outbound observations (withdrawals, migrations). |
| 14 | + |
| 15 | +A malicious proposer (node operator) took a legitimate deposit observation — where an observer had witnessed and signed an inbound transaction — flipped the unsigned `direction` flag from inbound to outbound, and committed the forged observation to L1 consensus. The existing observer signatures were valid for the inner `Tx` data, so consensus accepted it. During vault churn (`MIGRATE:26146440` at block 25,080,575), this forged observation caused `transferAllowance` to grant allowances to the attacker's address (`0x82a5...bDA`). |
| 16 | + |
| 17 | +The dust transactions from lookalike addresses (all ending in `bDA`) that initially appeared to be the attack vector were, per the revised analysis, "likely a side effect, not the main attack vector." The attacker may have generated vanity addresses as operational camouflage, but the root cause was the unsigned direction flag in Bifrost's observation signing. |
| 18 | + |
| 19 | +The attacker then waited ~48 hours, processing 166 ETH outbound transactions (which use `msg.value` and bypass `_vaultAllowance` checks) to appear as a functioning vault, before draining all ERC20 allowances in a single transaction. Global trading was paused by node operators via the Mimir mechanism at approximately 09:15 UTC. |
| 20 | + |
| 21 | +## Timeline (UTC) |
| 22 | + |
| 23 | +| When | Event | OAK ref | |
| 24 | +|---|---|---| |
| 25 | +| Pre-exploit | Bifrost `GetSignablePayload()` signs only inner `Tx`, omitting `ObservedTx` wrapper direction flag | **T10.008 surface existed** | |
| 26 | +| 2026-05-12 21:38 | Attacker funds address `0x82a5...bDA` with 0.083 ETH | (preparation) | |
| 27 | +| 2026-05-12~14 | Dust transactions from lookalike `bDA`-suffix addresses — likely side effect/camouflage, not the attack vector | (incidental) | |
| 28 | +| 2026-05-13 ~05:00 | **Critical moment:** Malicious proposer commits forged observation: legitimate deposit observation with direction flag flipped inbound→outbound. L1 consensus accepts. `transferAllowance(router, attacker, USDC, ~798K)` executes at block 25,080,575. Memo: `MIGRATE:26146440` | **T10.008 observation forgery** | |
| 29 | +| 2026-05-13~14 | Stealth period (~48h): Attacker processes 166 ETH outbounds via `transferOut` (bypasses allowance check via `msg.value`). ERC20 allowances sit untouched | **T10.008 stealth positioning** | |
| 30 | +| 2026-05-15 08:12 | Drain execution: all ERC20s drained from Router to theft address `0x82Fc...54Eb`. USDT $1.76M, USDC $1.26M, WBTC $540K, + 9 other tokens | **T10.008 drain** | |
| 31 | +| 2026-05-15 08:13~09:18 | Laundering: tokens → ETH via DEX aggregators; ETH chunked into 1,866 ETH pieces → 3 intermediate addresses (all ending `90bd`) | (post-exploit) | |
| 32 | +| 2026-05-15 ~09:15 | Mimir global pause activated by node operators | (response) | |
| 33 | +| Post-incident | `thornode` patch `af46db22`: `GetSignablePayload()` expanded to sign full `ObservedTx` wrapper including direction flag | (fix) | |
| 34 | + |
| 35 | +## What defenders observed |
| 36 | + |
| 37 | +- **Pre-exploit (protocol-level):** Bifrost's `GetSignablePayload()` returned only the inner `Tx` struct. The `ObservedTx` wrapper — containing `direction`, `destination`, `chain`, `memo`, `migration_slot` — was excluded from the signed payload. Any proposer could modify these wrapper fields without invalidating observer signatures. |
| 38 | +- **At-exploit (observation forgery):** A single proposer committed an observation where the wrapper's direction flag was OUTBOUND, but the inner signed `Tx` described a deposit (inbound). This wrapper-vs-inner inconsistency was detectable at commit time — the observation was semantically impossible (an outbound that describes a deposit). |
| 39 | +- **At-exploit (proposer divergence):** The malicious proposer's committed wrapper metadata differed from what other observers witnessed for the same event. Multi-observer wrapper comparison would have caught the divergence. |
| 40 | +- **During-churn (destination validation):** The `newVault` address (`0x82a5...bDA`) was not cryptographically derivable from THORChain's TSS pubkey. On-chain destination validation — `require(deriveAddress(tssPubkey) == newVault)` — would have blocked the migration regardless of the forged observation. |
| 41 | +- **Post-migration (stealth positioning):** The attacker's address processed 166 ETH outbounds (nonce 0→166) while holding ERC20 allowances completely idle for ~48 hours. A legitimate vault processes ERC20 and ETH outbounds proportionally; an address processing only ETH while holding large ERC20 allowances is anomalous. |
| 42 | + |
| 43 | +## What this example tells contributors writing future Technique pages |
| 44 | + |
| 45 | +- **T10.008 detection starts at the protocol source level.** The definitive signal is the gap between `GetSignablePayload()` output fields and the full `ObservedTx` struct fields that influence execution. Every bridge observer protocol should be audited for unsigned execution-relevant wrapper fields. This is a pre-exploit audit, not a runtime detection. |
| 46 | +- **Wrapper-vs-inner inconsistency is the runtime signal.** When the direction flag says "outbound" but the inner transaction describes a deposit, the observation is forged regardless of signature validity. This check requires protocol-level awareness of the wrapper/inner struct relationship. |
| 47 | +- **Multi-observer wrapper comparison catches the proposer.** If other observers witnessed the same event and their wrapper metadata differs from what the proposer committed, the proposer forged the unsigned fields. This is the consensus-level detection that requires access to individual observer signatures, not just the committed observation. |
| 48 | +- **Destination cryptographic validation is defense-in-depth (PATH D).** Even if the signing scope flaw is undiscovered, verifying that the migration destination is derivable from the TSS pubkey catches forged observations. This check lives on-chain and is the last line of defense. |
| 49 | +- **The dust transactions were a red herring.** The initial analysis flagged lookalike vanity-address dust as the attack vector (T4.003 address poisoning). The revised analysis confirms they were "likely a side effect." OAK contributors should treat on-chain transaction patterns as supporting evidence, not root cause, when the actual vulnerability is in protocol-level signing architecture. |
| 50 | + |
| 51 | +## Public references |
| 52 | + |
| 53 | +- [@Oblivionsage — Full on-chain proof (GitHub Gist)](https://gist.github.com/Oblivionsage/9859e3281ec41627b8dc5fb8f9c963c3) — primary technical citation, revised root cause: Bifrost `GetSignablePayload()` signature scope truncation. |
| 54 | +- [MetaSleuth — Attacker fund flow](https://metasleuth.io/result/eth/0x82Fc0d5150F3548027e971eC04c065f3c93154Eb) |
| 55 | +- [OAK Incident Report 2026-001](../investigations/2026-05-15-thorchain-router-exploit.md) — full OAK investigation. |
| 56 | +- First disclosure: @zachxbt (2026-05-15). Root cause traced and revised by: @theoblivionsage (2026-05-15). |
| 57 | +- THORChain Router (Etherscan): `0xD37BbE5744D730a1d98d8DC97c42F0Ca46aD7146` |
| 58 | +- Migration TX: `0xb087a91427a10490ad27f17bae7387fa8828a62eaaf5beec65d1602883425c4a` |
| 59 | +- Drain TX: `0xefa9087f93fb0924d2fb49ffbe1b2b93cc7cce3cf46327ab9546845f57d5ef96` |
| 60 | +- `thornode` patch: `af46db22` — expanded `GetSignablePayload()` to cover full `ObservedTx` wrapper |
| 61 | + |
| 62 | +## Discussion |
| 63 | + |
| 64 | +T10.008 captures a signature-engineering principle that applies to every bridge with an observer/relayer network: **the set of bytes signed must equal the set of bytes that determine the observation's semantic effect.** If a field changes what the observation does but sits outside the signature boundary, that field is unsigned attacker-controlled input regardless of how many observers signed the inner payload. |
| 65 | + |
| 66 | +The Nomad case (T10.002, `examples/2022-08-nomad-bridge.md`) and this THORChain case (T10.008) are structurally distinct: T10.002 is a *verification correctness* problem (the `process()` function was broken by the `0x00` sentinel collision — it treated all messages as proven). T10.008 is a *signature scope* problem (the verification logic is correct for the bytes it covers; it just doesn't cover the bytes that determine what the observation does). Both produce unauthorized custody transfer, but detection and mitigation differ: T10.002 lives at audit/upgrade review of verification logic; T10.008 lives at protocol-level payload coverage audit and multi-observer wrapper consistency monitoring. |
| 67 | + |
| 68 | +The initial misidentification of this incident as "address resolution poisoning" is itself a teaching point for OAK: on-chain transaction patterns (dust from lookalike addresses) are visible and tempting to classify, but protocol-level signing architecture is the actual attack surface. Future T10.008 investigations should start with the `GetSignablePayload()` coverage diff, not with on-chain transaction graphs. |
0 commit comments