fix: F-2026-18177 | [Dual Defense] User-Selected Rescue PRC20 Can Reinterpret a Stuck Inbound as Another Vault Asset - #343
Merged
Conversation
…e event Reject a rescue whose event PRC20 disagrees with the original asset's registered PRC20.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
F-2026-18177 — User-Selected Rescue PRC20 Can Reinterpret a Stuck Inbound as Another Vault Asset
Severity: Low (Impact 1 / Likelihood 1) · Repo:
push-chain-node· Module:x/uexecutorThe bug
In
AttachRescueOutboundFromReceipt(x/uexecutor/keeper/create_outbound.go), the rescue outboundtook its asset identity from the caller's event and its amount from the original inbound:
The only validation was that
event.PRC20is some registered PRC20 on that chain — never that it isthis deposit's asset. So a rescue could pair asset B's identity with asset A's raw amount.
Why it matters: raw integers across differing decimals
Amounts are raw base units, so a decimals mismatch amplifies the substitution. A stuck 1 pETH is
1e18raw. Pointed at 6-decimal USDC, that same1e18becomes a claim on1e18 / 1e6= 10^12whole USDC — bounded only by vault liquidity, and paid for by other depositors.
The realistic actor is not a third party: it is the legitimate owner of a genuinely stuck deposit who
names a more valuable token. The eligibility gates (CEA deposit
FAILED; non-CEAINBOUND_REVERTinREVERTED) check that a rescue is warranted, never which asset — they are correct and unchanged.The fix
1. Derive, do not accept. The asset now resolves from
originalUtx.InboundTx.AssetAddrviaGetTokenConfig(ctx, chain, address), and the PRC20 comes from that config'sNativeRepresentation.ContractAddress. This is the same rule the PC20 branch ondevelop/testnet/donutalready implements — its comment states detection is "on the original inbound'sis_pc20, not the rescue event". The reasoning already existed in the file; it simply had not beencarried into the sibling branch. This change brings the two into line. (
audit-fixespredates thePC20 work and has only the non-PC20 branch, so the diff here is a single site.)
2. Defence in depth: reject disagreement, don't override. The event still carries a PRC20. If it
disagrees with the derived one, the rescue is rejected with an actionable error rather than
silently overridden. Overriding would hide a mismatched client; rejecting surfaces it. This is the
assertion that would have caught the finding.
Comparison uses
utils.LenientCanonicalizeEVMAddress(PRC20s are Push-side EVM addresses): it mirrorsuregistry's own PRC20 identity function (
canonicalPRC20, which falls back to lowercase-trim when anaddress doesn't parse), so exactly the pairs the registry considers equal are accepted, and a
legitimate rescue can never be rejected merely because a registry entry doesn't parse. A missing or
empty
NativeRepresentationis rejected explicitly before the comparison, so "missing" can neverread as "match".
3. An original asset with no registered token config is now an error — not a substitution
opportunity. Falls out of (1); the error names the asset and chain.
Declined
Recs 4/5 (source-chain deposit accounting keyed by
universalTxId; Vault/Gateway must match therecorded token+amount) are declined: a substantial cross-repo change, and once the asset is derived
rather than supplied there is nothing left for that accounting to protect against.
Out of scope and unchanged: rescue eligibility gates, the duplicate-rescue guard, the gateway contracts.
Tests
test/integration/uexecutor/rescue_funds_test.go:inbound's asset is rejected, using the 18 → 6 decimal pair where the amplification lives (a
pETH-style 18-decimal original with a
1e18amount, a USDC-style 6-decimal substitute). Asserts onstate — no rescue outbound on the UTX, no new
PendingOutboundsrow — with the state assertionsplaced before
require.Errorso a regression fails on state rather than aborting the subtest.event.PRC20matches still succeeds and carries the original asset,the original PRC20 and the original amount.
setupRescueFundsTestpreviously made the deposit fail by using an unregistered asset, and everyrescue subtest then named an unrelated USDC PRC20 — i.e. the existing suite exercised exactly the
cross-asset shape this fix forbids. It has been corrected, not weakened: the stuck asset is now
registered (18-decimal pETH) and the deposit still fails because its PRC20 has no deployed contract,
which is closer to how a deposit actually fails in production. Subtests built on the USDC-based
bridge/CEA setups name the USDC PRC20 that belongs to their inbound.
Mutation-verified. Reverting the derivation (back to trusting
event.PRC20) makes the cross-assettest fail on the state assertion, with the created outbound showing the substitution directly: