NewQED x Commonware - read the announcement

A $47.43M Loophole in THORChain

Authors

Sunghyeon Jo & Yonghwi Jin

Published

June 1, 2026

A $47.43M Loophole in THORChain

QED found two distinct bugs in THORChain, both rooted in trust between protocol layers that don’t enforce what the other assumes.

  • MsgExec loophole (mainnet, critical): MsgModifyLimitSwap could bypass THORChain’s ante decorator when wrapped in authz.MsgExec, letting any permissionless EOA credit phantom RUNE to pool balances, manipulate prices by 99.9%, and drain all 11 L1 pools. Net profit $47.43M.
  • Reference-read misroute (v3.15.0-rc1 / develop, not on current mainnet): DepositHandler routes native reference-read deposits to AsgardName before resolving the inner memo, so Bond / Reserve / THORName handlers can mutate state while the underlying funds sit in the wrong module account.

The MsgExec bug was fixed in late January 2026. The reference-read bug is pre-release and the disclosure is in progress.


Bug 1 — An $47.43M MsgExec Loophole

Executive Summary

A critical vulnerability in THORChain allowed arbitrary settlement of pool balances unbacked by actual fund transfers. This happened because MsgModifyLimitSwap could bypass the ante decorator validation when wrapped in authz.MsgExec. This enables an attacker to manipulate pool prices by 99.9% and drain assets from 11 different pools at a net profit of 47.43M USD.

Primer: Ante Handlers and Authz

Cosmos SDK chains process transactions through a pipeline of ante decorators before executing the actual message handlers. These decorators run validation checks, such as signature verification, fee deduction, sequence number checks, and can reject transactions early if something is wrong. Critically, ante decorators also enforce application-specific rules about which message types are allowed. In THORChain’s case, the ante decorator acts as a gatekeeper: it inspects each incoming message type and rejects anything that isn’t on the whitelist. This is the first line of defense against unauthorized operations.

The authz module introduces a layer of indirection. It allows one account to grant another account the ability to execute messages on its behalf, wrapped inside a generic MsgExec container. When the ante decorator sees a MsgExec, it faces a choice: inspect the inner messages recursively, or trust that the authz module will handle validation later. THORChain’s ante decorator chose the latter: it whitelisted MsgExec without unwrapping it. This meant that any message type, including those that would normally be rejected at the ante stage, could bypass validation simply by being wrapped in MsgExec. The authz module itself doesn’t re-run ante checks on inner messages; it assumes they already passed.

This tension between MsgExec and ante handlers is a known vulnerability class in Cosmos SDK chains. In 2022, Jump Crypto disclosed an identical pattern in Ethermint: their RejectMessagesDecorator blocked direct MsgEthereumTx submissions but failed to inspect messages nested inside MsgExec. The bug we will be talking about today is the same class, but in a different code base.

Technical Analysis

An attacker wraps an internal message in MsgExec to bypass the ante whitelist, credits phantom funds to any pool, and swaps at manipulated prices to drain vaults.

MsgModifyLimitSwap allows users to modify or cancel existing limit swap orders. It includes a DepositAmount field that donates funds to the pool when modifying an order. This message is intentionally excluded from the ante whitelist because it is designed as an internal message, only reachable through two paths: MsgDeposit (which transfers funds to Reserve before invoking the handler) or Bifrost observation (for external chain transactions). Both paths ensure funds are transferred before the handler runs.

THORChain’s ante decorator whitelists MsgExec without recursion (x/thorchain/ante.go:200-203):

case *authz.MsgGrant, *authz.MsgRevoke, *authz.MsgExec:
return ctx, nil // inner messages never inspected

This creates a third path. When MsgModifyLimitSwap is wrapped in MsgExec, it bypasses the ante decorator entirely and executes directly. The handler’s donateToPool() then credits arbitrary amounts to pool balances (handler_modify_limit_swap.go:175-228):

pool.BalanceAsset = pool.BalanceAsset.Add(amount)

The handler trusts that funds were already transferred by the entry point. But MsgExec skips those entry points, so no funds exist.

Attack Flow

The attacker creates a limit swap order via MsgDeposit, then grants themselves authz authorization. They wrap MsgModifyLimitSwap inside MsgExec to inject phantom balance into the pool by an arbitrary amount. The ante decorator sees MsgExec and returns early. The inner message executes without validation. Finally, they swap at the manipulated price, extracting real assets backed by phantom liquidity. Repeat for each pool.

Impact Assessment

Critical. Any permissionless EOA can execute this attack. All 11 L1 pools drained in a single automated run: $47.88M extracted at an attack cost of ~$47k (81,080 RUNE). Net profit: ~$47.43M. The vulnerability enables arbitrary phantom balance injection, 99.9% price manipulation, complete vault drainage, and protocol insolvency in one transaction sequence.

The Fix

THORChain fixed the vulnerability in donateToPool() by requiring that native asset donations transfer actual funds from Reserve to Asgard before updating pool balances:

if asset.IsNative() {
coin := common.NewCoin(asset, amount)
if err := h.mgr.Keeper().SendFromModuleToModule(ctx, ReserveName, AsgardName, common.NewCoins(coin)); err != nil {
return fmt.Errorf("fail to transfer funds from reserve to asgard: %w", err)
}
}

This ensures pool balances are always backed by actual funds in the Asgard module account. The ante bypass remains possible, but the handler no longer trusts that funds exist without verification.

Timeline

  • Discovery Date: 2026-01-09
  • Vendor Notification: 2026-01-09
  • Fix Release: 2026-01-29

Bug 2 — Reference-Read Deposits Misroute to Asgard

Executive Summary

Native THORChain deposits that use reference-read memos (TxReferenceReadMemo, e.g. r:<ref>) route RUNE to the wrong module account before the underlying memo is resolved. Today:

  • Funds for native reference-read deposits are always credited to the AsgardName module.
  • After resolution, handlers such as Bond, Reserve, and THORName run under the assumption that their own modules (BondName, ReserveName, etc.) were funded.

This breaks the intended invariant that module balances match protocol state derived from memos and handlers. When deposits are routed based on the unresolved memo (r:<ref>) and only later resolved to the embedded memo (e.g. bond:…, reserve:…, name:…), any handler that assumes “DepositHandler already credited my module” can end up mutating state without matching module funding.

The attached PoC shows a native MsgDeposit with memo = "r:<ref>" debiting the user and crediting Asgard, while the Bond module balance remains unchanged and NodeAccount.total_bond for the bond target node increases by the deposited amount (unbacked bond state). This is a concrete instance of a broader bug class affecting any memo type that:

  • is reachable via TxReferenceReadMemo, and
  • relies on DepositHandler to fund its module (e.g. Bond, Reserve, THORName) without moving coins itself.

We focus the on-chain PoC on the bond path for clarity, but the same routing pattern and assumptions are present for Reserve and THORName handlers. If those flows are driven via reference-read deposits, they can also create unbacked Reserve/THORName state even though we do not include separate Reserve/THORName PoCs in this report.

Impact: Bond/reserve/THORName solvency risk; unbond/slash reliability risk; potential need for governance-led state repair if exploited at scale. Known Affected: Local mocknet built from this repo at commit 3749ae875, and code paths including commit 8ba713503 (“[feature] Support memoless native asset deposits with amount-encoded references”) such as v3.15.0-rc1 and current develop. Mainnet Status: Not affected as of v3.14.1 (current mainnet release), which does not include the reference memo feature or the modified DepositHandler logic described below. Fixed upstream on 2026-02-03.

Vulnerability Description

The THORChain DepositHandler routes funds to different module accounts based on the parsed memo type. For bond operations, funds are supposed to go to the BondName module so that NodeAccount.total_bond remains fully backed by slashable collateral in Bond.

However, the handler:

  1. Parses the top-level memo.
  2. Chooses a targetModule based on memo.GetType().
  3. Sends funds from the signer to targetModule.
  4. Only then, if the memo is a reference-read (TxReferenceReadMemo), resolves the reference and reparses the memo.

Because TxReferenceReadMemo is not handled explicitly in the routing switch, it falls through to the default AsgardName module. The resolved memo is used only to choose which internal handler runs, not to correct where funds were sent.

In particular, reference-read deposits whose resolved memo is a Bond/Reserve/THORName memo will execute those handlers as if their modules were funded. In reality, the funds sit in Asgard, breaking the module-balance vs protocol-state correspondence that invariants and economic safety assumptions rely on.

Technical Analysis

1. Routing Before Reference Resolution

In DepositHandler.handle, routing is done on the unresolved memo:

// x/thorchain/handler_deposit.go (excerpt)
memo, err := ParseMemoWithTHORNames(ctx, h.mgr.Keeper(), msg.Memo)
if err != nil {
return nil, ErrInternal(err, "invalid memo")
}
var targetModule string
switch memo.GetType() {
case TxBond, TxUnBond, TxLeave, TxOperatorRotate:
targetModule = BondName
case TxReserve, TxTHORName, TxTCYClaim, TxMaint, TxModifyLimitSwap:
targetModule = ReserveName
case TxTCYStake, TxTCYUnstake:
targetModule = TCYStakeName
default:
targetModule = AsgardName // TxReferenceReadMemo falls here
}
coinsInMsg := msg.Coins
if !coinsInMsg.IsEmpty() && !coinsInMsg[0].Asset.IsTradeAsset() && !coinsInMsg[0].Asset.IsSecuredAsset() {
if err := h.mgr.Keeper().SendFromAccountToModule(ctx, msg.GetSigners()[0], targetModule, msg.Coins); err != nil {
return nil, err
}
}

All native reference-read deposits (memo.GetType() == TxReferenceReadMemo) go through the default case and are therefore funded into Asgard.

2. Late Resolution Without Re-Routing

Reference resolution happens only after funds are moved:

// x/thorchain/handler_deposit.go (excerpt)
if len(msg.Coins) > 0 && memo.GetType() == TxReferenceReadMemo {
asset := msg.Coins[0].Asset
tempTx := common.NewTx(txID, from, to, msg.Coins, common.Gas{}, msg.Memo)
resolvedMemo := fetchMemoFromReference(ctx, h.mgr, asset, tempTx, ctx.BlockHeight())
msg.Memo = resolvedMemo
memo, err = ParseMemoWithTHORNames(ctx, h.mgr.Keeper(), msg.Memo)
if err != nil {
return nil, ErrInternal(err, "invalid resolved memo")
}
// targetModule is NOT updated; funds remain in Asgard.
}

After this, the code constructs an internal ObservedTx and routes it through processOneTxIn, which dispatches on the resolved memo (Bond/Reserve/THORName/etc.) but does not move coins between modules.

3. Handlers Assume Deposit Routing Was Correct

Handlers for Bond, Reserve, and THORName assume the deposit has already credited their module.

Bond increments NodeAccount.Bond, and unbonds pay from BondName:

// x/thorchain/handler_bond.go (excerpt)
nodeAccount.Bond = nodeAccount.Bond.Add(msg.Bond)
// No verification that BondName was funded by this deposit
// x/thorchain/helpers.go (excerpt)
unbondCoin := common.NewCoin(common.RuneAsset(), amt)
err = mgr.Keeper().SendFromModuleToAccount(ctx, BondName, provider.BondAddress, common.NewCoins(unbondCoin))

ReserveContributor and ManageTHORName similarly treat their messages as accounting against Reserve, assuming the deposit handler already moved funds into ReserveName.

If future changes or specific memo paths cause state changes (bond/reserve/THORName) to occur for reference-read deposits while routing still sends funds to Asgard, the result is unbacked state: protocol state claims funds in Bond/Reserve that the corresponding module accounts do not actually hold.

Proof of Concept Results

Using poc.sh on a Docker mocknet:

  1. Register a reference memo embedding bond:<nodeAddr> via native MsgDeposit:
    • memo = "reference:THOR.RUNE:bond:<nodeAddr>".
  2. Resolve the reference id via /thorchain/memo/{txhash} (e.g. 00001).
  3. Send a native MsgDeposit of 2 RUNE (default DEPOSIT_AMT=200000000) with memo = "r:00001".
  4. Compare:
    • NodeAccount.total_bond for the bond target node (node_address in bond:<nodeAddr>).
    • Bond module RUNE balance.
    • Asgard module RUNE balance.

Observed deltas from the PoC run:

=== E2E PoC Result ===
NodeAccount.total_bond delta: 200000000
Bond module RUNE delta: 0
Asgard module RUNE delta: 200000000
Expected (unbacked misroute): Bond +200000000, Bond module +0, Asgard +200000000
PoC SUCCESS: bond state increased while funds were credited to Asgard (unbacked bond).

This proves that reference-read deposits can be registered and used for memos that logically belong to Bond, but the actual funds are credited to Asgard, not Bond, while NodeAccount.total_bond increases; i.e. bond state is advanced without Bond module custody.

By inspection of the Reserve and THORName handlers, the same structural pattern holds there: they derive state changes (reserve contributions, THORName fees/expiries) under the assumption that DepositHandler has already credited ReserveName. When those handlers are reached via reference-read deposits, their state can be updated while the underlying funds remain in Asgard, leading to unbacked Reserve/THORName state.

Impact Assessment

ImpactDescription
Bond module solvency riskBondName can become under-collateralised vs NodeAccount.total_bond, breaking BondInvariant.
Reserve / THORName driftReserve/THORName state (fees, expiries) can diverge from ReserveName balances.
Unbond / slash reliabilityUnbonds and slashes pay from BondName; if underfunded, they may fail or underpay.
Operational recovery costFixing large-scale drift likely requires governance coordination and explicit state repair.

The PoC demonstrates the mis-routing behavior with real state changes (user balance and Asgard module). Given that Bond and Reserve invariants are central to THORChain’s economic security, any bug that can de-synchronise module balances from protocol state at scale is best treated as Critical.

Remediation Recommendations

1. Resolve References Before Routing (Recommended). Change DepositHandler.handle so that reference-read memos are resolved before choosing targetModule and moving funds:

  1. Parse the memo.
  2. If TxReferenceReadMemo, resolve and reparse.
  3. Switch on the resolved memo type to choose targetModule.
  4. Only then call SendFromAccountToModule.

This makes reference-read deposits behave exactly like specifying the underlying memo directly, keeping module balances in sync with handler expectations.

2. Restrict Referenceable Memo Types (Defence in Depth). In handler_reference_memo.go, block especially sensitive memo types from being registered as references, e.g. Bond/Unbond memos, Reserve contribution memos, and outbound/internal memos (OUT, MIGRATE, CONSOLIDATE, etc.). This reduces blast radius if any future path accidentally mis-routes or mishandles these memos.

3. Immediate Operational Mitigation. Until a code fix is deployed, operators can mitigate risk by disabling memoless / reference functionality:

Set HaltMemoless = 1 in Mimir to disable reference transactions.

Timeline

  • Discovery Date: 2026-01-09
  • Vendor Notification: 2026-01-09
  • Fix Release: 2026-02-03

© 2026 QED Audit Inc.