Summary
The Bitcoin inbound observer hard-codes tx.Vin[0].Txid when resolving the sender for inscription-memo deposits, but tryExtractInscription accepts an inscription on any input. When the inscription rides on Vin[k>0], the sender lookup walks back through a completely unrelated previous transaction the attacker hand-picks, so event.FromAddress resolves to a victim address while event.MemoBytes is the attacker's memo. The forged address propagates into MsgVoteInbound.Sender / TxOrigin and from there into cctx.InboundParams.Sender, the BTC revert receiver, and gatewayzevm.MessageContext.{Sender, SenderEVM} exposed to universal apps.
Root cause
// zetaclient/chains/bitcoin/observer/witness.go:111-118
} else if candidate = tryExtractInscription(tx, logger); candidate != nil {
memo = candidate
// override the sender address with the initiator of the inscription's commit tx
if fromAddress, err = bitcoinClient.GetTransactionInitiator(ctx, tx.Vin[0].Txid); err != nil {
return nil, errors.Wrap(err, "unable to get inscription initiator")
}
}
tryExtractInscription (lines 200-229) returns the memo but discards which input carried it. The override then assumes the inscription must be on Vin[0]. GetTransactionInitiator walks one hop back (tx -> Vin[0] -> that prev tx's Vin[0] -> spender pkScript), so the attacker only needs Vin[0] to spend any UTXO whose parent tx had the victim at TxIn[0] — essentially any output of any tx the victim ever signed.
The existing test TestGetBtcEventWithWitness/decode_inscription_ok works around the bug rather than exposing it — witness_test.go:217 rewrites tx.Vin[0].Txid = preHash precisely to feed the override the right txid that the production code would otherwise miss.
Impact
This is a sender-forgery primitive, not a fund-extraction primitive against the protocol. The chain does not lose custody. Concrete impacts:
- Compliance bypass (
event.go:77): ContainRestrictedAddress checks the spoofed FromAddress, so a sanctioned spender can launder through the bridge by pinning Sender to a clean victim.
msg.sender impersonation in ZEVM: MessageContext.Sender / SenderEVM is propagated to universal apps. Any user-deployed app that gates on msg.sender can be tricked into acting "as the victim". Protocol-deployed contracts (Gateway, ZRC20, SystemContract, ZetaConnector ZEVM) do not gate privileged behavior on this field, so they are unaffected.
- Revert-path griefing: For BTC inbounds,
AddRevertOutbound (cctx.go:150-156) defaults revertReceiver = InboundParams.Sender unless the memo's RevertOptions.RevertAddress is set. An attacker who omits the override and designs the deposit to revert ends up gifting their BTC to the victim's address. Self-victimizing, no chain loss.
Cost to the attacker: inscription fees (~$15) plus the BTC deposit, which they only get back via the non-revert path.
Fix
Make tryExtractInscription return the input index it found the memo on, and use tx.Vin[i].Txid in the GetTransactionInitiator call. ~5 LOC.
// tryExtractInscription(tx, logger) -> (memo []byte, vinIndex int, found bool)
} else if candidate, vinIndex, ok := tryExtractInscription(tx, logger); ok {
memo = candidate
if fromAddress, err = bitcoinClient.GetTransactionInitiator(ctx, tx.Vin[vinIndex].Txid); err != nil {
return nil, errors.Wrap(err, "unable to get inscription initiator")
}
}
Replace the workaround at witness_test.go:215-219 with a real Vin[0]-inscription fixture, and add a regression test placing the inscription on Vin[1] with a different Vin[0] funder, asserting the event's FromAddress tracks the inscription's commit tx rather than Vin[0]'s parent.
While in the area, worth checking whether GetTransactionInitiator's "spender of the previous tx's first input" definition is actually the right semantic for an ordinal inscription, or whether it should be the funder of the commit tx itself.
Backport to v36 / v37 / v38 — same code, same bug.
History
Introduced by PR #3964 (commit f26ceaa2c4ae7001136d5985c4bc1b4e9f089232, 2025-06-16). The PR's intent was correct ("use the commit tx initiator address for Bitcoin inscription based inbound") but the implementation conflated "the inscription's commit tx" with "Vin[0]'s parent tx".
Severity
Medium. Permissionless integrity violation with concrete reach (compliance bypass and msg.sender impersonation in universal apps), no observer or signer compromise required, no protocol fund extraction.
References
Summary
The Bitcoin inbound observer hard-codes
tx.Vin[0].Txidwhen resolving the sender for inscription-memo deposits, buttryExtractInscriptionaccepts an inscription on any input. When the inscription rides onVin[k>0], the sender lookup walks back through a completely unrelated previous transaction the attacker hand-picks, soevent.FromAddressresolves to a victim address whileevent.MemoBytesis the attacker's memo. The forged address propagates intoMsgVoteInbound.Sender/TxOriginand from there intocctx.InboundParams.Sender, the BTC revert receiver, andgatewayzevm.MessageContext.{Sender, SenderEVM}exposed to universal apps.Root cause
tryExtractInscription(lines 200-229) returns the memo but discards which input carried it. The override then assumes the inscription must be onVin[0].GetTransactionInitiatorwalks one hop back (tx -> Vin[0] -> that prev tx's Vin[0] -> spender pkScript), so the attacker only needsVin[0]to spend any UTXO whose parent tx had the victim atTxIn[0]— essentially any output of any tx the victim ever signed.The existing test
TestGetBtcEventWithWitness/decode_inscription_okworks around the bug rather than exposing it —witness_test.go:217rewritestx.Vin[0].Txid = preHashprecisely to feed the override the right txid that the production code would otherwise miss.Impact
This is a sender-forgery primitive, not a fund-extraction primitive against the protocol. The chain does not lose custody. Concrete impacts:
event.go:77):ContainRestrictedAddresschecks the spoofedFromAddress, so a sanctioned spender can launder through the bridge by pinning Sender to a clean victim.msg.senderimpersonation in ZEVM:MessageContext.Sender/SenderEVMis propagated to universal apps. Any user-deployed app that gates onmsg.sendercan be tricked into acting "as the victim". Protocol-deployed contracts (Gateway, ZRC20, SystemContract, ZetaConnector ZEVM) do not gate privileged behavior on this field, so they are unaffected.AddRevertOutbound(cctx.go:150-156) defaultsrevertReceiver = InboundParams.Senderunless the memo'sRevertOptions.RevertAddressis set. An attacker who omits the override and designs the deposit to revert ends up gifting their BTC to the victim's address. Self-victimizing, no chain loss.Cost to the attacker: inscription fees (~$15) plus the BTC deposit, which they only get back via the non-revert path.
Fix
Make
tryExtractInscriptionreturn the input index it found the memo on, and usetx.Vin[i].Txidin theGetTransactionInitiatorcall. ~5 LOC.Replace the workaround at
witness_test.go:215-219with a real Vin[0]-inscription fixture, and add a regression test placing the inscription onVin[1]with a differentVin[0]funder, asserting the event'sFromAddresstracks the inscription's commit tx rather thanVin[0]'s parent.While in the area, worth checking whether
GetTransactionInitiator's "spender of the previous tx's first input" definition is actually the right semantic for an ordinal inscription, or whether it should be the funder of the commit tx itself.Backport to v36 / v37 / v38 — same code, same bug.
History
Introduced by PR #3964 (commit
f26ceaa2c4ae7001136d5985c4bc1b4e9f089232, 2025-06-16). The PR's intent was correct ("use the commit tx initiator address for Bitcoin inscription based inbound") but the implementation conflated "the inscription's commit tx" with "Vin[0]'s parent tx".Severity
Medium. Permissionless integrity violation with concrete reach (compliance bypass and
msg.senderimpersonation in universal apps), no observer or signer compromise required, no protocol fund extraction.References
hackeproof/analysis/blockchain/report_ZCNode-268.md