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
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.

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
AliasOriginto preserve origin in a form that is valid from the destination chain’s perspective, or - insert
ClearOriginto 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:
- If
preserve_origin == trueand an origin exists, insertAliasOrigin. - If
preserve_origin == false, insertClearOrigin.
However, there is a third reachable state:
preserve_origin == trueandorigin_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:
self.origin_ref().ok_or(XcmError::BadOrigin)?The instruction is rejected if no origin is present.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:
InitiateTransfercould omit bothAliasOriginandClearOriginwhenpreserve_origin: truewas combined with a previously cleared origin.- User-controlled
remote_xcmcould includeReserveAssetDepositedand 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_originis requested but no origin remains to preserve, the executor must fall back toClearOriginrather than emitting nothing. - User-controlled
remote_xcmshould not cross the boundary unless an explicit origin-handling decision has been made first.

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_xcmis 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:
ClearOrigincould be executed earlier in the same XCM program.- A later
InitiateTransfercould still run withpreserve_origin: true. - In that state,
origin_ref()returnedNone. - The executor then took the
preserve_originbranch but emitted neitherAliasOriginnorClearOrigin.
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:
- a reachable path where user-controlled
remote_xcmcrossed to the destination without an explicit origin-handling step, and - a reachable path where privileged instructions such as
ReserveAssetDepositedcould 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:
- Fuzz for invariant violations
- Minimize the counterexample
- Trace the implementation manually
- Test related semantic invariants
- 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.
