OAK — OnChain Attack Knowledge

Worked example · 2026-05

THORChain Router — Ethereum / BSC / Base / Bitcoin — 2026-05-15

Loss
~$7.4M (ERC20 + ETH) on Ethereum; ~$3M on BSC/Base/BTC combined.
OAK Techniques observed
OAK-T10.008 (Bridge Observer Signature Scope Truncation) — primary.
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.
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.

Summary

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.

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).

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).

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.

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.

Timeline (UTC)

When Event OAK ref
Pre-exploit Bifrost GetSignablePayload() signs only inner Tx, omitting ObservedTx wrapper direction flag T10.008 surface existed
2026-05-12 21:38 Attacker funds address 0x82a5...bDA with 0.083 ETH (preparation)
2026-05-12~14 Dust transactions from lookalike bDA-suffix addresses — likely side effect/camouflage, not the attack vector (incidental)
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
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
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
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)
2026-05-15 ~09:15 Mimir global pause activated by node operators (response)
Post-incident thornode patch af46db22: GetSignablePayload() expanded to sign full ObservedTx wrapper including direction flag (fix)

What defenders observed

  • 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.
  • 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).
  • 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.
  • 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.
  • 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.

What this example tells contributors writing future Technique pages

  • 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.
  • 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.
  • 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.
  • 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.
  • 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.

Public references

  • @Oblivionsage — Full on-chain proof (GitHub Gist) — primary technical citation, revised root cause: Bifrost GetSignablePayload() signature scope truncation.
  • MetaSleuth — Attacker fund flow
  • OAK Incident Report 2026-001 — full OAK investigation.
  • First disclosure: @zachxbt (2026-05-15). Root cause traced and revised by: @theoblivionsage (2026-05-15).
  • THORChain Router (Etherscan): 0xD37BbE5744D730a1d98d8DC97c42F0Ca46aD7146
  • Migration TX: 0xb087a91427a10490ad27f17bae7387fa8828a62eaaf5beec65d1602883425c4a
  • Drain TX: 0xefa9087f93fb0924d2fb49ffbe1b2b93cc7cce3cf46327ab9546845f57d5ef96
  • thornode patch: af46db22 — expanded GetSignablePayload() to cover full ObservedTx wrapper

Discussion

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.

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.

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.

Techniques demonstrated (1)