Back to home
Fuzzing 

Saving $69M at Risk: Unlimited Unbacked Cross Chain Asset Minting in Top Cross-Chain Ecosystem

by 0xScourgedev & Gianluca

CEO & Lead Fuzzing Specialist · Tooling Specialist at Perimeter

Two bugs, each harmless alone. Chained through Polkadot's XCM executor, they forged assets no reserve ever backed, and earned a $75,000 bounty.

Saving $69M at Risk: Unlimited Unbacked Cross Chain Asset Minting in Top Cross-Chain Ecosystem

Saving $69M at Risk: Unlimited Unbacked Cross Chain Asset Minting in Top Cross-Chain Ecosystem

On Feb. 16, Gianluca Brigandi, a Tooling Researcher at Perimeter, responsibly disclosed a critical Polkadot vulnerability, preventing the theft of $69 million and earning a $75,000 bounty. The flaw could have allowed an attacker to mint unlimited unbacked assets across chains. Brigandi discovered the issue through fuzzing and traced it to a missing origin-isolation step. Combined with unsanitized, user-controlled cross-chain messages (XCM), it turned two seemingly limited bugs into a full exploit.

Contact Perimeter to safeguard your protocol against exploits and large bug bounty payouts with custom purpose-made tooling.

Overview

A parachain can send an XCM message to another chain and ask it to execute additional instructions on arrival. The important trust boundary is what identity those forwarded instructions execute under. In normal operation, the executor is supposed to make that choice explicitly: either preserve the origin in a controlled way, or clear it before the remote instructions run.

This bug existed because that guarantee stopped being true in one InitiateTransfer path. Under a specific state transition, user-controlled remote_xcm could be forwarded without the executor inserting either AliasOrigin or ClearOrigin. That left the destination chain processing attacker-controlled instructions with the wrong identity context.

A second flaw made that state exploitable. The outbound message could also carry privileged XCM instructions that should not have remained under user control, including ReserveAssetDeposited, which tells the destination chain to treat assets as though they had already arrived from a trusted reserve. On its own, that instruction was usually constrained by the surrounding message logic; combined with the missing origin-isolation step, however, it became a forged reserve claim that the destination chain could accept.

message-comparison

The exploit was not just “bad input validation” or a single missing check. It was the interaction between a broken trust-boundary transition and a privileged instruction surviving farther than it should have.

Detailed Description of the Bug

The relevant security invariant is that user-controlled remote_xcm should not cross to the destination chain without an explicit origin-handling step. In XCM, origin is the identity the destination executor uses when evaluating whether certain instructions are trusted. Before forwarding remote instructions, the executor is expected to do one of two things:

  • insert AliasOrigin to preserve origin in a form that is valid from the destination chain’s perspective, or
  • insert ClearOrigin to remove inherited origin before remote execution.

The bug was that one InitiateTransfer path allowed emitting neither instruction.

The affected logic looked like this:

if preserve_origin {
    if let Some(original_origin) = self
        .origin_ref()
        .filter(|origin| *origin != &Location::here())
        .cloned()
    {
        let reanchored_origin =
            Self::try_reanchor(original_origin, &destination)?.0;
        message.push(AliasOrigin(reanchored_origin));
    }
} else {
    message.push(ClearOrigin);
}

This code appears to cover the two intended branches:

  1. If preserve_origin == true and an origin exists, insert AliasOrigin.
  2. If preserve_origin == false, insert ClearOrigin.

However, there is a third reachable state:

  1. preserve_origin == true and origin_ref() == None.

In that case, the outer if preserve_origin branch is taken, the inner if let Some(```) does not match, and execution falls through **without** emitting either AliasOrigin or ClearOrigin.

That state is attacker-reachable because ClearOrigin can be executed earlier in the same XCM program. Once origin has been cleared, a later InitiateTransfer with preserve_origin: true reaches this silent fallthrough path and produces an outbound message with no explicit origin-handling step.

This is the first bug: a missing origin-isolation step in one InitiateTransfer path.

The second bug was that attacker-controlled inner XCM could survive into the outbound message without filtering out privileged instructions such as ReserveAssetDeposited.

A simplified malicious payload looked like this:

ReserveAssetDeposited(50_000)
DepositAsset { to: me }

ReserveAssetDeposited is not an ordinary user instruction. It is a reserve-backed asset claim: it tells the destination chain to treat assets as having already arrived from a trusted reserve location. Whether that claim is accepted depends on the current origin.

The relevant destination-side handling looked like this:

ReserveAssetDeposited(assets) => {
    let origin = self.origin_ref().ok_or(XcmError::BadOrigin)?;
    for asset in assets.inner() {
        ensure!(Config::IsReserve::contains(asset, origin), XcmError::UntrustedReserveLocation);
    }
    self.holding.subsume_assets(assets.into());
}

Two checks matter here:

  1. self.origin_ref().ok_or(XcmError::BadOrigin)? The instruction is rejected if no origin is present.
  2. Config::IsReserve::contains(asset, origin) The instruction is rejected unless the current origin is trusted as a reserve location for the asset.

This is why the instruction-injection issue was constrained on its own. In the normal TransferReserveAsset flow, the executor-generated message inserted ClearOrigin before attacker-controlled instructions. That caused a forged ReserveAssetDeposited to fail at BadOrigin before any reserve-trust check could succeed.

The severe exploitability is due to the combination of two behaviors:

  • InitiateTransfer could omit both AliasOrigin and ClearOrigin when preserve_origin: true was combined with a previously cleared origin.
  • User-controlled remote_xcm could include ReserveAssetDeposited and other privileged instructions in the outbound message.

As a result, the protocol invariant no longer held. Attacker-controlled remote instructions could reach the destination without the origin state being explicitly normalized first. If the destination chain’s IsReserve configuration trusted the sending chain as a reserve location for the asset, the forged ReserveAssetDeposited could be accepted and the claimed assets could be credited despite no real reserve-backed transfer having occurred.

The fix is to make the origin-handling logic total:

  • If preserve_origin is requested but no origin remains to preserve, the executor must fall back to ClearOrigin rather than emitting nothing.
  • User-controlled remote_xcm should not cross the boundary unless an explicit origin-handling decision has been made first.
attack-sequenece

This addresses the issue at the invariant level, rather than relying on narrower checks against individual privileged instructions.

Steps Taken to Find the Bug

The bug was found by testing protocol invariants rather than looking for a specific failure mode.

An invariant is a property that should remain true across all valid executions. In this case, the most useful invariants were not about crashes or rejected messages. They were about message structure and asset semantics: whether the executor preserved the intended trust boundary, and whether the value represented in an outbound message stayed consistent with the value that actually entered it.

The work proceeded in four stages.

1. Check the origin-isolation invariant

The first invariant was:

If user-controlled remote_xcm is forwarded to another chain, the outbound message must include an explicit origin-handling step before those instructions execute.

In practice, that meant checking for instructions such as ClearOrigin or AliasOrigin in the generated outbound message before user-controlled remote instructions.

A structural fuzzer was used for this step. The point of this fuzzer was not to prove impact. It was to generate many valid-looking XCM programs and check whether the resulting outbound message still preserved the expected boundary conditions.

That process produced a counterexample: a reachable InitiateTransfer path where the outbound message contained neither ClearOrigin nor AliasOrigin before attacker-controlled remote_xcm.

At that stage, the finding was limited but already meaningful. It established that origin isolation was not total. It did not yet show whether the missing step could be used to make the destination chain accept something it should reject.

2. Minimize the case and trace the executor path

Once the counterexample existed, the next step was to reduce it to the smallest program that still reproduced the behavior.

That minimized case made it possible to trace the executor path directly instead of reasoning only from fuzzer output. The trace showed that the missing instruction was not caused by a serializer issue or a message-construction edge case. It came from a reachable control-flow gap in InitiateTransfer.

More specifically:

  • ClearOrigin could be executed earlier in the same XCM program.
  • A later InitiateTransfer could still run with preserve_origin: true.
  • In that state, origin_ref() returned None.
  • The executor then took the preserve_origin branch but emitted neither AliasOrigin nor ClearOrigin.

This established the first bug precisely: the origin-handling logic was not total.

3. Check if privileged instructions remain in outbound message

At this point, only one question remained. Whether the missing origin-isolation step had any security consequence beyond violating a structural invariant.

To answer that, a second invariant was tested:

User-controlled inner XCM should not be able to introduce value claims or privileged state transitions that exceed what the surrounding transfer logic actually authorized.

This is an asset-consistency invariant. The outbound message should not be able to claim more assets than were really moved into it.

A second fuzzing campaign focused on attacker-controlled inner XCM and looked for cases where privileged instructions survived into the outbound message. That process identified paths where instructions such as ReserveAssetDeposited could be carried through without being stripped or normalized.

That result still needed one more check, because ReserveAssetDeposited is only meaningful if the destination chain accepts the current origin as trusted for the asset. Manual validation showed that, on the ordinary TransferReserveAsset path, this did not happen: ClearOrigin was inserted before the attacker-controlled instructions, so the forged reserve claim failed immediately with BadOrigin.

That is what made the issue difficult to spot during the initial review. The instruction-injection behavior was present, but the surrounding message logic usually neutralized it.

4. Compose the two results

The final step was to evaluate the two findings together rather than in isolation.

Individually, the results were:

  1. a reachable path where user-controlled remote_xcm crossed to the destination without an explicit origin-handling step, and
  2. a reachable path where privileged instructions such as ReserveAssetDeposited could survive into outbound XCM.

Taken together, they described a single bad state:

  • the outbound message omitted the normal origin-isolation step, and
  • the attacker-controlled remote program still contained a privileged reserve-asset claim.

Once those two conditions were combined, the destination-side reserve check became the critical question. If the destination chain’s IsReserve configuration trusted the sending chain as a reserve location for the asset, then the forged ReserveAssetDeposited could be accepted even though no real reserve-backed transfer had occurred.

Summary of the Method

The bug was found by testing invariants instead of searching for one specific known failure pattern.

The process started with structural fuzzing against message-shape assumptions: if user-controlled instructions were forwarded to another chain, the generated outbound message should still include an explicit origin-handling step. That surfaced a counterexample and led to a minimized test case.

From there, manual tracing of the executor path explained the result. The minimized case showed that a specific InitiateTransfer path could fall through without inserting either AliasOrigin or ClearOrigin when origin had already been cleared earlier in the program.

A second round of fuzzing then focused on asset and instruction semantics rather than message structure alone. That work looked for cases where user-controlled inner XCM could survive into the outbound message with behavior that exceeded what the surrounding transfer logic should allow.

The final step was composition: taking the two independently discovered results, validating them manually, and checking whether they could occur on the same path. That is what turned two seemingly limited findings into a complete bug bounty result.

In short, the method was:

  1. Fuzz for invariant violations
  2. Minimize the counterexample
  3. Trace the implementation manually
  4. Test related semantic invariants
  5. Evaluate whether separate findings compose

Learnings

The main lesson is broader than this specific bug bounty: serious protocol bugs often come from broken invariants, not obviously dangerous inputs.

Every protocol should keep a short, explicit list of its critical invariants. These are the properties that must always hold for the system to remain safe.

Those invariants should be tested directly, not just assumed. And when one is violated, that result should always be investigated thoroughly. Even if the issue does not look exploitable at first, it may still point to a deeper design problem or become dangerous when combined with another bug.

This is also why fuzzing matters so much. Many of the deepest bugs do not crash the system. They produce valid-looking execution with invalid security behavior. Fuzzing is especially effective at finding those cases when it is designed around protocol invariants instead of only malformed input.

That kind of work benefits from specialists with deep fuzzing expertise. Finding semantic bugs usually requires more than scale. It requires people who know how to model the protocol correctly, define meaningful invariants, and recognize when a strange result signals a real security failure.

The practical takeaway is simple: define critical invariants early, fuzz against them continuously, and treat every invariant violation as worth understanding and fixing whenever possible.

Conclusion

This bug bounty came from the interaction of two individually constrained behaviors: one path failed to enforce explicit origin handling before forwarding user-controlled remote instructions, and another allowed privileged XCM instructions to remain under user control longer than they should have.

The result was not just a narrow implementation mistake. It was a violation of a core protocol invariant at a trust boundary.

More broadly, this is why invariant-based testing should be part of the core security strategy for any protocol. Not every security problem is best framed that way, but when a protocol depends on certain properties always holding, those properties should be stated clearly, tested directly, and investigated seriously when they fail.

This case also reinforces the value of fuzzing in protocol review. Some of the most important bugs are not crashes. They are valid-looking states that break the protocol’s security assumptions. Those bugs are often hardest to find without people who know how to fuzz for semantic failures.


This vulnerability was reported through Polkadot's responsible disclosure process. The fix has been deployed — Polkadot forum post https://forum.polkadot.network/t/postmortem-xcm-initiatetransfer-origin-leak/17357. We thank the Parity team for their responsive handling of this report.

perimeter-logo

Perimeter is built on uncompromising quality, consistent communication, and transparency.

X-logoGithub-logo

Open Source

Resources

Legal & Contact

Copyright 2026 | All Rights Reserved