A $47.43M Loophole in THORChain
Authors
Sunghyeon Jo & Yonghwi Jin
Published
June 1, 2026
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):
MsgModifyLimitSwapcould bypass THORChain’s ante decorator when wrapped inauthz.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):DepositHandlerroutes native reference-read deposits toAsgardNamebefore 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 inspectedThis 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
AsgardNamemodule. - 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
DepositHandlerto 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:
- Parses the top-level memo.
- Chooses a
targetModulebased onmemo.GetType(). - Sends funds from the signer to
targetModule. - 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 stringswitch memo.GetType() {case TxBond, TxUnBond, TxLeave, TxOperatorRotate: targetModule = BondNamecase TxReserve, TxTHORName, TxTCYClaim, TxMaint, TxModifyLimitSwap: targetModule = ReserveNamecase TxTCYStake, TxTCYUnstake: targetModule = TCYStakeNamedefault: targetModule = AsgardName // TxReferenceReadMemo falls here}
coinsInMsg := msg.Coinsif !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:
- Register a reference memo embedding
bond:<nodeAddr>via nativeMsgDeposit:memo = "reference:THOR.RUNE:bond:<nodeAddr>".
- Resolve the reference id via
/thorchain/memo/{txhash}(e.g.00001). - Send a native
MsgDepositof 2 RUNE (defaultDEPOSIT_AMT=200000000) withmemo = "r:00001". - Compare:
NodeAccount.total_bondfor the bond target node (node_addressinbond:<nodeAddr>).- Bond module RUNE balance.
- Asgard module RUNE balance.
Observed deltas from the PoC run:
=== E2E PoC Result ===NodeAccount.total_bond delta: 200000000Bond module RUNE delta: 0Asgard module RUNE delta: 200000000Expected (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
| Impact | Description |
|---|---|
| Bond module solvency risk | BondName can become under-collateralised vs NodeAccount.total_bond, breaking BondInvariant. |
| Reserve / THORName drift | Reserve/THORName state (fees, expiries) can diverge from ReserveName balances. |
| Unbond / slash reliability | Unbonds and slashes pay from BondName; if underfunded, they may fail or underpay. |
| Operational recovery cost | Fixing 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:
- Parse the memo.
- If
TxReferenceReadMemo, resolve and reparse. - Switch on the resolved memo type to choose
targetModule. - 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