Investigation
OAK Incident Report — THORChain Router Exploit 2026-05-15
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
transferOutusesmsg.value, bypassing_vaultAllowancechecks - 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
- The
transferAllowanceevent looked like a normal vault churn - The observation carried valid observer signatures — L1 consensus had no reason to reject it
- No wrapper-vs-inner inconsistency check existed at the Bifrost level
- No multi-observer wrapper comparison was performed — the proposer's forged wrapper was committed without cross-checking other observers' witnessed wrapper metadata
- 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:
- Token → ETH swap: USDC, USDT, WBTC, etc. swapped via multiple venues (1inch, direct pool interactions)
- Chunking: ETH split into exactly 1,866 ETH chunks
- Intermediate hops: 3 intermediate addresses, each receiving 3×1,866 ETH
- Pattern: Addresses end in
90bd— same suffix pattern as thebDAvanity 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:
Full wrapper signing (OAK-M02).
GetSignablePayload()MUST cover the fullObservedTxstruct — including all wrapper fields that influence execution (direction, destination, chain, memo, migration slot). Thethornodepatchaf46db22is the reference fix: expanded fromtx.Tx.CanonicalBytes()totx.CanonicalBytes()(full ObservedTx).Cryptographic vault identity binding (OAK-M02).
transferAllowanceshould acceptnewVaultonly if the address is derivable from the TSS public key — defense-in-depth:require(deriveAddress(tssPubkey) == newVault, "not TSS-controlled vault");Migration timelock (OAK-M08).
transferAllowanceshould include a mandatory delay (e.g., 24h) before newVault can calltransferOut, giving node operators time to detect and revert forged migration observations.Multi-observer wrapper consistency (OAK-M09). Require that a supermajority of observers agree on the full
ObservedTxwrapper (not just the innerTx) before the observation is committed. A proposer's wrapper diverging from the observer supermajority is rejected at consensus.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
- GitHub Gist (full on-chain proof): https://gist.github.com/Oblivionsage/9859e3281ec41627b8dc5fb8f9c963c3
- MetaSleuth fund flow: https://metasleuth.io/result/eth/0x82Fc0d5150F3548027e971eC04c065f3c93154Eb
- First disclosure: @zachxbt (2026-05-15)
- Root cause traced by: @theoblivionsage (2026-05-15)
- THORChain Router (Etherscan): 0xD37BbE5744D730a1d98d8DC97c42F0Ca46aD7146
- Migration TX: 0xb087a91427a10490ad27f17bae7387fa8828a62eaaf5beec65d1602883425c4a
- Drain TX: 0xefa9087f93fb0924d2fb49ffbe1b2b93cc7cce3cf46327ab9546845f57d5ef96
Investigation conducted 2026-05-15, within ~3 hours of first disclosure.
Classification and T10.008 spec proposed based on on-chain evidence from @theoblivionsage.