[CRITICAL] Unlimited Token Minting via Weaver Session Replay

Component: ghost-node — pallets/weaver (pull_thread, EvmThreadProof::get_unique_key)
Impact: Repeatable, unbounded minting of the native protocol currency from a single valid Merkle proof.

Summary

pull_thread accepts a caller-supplied session and validates the thread proof against
LoomStates[network_id][session] without ever checking that the supplied session is the current
one. For EVM proofs the replay-guard key is hash(session, slot_index, proof_index, network_id) —
it embeds that same caller-chosen session — and loom roots are never pruned. A single valid
proof therefore mints again for every stored session whose root matches the proof.

Impact

  • Unbounded supply inflation: one proof mints once per session entry, and the number of entries
    only grows (the session counter increments and old entries are never removed).
  • pull_thread is an unsigned extrinsic (ensure_none), so any account can replay.
  • The UTXO path is unaffected, which shows the EVM omission is unintended.

Root Cause

  1. pallets/weaver/src/lib.rs, pull_thread:

    let root_hash = LoomStates::::get(&network_id, &session); // session = caller input
    let pulled_thread_key = thread_proof.get_unique_key(session, network_id);

    No ensure!(session == CurrentWeavingSession::::get(&network_id)) anywhere.

  2. pallets/weaver/src/types.rs, EvmThreadProof::get_unique_key:

    let session_bytes = (session as u64).to_be_bytes();
    let slot_index_bytes = (self.slot_index as u64).to_be_bytes();
    let proof_index_bytes = (self.proof_index as u64).to_be_bytes();
    let metadata: [H256; 4] = [H256::from(h1), H256::from(h2), H256::from(h3), H256::from(h4)];
    sequential_hash::<SubstrateBlake2Hasher, _>(metadata)

    Changing session changes the key, so the ThreadAlreadyPulled guard never triggers.

  3. LoomStates entries are written in on_initialize for current_session and are never removed
    (there is no LoomStates::remove / kill anywhere in the pallet).

For comparison, UtxoThreadProof::get_unique_key(&self, network_id) does NOT take a session,
so its replay guard is stable. The EVM path is the asymmetric one.

Reproduction

cargo test -p ghost-weaver poc_same_evm_proof_mints_in_two_sessions – --nocapture

Output:

running 1 test
balance after first pull  (session 0): 69
balance after second pull (session 1): 138
test poc_replay::poc_same_evm_proof_mints_in_two_sessions ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 23 filtered out

The pallet’s 24 existing tests still pass.

Suggested Fix

ensure!(
    session == CurrentWeavingSession::<T>::get(&network_id),
    Error::<T>::WeavingIsInactive,   // or a dedicated StaleSession error
);

Additionally, key the replay guard on the proof identity (root / slot index / proof index) rather
than the bookkeeping session, so a proof can only ever be pulled once.

Additional context on how this differs from #87 (“WeavingStates never reset”).

Both reports touch sessions, but the mechanism and the impact cap differ:

#87 — stale finalization.
WeavingStates is never reset, so a stale root hash is copied into the LoomStates
of later sessions by on_initialize. That report concludes the double-mint is
“honestly capped” because “the receiver is bound in the leaf preimage, so only the
original depositor profits, one extra mint of the same deposit per replayed session.”

This report — replay-guard key scoping.
The problem here is independent of stale finalization: pull_thread accepts an
arbitrary caller-supplied session and never checks it against
CurrentWeavingSession. For EVM proofs the replay guard is keyed on
hash(session, slot_index, proof_index, network_id) — it embeds that same
caller-chosen session — so the ThreadAlreadyPulled guard does not fire when a
different session value is supplied.

Consequences that are NOT capped at one extra mint:

  • The proof can be submitted once per stored session, and stored sessions are
    never pruned (no LoomStates::remove / kill at HEAD).
  • pull_thread is an unsigned extrinsic (ensure_none), so the replay is not
    restricted to the original depositor. Any account that observes the proof
    can resubmit it.
  • The UTXO path is unaffected (UtxoThreadProof::get_unique_key takes no
    session), which is evidence the EVM omission is unintended rather than a
    deliberate session-scoped design.

The PoC prints the mint growing with the session value:
balance after first pull (session 0): 69
balance after second pull (session 1): 138
and the same call can be repeated for every stored session.

Fix suggestion (either is sufficient, both is better):

  1. ensure!(session == CurrentWeavingSession::::get(&network_id), StaleSession);
  2. key PulledThreads on the proof identity (root / slot_index / proof_index)
    instead of the bookkeeping session.

Happy to share the full test file (pallets/weaver/src/poc_replay.rs) if useful.

Thank you for your report!

Let me clarify why a session is needed on the EVM side (solidity side of the protocol). Because the smart contract has hard limits on Merkle tree depth and on the number of slots behind each tree, the total number of entries is capped at 2048. This is what we call a session - that is all possible filled entries smart contract could handle in one tree.

That’s why, at any point in time, session T and all of {T-1, T-2, …, 0} have their own root hash that can be extracted with getRootHash on the smart contract side. The only session whose root hash keeps changing is the current one - in our example T - which we can describe as ongoing and changing.

The concept is to update the LoomState until validators observe that the session changed during the getRootHash call (take a look on return of it). That means all 2048 entries are filled with actual values and all of them are ready to be claimed.

So I do not understand how anyone could claim the same amount to the same receiver unless the root hashes match - which would effectively mean we have two identical Merkle trees representing exactly the same sequence of registered transactions across two different sessions. In any case, your report sounds promising. It would be great if you could elaborate and explain your idea in more depth, or perhaps the clarifications above will lead you to reconsider the abuse from a different angle.

Thanks for the explanation — that’s helpful, and it actually sharpens the report rather than dismissing it. Let me separate the two things you’re describing, because they’re different.

## You’re right, and here’s the part we agree on

> “I do not understand how anyone could claim the same amount to the same receiver unless the root hashes match.”

Correct — and I never claimed otherwise. `verify_thread_proof` checks the proof against `LoomStates[network_id][session]`, so a proof only verifies when the stored root for that session matches the proof’s root. Your clarify on solidity-side sessions (2048-entry trees, `getRootHash(T)` per session) is consistent with what the code does.

## The gap this doesn’t cover

The proof only has to match *a* session’s root — not the *current* one. `pull_thread` takes `session` as a caller parameter and never checks it against `CurrentWeavingSession`:

```rust

// pallets/weaver/src/lib.rs, pull_thread

let root_hash = LoomStates::::get(&network_id, &session); // caller-chosen key

let pulled_thread_key = thread_proof.get_unique_key(session, network_id);

```

And the replay guard is keyed on that same caller-chosen session:

```rust

// pallets/weaver/src/types.rs, EvmThreadProof::get_unique_key

let session_bytes = (session as u64).to_be_bytes();

// … → hash(session, slot_index, proof_index, network_id)

```

So the question isn’t “can one tree produce two identical roots” (it can’t, as you say). It’s: **can two different sessions end up with the same stored root?** If yes, one proof mints once per such session.

## They can — and the pallet produces that state itself

I originally showed this by writing the same root into two sessions directly, which understandably looks like I fabricated the state. So I redid it using only the pallet’s own lane: `weave_weft` (validator attestation) + `on_initialize` (finalization). No manual `LoomStates::insert` for the second session.

Run:

```

cargo test -p ghost-weaver poc_v3_same_root – --nocapture

```

Output (from a test that only seeds session 0, then lets the pallet run):

```

weave_weft result: Ok(())

round@10: attest hash=0x07f4a5ba…6129 next_session=1 | current_session 0 → 1

round 1: session 0 → 1

root[session 0] = 0x07f4a5ba…6129 (written by on_initialize)

weave_weft result: Ok(())

round@20: attest hash=0x07f4a5ba…6129 next_session=2 | current_session 1 → 1

round 2: session 1 → 1

root[session 1] = 0x07f4a5ba…6129 (written by on_initialize)

SHARED ROOT CHECK: root[s0]=0x07f4a5ba…6129 root[s1]=0x07f4a5ba…6129 equal=true

after pull via session 0: 69

after pull via session 1: 138

RESULT: one proof, two pallet-produced sessions sharing one root | balance 69 → 138

```

Note the flow, which is the whole point:

1. Round 1 attests hash `H` with `next_session = 1`. `on_initialize` sees `LoomStates[current] != H` and `weaving_state.next_session != current_session`, so it writes `H` into session 0 and advances current to 1.

2. Round 2 attests the **same** hash `H` with `next_session = 2`. `on_initialize` writes `H` into session **1** — again itself. Nothing in the test touches `LoomStates[1]`.

3. Now session 0 and session 1 both hold root `H`. The same proof verifies against both, and `get_unique_key(session, …)` differs per session, so `PulledThreads` never blocks the second pull.

So the state you called out as unreachable (“two identical Merkle trees across two sessions”) is written by `on_initialize` as part of normal operation. It doesn’t require the tree to be identical — it requires the *loom root to be carried forward into a later session*, which the advance logic at `lib.rs:507-521` does whenever validators attest a `next_session` ahead of the current one while the hash is unchanged.

## Why this is repeatable, not a one-off

- `LoomStates` entries are never removed (no `LoomStates::remove` / `kill` at HEAD), so once a session holds root `H`, it stays valid forever.

- `pull_thread` is unsigned (`ensure_none`), so replay isn’t limited to the original depositor — anyone who observes the proof can resubmit it against every stored session whose root is `H`.

- The UTXO path is unaffected: `UtxoThreadProof::get_unique_key(&self, network_id)` takes no session, so its replay key is stable and the guard holds. The EVM path is the asymmetric one, which is why I think the session-scoped key is unintended rather than a deliberate per-session design.

## Suggested fix (either, both is better)

```rust

ensure!(

session == CurrentWeavingSession::::get(&network_id),

Error::::StaleSession, // or WeavingIsInactive

);

```

and/or key `PulledThreads` on proof identity rather than the bookkeeping session:

```rust

// hash(root, slot_index, proof_index, network_id) — no caller-supplied session

```

The full test file (`pallets/weaver/src/poc_replay_v3.rs`) is attached / can be pasted into `pallets/weaver/src/` and registered with `mod poc_replay_v3;` under `#[cfg(test)]`. Happy to walk through any step if a detail doesn’t line up.

First of all, thank you for continuing this thread!

The fix you’re proposing will block any historical claims - that is, pull_threads down the road - which is not the expected behavior. For example, the last entry at position 2047 will be written, and then a new session will be spawned instantly on the smart contract side. At any point in time, whenever validators get to the root, they’ll bump CurrentWeavingSession, and that last record will become unclaimable.

Also, I don’t see the poc_replay_v3 you’re referring to - if possible, could you paste it here? Just to make sure we’re both on the same page.

Actually, I believe the core issue here is that WeavingStates is not cleared on a successful update of the Merkle tree. So what I think you’re getting is:

  1. Do some number of honest weave_weft calls.
  2. Run on_initialize - it will create a TapestryDraft here.
  3. On another on_initialize, it will fall into the first branch here and store the hash into LoomState.
  4. Then, I assume your test is not doing new weave_weft calls and instead runs on_initialize multiple times right away.
  5. Because the WeavingStates is not cleared or updated, it will re-apply the sort of cached value into a new session.

Can you confirm I’m on the right track with my understanding of what you’re reporting? The issue is that a previously cached WeavingStates value - not cleaned up - automatically falls into the LoomState of a new session thus doing duplication of hash for the next session. Please correct me if I’m wrong.

Hey @ediology - it looks like the dev team has fixed the issue you reported. Could you confirm whether it’s resolved on your end now, or continue the thread if it still persists?

The fix is dead simple, just clean the cached state of the WeavingStates. We believe this should fix what you’ve reported.

Record-keeping note for the first-reporter accounting on this fix: the WeavingStates not cleared at session finalization diagnosis was filed in topic 87 on Oct 2 at 13:57 PKT with an executed PoC, about 2h19m before topic 100’s 16:16 PKT filing the same day. The shipped fix, clearing WeavingStates at session finalization, addresses that diagnosis directly. Topic 87 is locked to replies, so leaving the note here.

@Sands is right. Here is the link to the post.

I have no idea why it was closed.