OAK — OnChain Attack Knowledge

Investigation

OAK Incident Report — THORChain Router Exploit 2026-05-15

Report ID
OAK-IR-2026-001
Status
COMPLETE
Classification
T10.008 — Bridge Observer Signature Scope Truncation (Bifrost GetSignablePayload() signed only inner Tx; ObservedTx wrapper direction flag was unsigned; proposer flipped inbound→outbound to forge migration observation)
Date
2026-05-15 08:12 UTC
Loss
~$7.4M (ERC20 + ETH) on Ethereum; ~$3M on BSC/Base/BTC combined
Protocol
THORChain
Chains affected
Ethereum, BSC, Base, Bitcoin
Source
@zachxbt, @theoblivionsage, @peckshield
Full proof
<https://gist.github.com/Oblivionsage/9859e3281ec41627b8dc5fb8f9c963c3>
Definition
The attacker exploits the gap between what a bridge observer
Signal
GetSignablePayload() returns only the inner Tx bytes; the
This attack
Bifrost's GetSignablePayload() signed only tx.Tx.CanonicalBytes().
Signal
A committed observation where the wrapper's direction flag says
This attack
The proposer committed an observation with Direction: outbound
Signal
The committed observation's wrapper fields (direction, destination)
This attack
Other Bifrost observers witnessed an inbound deposit to the
Signal
During vault migration, the newVault address is not
This attack
0x82a5...bDA was not derivable from THORChain's TSS pubkey.
Signal
After an observer protocol upgrade, a newly-added wrapper field
This attack
Not applicable to this incident (the flaw was pre-existing,

1. Executive Summary

On 2026-05-15 at 08:12 UTC, THORChain's Router contract (0xD37BbE5744D730a1d98d8DC97c42F0Ca46aD7146) was drained of ~$7.4M in ERC20 tokens on Ethereum, plus ~$3M across BSC, Base, and Bitcoin. The attack exploited a signature scope truncation vulnerability in Bifrost, THORChain's cross-chain observer protocol: GetSignablePayload() signed only the inner Tx data, while the ObservedTx wrapper's direction flag (inbound/outbound) was outside the signed boundary. A malicious proposer (node operator) took a legitimate deposit observation, flipped the unsigned direction flag from inbound to outbound, and L1 consensus accepted the forged observation as a valid outbound migration — executing transferAllowance to the attacker's address (0x82a5...bDA) at block 25,080,575.

This is a novel attack vector not previously catalogued in any security taxonomy. It targets the signature scope boundary in bridge observer protocols — the gap between what observers sign and what consensus executes. The EVM router code itself is correct — it properly checks _vaultAllowance[msg.sender][asset]. The vulnerability is upstream: Bifrost's GetSignablePayload() did not include the wrapper fields that determine the observation's semantic effect, so a malicious proposer could forge the observation without invalidating any observer signatures.

The attacker operated stealthily for ~48 hours (May 13-14) as a legitimate vault, processing 166 ETH outbound transactions, before draining all ERC20 allowances on May 15. Global trading was paused by node operators via the Mimir mechanism at approximately 09:15 UTC. The dust transactions from lookalike addresses (all ending in bDA) that initially appeared to be the attack vector were "likely a side effect, not the main attack vector" per @theoblivionsage's revised analysis.


2. Key Addresses

Role Address Chain
Attacker (fake vault) 0x82a5CF67F3e6970C0529122178075C0a94878bDA Ethereum
Theft address (funds receiver) 0x82Fc0d5150F3548027e971eC04c065f3c93154Eb Ethereum
THORChain Router v4.1 0xD37BbE5744D730a1d98d8DC97c42F0Ca46aD7146 Ethereum
Old vault 1 (signed migrate) 0x5e7cEee2066b1811B8F845a10C412f5557D067b4 Ethereum
Old vault 2 0x9f17De93788Cb3e4C83472608d953122Aa5A09bb Ethereum
BTC theft address bc1ql4u94klk265lnfur2ujk9p6uh52f2a8jhf6f37 Bitcoin
Laundering hop 1 0xd477b69551f49c0519f9b18c55030676138890bd Ethereum
Laundering hop 2 0xd4767e6291273dfbd16095abef12aae6628890bd Ethereum
Laundering hop 3 0xd4772db9c73ceff784ab93f48e92ca31f2ae90bd Ethereum

3. Attack Timeline (UTC)

2026-05-12 21:38  Attacker funds fresh address 0x82a5...bDA with 0.083 ETH
                  from 0xcae0ba75...

2026-05-12~14     Address poisoning phase:
                  Dust transactions sent from lookalike addresses to theft
                  address. All end in "bDA", mimicking vault address pattern:

                    0x82a9f9b7...8bDA → 1e-7 ETH
                    0x82a5260b...8bDA → 1e-9 ETH
                    0x82a526a3...8bDA → 1e-9 ETH
                    0x82a5c45f...8bDA → 1e-7 ETH

2026-05-13 ~05:00 BLOCK 25,080,575 — THE CRITICAL MOMENT
                  TX: 0xb087a91427a10490ad27f17bae7387fa8828a62eaaf5beec65d1602883425c4a
                  Old vault 0x5e7ceee2 calls transferAllowance(router, newVault, USDC, ~798K)
                  newVault = 0x82a5CF67F3e6970C0529122178075C0a94878bDA ← ATTACKER
                  memo: "MIGRATE:26146440"
                  
                  On-chain effect (verified via binary search):
                    Block -1: vaultAllowance[attacker][USDC] = 0
                    Block  0: vaultAllowance[attacker][USDC] = 798,691,238,701

2026-05-13~14     STEALTH PERIOD (~48 hours):
                  Attacker operates as legitimate vault.
                  Processes 166 ETH outbound transactions via transferOut().
                  Nonce goes from 0 → 166.
                  
                  CRITICAL: ETH outbounds use transferOut(to, amount) which
                  forwards msg.value directly — does NOT check _vaultAllowance.
                  This let the attacker process ETH without triggering alerts.
                  
                  Meanwhile, ERC20 allowances sit untouched — the attacker
                  waits, building confidence.

2026-05-15 08:12  DRAIN EXECUTION:
                  TX: 0xefa9087f93fb0924d2fb49ffbe1b2b93cc7cce3cf46327ab9546845f57d5ef96
                  
                  All ERC20s drained from Router to theft address
                  0x82Fc0d5150F3548027e971eC04c065f3c93154Eb:
                  
                  USDT:    1,756,756  (~$1.76M)
                  USDC:    1,261,986  (~$1.26M)
                  WBTC:        5.206  (~$540K)
                  LUSD:       64,138  (~$64K)
                  GUSD:       61,074  (~$61K)
                  USDP:       38,762  (~$39K)
                  AAVE:         78.1  (~$15K)
                  LINK:        1,044  (~$16K)
                  DAI:         4,567  (~$4.5K)
                  THOR:    3,349,323  (THOR token)
                  XRUNE:  73,768,463  (XRUNE token)
                  FOX:       481,996  (FOX token)
                  
                  ETH Chain total: ~$3.7M

2026-05-15 08:13~09:18  LAUNDERING:
                  Tokens swapped to ETH via:
                    - 0x7f54f05635d15cde17a49502fedb9d1803a3be8a (~1,515 ETH)
                    - 1inch router (28.4 ETH)
                  
                  ETH split into 1,866 ETH chunks → 3 intermediate addresses:
                    0xd477b695...90bd (×3), 0xd4767e62...90bd (×3),
                    0xd4772db9...90bd (×3)
                  
                  ~17.9 ETH remaining in theft address as of analysis time.

2026-05-15 ~09:15  Mimir global pause activated by node operators.
                  All swap operations halted.

4. Root Cause Analysis

4.1 The vulnerable code path

The vulnerability was in Bifrost, THORChain's cross-chain observer protocol. Bifrost observers sign observations of external chain events (deposits, swaps, migrations) so that THORChain L1 consensus can authorize on-chain execution on EVM chains. The signing function GetSignablePayload() returned only the inner transaction (Tx) bytes:

// Before patch (af46db22) — signs only inner Tx
func GetSignablePayload(tx ObservedTx) []byte {
    return tx.Tx.CanonicalBytes()  // ONLY inner transaction
}

The full ObservedTx struct contains wrapper fields that determine the observation's semantic effect — crucially, the Direction flag:

type ObservedTx struct {
    Tx        Tx       // Inner transaction (SIGNED)
    Direction string   // "inbound" or "outbound" (UNSIGNED — before patch)
    Chain     string   // Destination chain (UNSIGNED)
    Memo      string   // Migration memo (UNSIGNED)
    // ... other unsigned wrapper fields
}

The Direction flag distinguishes inbound observations ("user deposited funds") from outbound observations ("send funds from vault to user" or "migrate allowances to new vault"). Because GetSignablePayload() did not include the wrapper, a malicious proposer could flip Direction after observers had signed the inner Tx, and the existing signatures would remain valid.

4.2 The attack vector

The attacker exploited the signature scope truncation in Bifrost:

Bifrost Observer Network
       │
       │  Observer witnesses: Tx{deposit, sender: user, amount: 798K USDC}
       │  Observer signs: GetSignablePayload(Tx) → signature over inner Tx ONLY
       │
       ▼
  Malicious Proposer
       │
       │  Takes the signed inner Tx
       │  Flips ObservedTx.Direction: "inbound" → "outbound"
       │  Sets ObservedTx.Destination: 0x82a5...bDA (attacker)
       │  Commits forged observation to L1 consensus
       │
       │  ⚠ Signatures are valid — they only cover inner Tx, not the wrapper
       ▼
  THORChain L1 Consensus
       │
       │  Validates signatures over inner Tx ✓
       │  Does NOT validate wrapper metadata ✗
       │  Accepts forged observation as valid outbound migration
       │
       ▼
  EVM Router Contract
       │
       │  transferAllowance(router, 0x82a5...bDA, USDC, 798M)
       │  _vaultAllowance[attacker][USDC] += 798,691,238,701
       ▼
  ATTACKER NOW HAS CUSTODY OF ALLOWANCES

The attacker did NOT need to:

  • Compromise any TSS key
  • Break any cryptographic signature
  • Poison the address resolution mechanism
  • Trick L1 consensus through social engineering

The attacker only needed to be a Bifrost proposer (node operator) and flip an unsigned field in the ObservedTx wrapper. The dust transactions from lookalike addresses (all ending in bDA) were "likely a side effect, not the main attack vector" per @theoblivionsage's revised analysis.

4.3 The fix

The thornode patch af46db22 expanded GetSignablePayload() to cover the full ObservedTx wrapper:

// After patch (af46db22) — signs full ObservedTx
func GetSignablePayload(tx ObservedTx) []byte {
    return tx.CanonicalBytes()  // FULL ObservedTx including Direction, Chain, Memo
}

After the patch, flipping any wrapper field invalidates all observer signatures.

4.4 The stealth period

The attacker waited 48 hours between receiving allowances (May 13) and draining (May 15). During this period:

  • The attacker processed legitimate ETH outbound transactions (nonce 0→166)
  • ETH transferOut uses msg.value, bypassing _vaultAllowance checks
  • This made the attacker's address appear as a functioning vault
  • ERC20 balances sat untouched until the drain moment

4.5 Why detection was delayed

  1. The transferAllowance event looked like a normal vault churn
  2. The observation carried valid observer signatures — L1 consensus had no reason to reject it
  3. No wrapper-vs-inner inconsistency check existed at the Bifrost level
  4. No multi-observer wrapper comparison was performed — the proposer's forged wrapper was committed without cross-checking other observers' witnessed wrapper metadata
  5. The router contract had no on-chain TSS pubkey → address derivation check

5. OAK Technique Classification

5.1 Primary: T10.008 — Bridge Observer Signature Scope Truncation

This attack defines a new OAK technique. The core primitive is exploiting unsigned wrapper metadata in bridge observer protocols — the observer's GetSignablePayload() covers only the inner transaction, while wrapper fields that determine the observation's semantic effect (direction, destination, chain, memo, migration slot) sit outside the signed boundary. A malicious proposer flips these unsigned fields to forge observations that carry valid signatures but represent semantically different operations.

signs and what the consensus layer executes. The GetSignablePayload() (or equivalent signing function) returns bytes covering only a subset of the observation struct. Semantically-meaningful fields in the wrapper influence the execution path but were never included in the signature's coverage. The attacker modifies these unsigned fields, and the existing observer signatures remain valid because they only cover the signed subset.

Excludes:

  • T10.001 (Validator-signer Key Compromise): No TSS key was compromised. The attacker uses validly-signed observations and modifies only unsigned wrapper metadata.
  • T10.002 (Message Verification Bypass): The verification logic is correct for what it covers. In T10.002 (Nomad) the process() function was broken — it treated all messages as proven. In T10.008 the signatures verify correctly; the flaw is that they don't cover every field that determines the observation's effect.
  • T4.003 (Address Poisoning): The initial analysis flagged dust transactions from lookalike addresses as causal. The revised analysis confirms they were "likely a side effect, not the main attack vector." The vanity address was camouflage; the root cause was the unsigned direction flag.

5.2 Supporting techniques detected

Technique Relevance Detection Signal
T10.007 Bridge validator economic incentive gap RUNE bond vs custodied TVL before attack
T8.005 Operational security procedural failure Observation signing scope not covering full struct

6. Detection Paths (T10.008)

PATH A — Payload coverage audit

ObservedTx wrapper fields that influence execution (direction, destination, chain, memo) are outside the signature boundary.

The Direction, Chain, and Memo fields in ObservedTx were unsigned — any proposer could modify them without invalidating observer signatures.

PATH B — Wrapper-vs-inner inconsistency

"outbound" but the inner signed transaction describes a deposit (inbound). The observation is semantically impossible.

where the inner Tx was a deposit from a user — the inner data described an inbound event, the wrapper claimed outbound. The mismatch was detectable at commit time.

PATH C — Proposer metadata divergence

differ from what individual observers witnessed and signed for the same event.

legitimate vault. The malicious proposer committed Direction: outbound with Destination: 0x82a5...bDA (attacker address). Cross-observer wrapper comparison would have caught the divergence.

PATH D — During-churn destination cryptographic validation

cryptographically derivable from the bridge's TSS pubkey.

An on-chain check — require(deriveAddress(tssPubkey) == newVault) — would have blocked the migration regardless of the forged observation.

PATH E — Post-upgrade signature scope regression

is not included in GetSignablePayload() coverage.

not introduced by an upgrade). But the THORChain patch af46db22 that fixed the issue is exactly the kind of change PATH E monitors for regression.


7. Laundering Analysis

The attacker used a sophisticated laundering pattern:

  1. Token → ETH swap: USDC, USDT, WBTC, etc. swapped via multiple venues (1inch, direct pool interactions)
  2. Chunking: ETH split into exactly 1,866 ETH chunks
  3. Intermediate hops: 3 intermediate addresses, each receiving 3×1,866 ETH
  4. Pattern: Addresses end in 90bd — same suffix pattern as the bDA vanity addresses, suggesting the attacker generates vanity addresses in bulk

OAK laundering classification:

  • T7.002 (Mixer/tumbler use): Not yet observed — funds still in intermediate addresses
  • T7.003 (Cross-chain bridge laundering): Likely next — BTC funds on bc1ql4u9... may be bridged
  • T8.003 (Exchange deposit laundering): Monitor CEX deposit addresses

8. OAK Detection Gap Analysis

8.1 Gaps identified

Gap Severity Description
T10.008 missing CRITICAL No technique existed for observer signature scope truncation — the unsigned wrapper metadata attack surface in bridge observer protocols
GetSignablePayload() coverage audit HIGH No spec covers protocol-level payload coverage analysis as a detection surface
Wrapper-vs-inner inconsistency HIGH No spec monitors the gap between committed observation wrapper metadata and inner signed transaction data
Proposer metadata divergence MEDIUM Cross-observer wrapper comparison not modelled as a detection primitive

8.2 New technique proposal

T10.008 — Bridge Observer Signature Scope Truncation

Scope: Detect attacks where a bridge observer/relayer's signing function (GetSignablePayload() or equivalent) covers only the inner transaction data while semantically-meaningful wrapper fields — direction flags, destination addresses, chain identifiers, migration slots — sit outside the signed boundary. A malicious proposer modifies these unsigned wrapper fields to forge observations that carry valid signatures but represent semantically different operations.

This spec should be created with this incident as its primary positive test fixture.

8.3 Spec coverage improvements

The following existing specs need test fixture updates:

Spec Update
T10.001 Add as NEGATIVE fixture (was NOT a TSS key compromise)
T10.002 Add as NEGATIVE fixture (signatures verified correctly; flaw was what wasn't signed, not broken verification)
T10.007 Add as supporting context (economic incentive analysis for proposer behavior)
T4.003 The dust transactions from lookalike addresses were a side effect, not the attack vector — useful as a reminder that on-chain patterns can mislead root-cause analysis

9. Mitigations (Protocol Level)

For THORChain and similar bridge/vault architectures with observer networks:

  1. Full wrapper signing (OAK-M02). GetSignablePayload() MUST cover the full ObservedTx struct — including all wrapper fields that influence execution (direction, destination, chain, memo, migration slot). The thornode patch af46db22 is the reference fix: expanded from tx.Tx.CanonicalBytes() to tx.CanonicalBytes() (full ObservedTx).

  2. Cryptographic vault identity binding (OAK-M02). transferAllowance should accept newVault only if the address is derivable from the TSS public key — defense-in-depth:

    require(deriveAddress(tssPubkey) == newVault, "not TSS-controlled vault");
    
  3. Migration timelock (OAK-M08). transferAllowance should include a mandatory delay (e.g., 24h) before newVault can call transferOut, giving node operators time to detect and revert forged migration observations.

  4. Multi-observer wrapper consistency (OAK-M09). Require that a supermajority of observers agree on the full ObservedTx wrapper (not just the inner Tx) before the observation is committed. A proposer's wrapper diverging from the observer supermajority is rejected at consensus.

  5. Post-upgrade signature scope regression (OAK-M11). After any observer protocol upgrade, verify that no newly-added wrapper field was left outside GetSignablePayload() coverage.

OAK Mitigations mapping

Mitigation OAK ID
Full observer payload signing OAK-M02 (Access Control Verification)
Cryptographic vault identity binding OAK-M02 (Access Control Verification)
Migration timelock OAK-M08 (Timelock Enforcement)
Multi-observer wrapper consistency OAK-M09 (Multi-party Validation)
Post-upgrade signature scope regression OAK-M11 (Continuous Verification)
Stealth period detection OAK-M20 (Behavioral Anomaly Detection)

10. References


Investigation conducted 2026-05-15, within ~3 hours of first disclosure.
Classification and T10.008 spec proposed based on on-chain evidence from @theoblivionsage.