# \[CRITICAL\] Unlimited Token Minting via Weaver Session Replay

**URL:** https://ghostforum.org/t/critical-unlimited-token-minting-via-weaver-session-replay/100
**Category:** Remediation in Progress
**Tags:** weaver-pallet
**Created:** [October 2, 2026, 11:16am UTC](https://ghostforum.org/t/critical-unlimited-token-minting-via-weaver-session-replay/100 "2026-10-02T11:16:48Z")
**Posts on this page:** 8
**Page:** 1

<div class="post-metadata">

### Author: ![ediology](https://ghostforum.org/user_avatar/ghostforum.org/ediology/32/131_2.png) [@ediology](https://ghostforum.org/u/ediology)
#### Post date: [October 2, 2026, 11:16am UTC](https://ghostforum.org/t/critical-unlimited-token-minting-via-weaver-session-replay/100/1 "2026-10-02T11:16:48Z")

</div>

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:

2. pallets/weaver/src/types.rs, EvmThreadProof::get\_unique\_key:

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:

```auto
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

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

---

<div class="post-metadata">

### Author: ![ediology](https://ghostforum.org/user_avatar/ghostforum.org/ediology/32/131_2.png) [@ediology](https://ghostforum.org/u/ediology)
#### Post date: [October 2, 2026, 11:38am UTC](https://ghostforum.org/t/critical-unlimited-token-minting-via-weaver-session-replay/100/2 "2026-10-02T11:38:20Z")

</div>

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.

---

<div class="post-metadata">

### Author: ![VoidDragon](https://ghostforum.org/user_avatar/ghostforum.org/voiddragon/32/16_2.png) [@VoidDragon](https://ghostforum.org/u/VoidDragon)
#### Post date: [October 3, 2026, 9:00pm UTC](https://ghostforum.org/t/critical-unlimited-token-minting-via-weaver-session-replay/100/3 "2026-10-03T21:00:20Z")

</div>

Thank you for your report!

Let me clarify why a session is needed on the EVM side ([solidity side of the protocol](https://git.ghostchain.io/ghostchain/ghost-dao-contracts/src/branch/main/src/types/Weaver.sol)). 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.

---

<div class="post-metadata">

### Author: ![ediology](https://ghostforum.org/user_avatar/ghostforum.org/ediology/32/131_2.png) [@ediology](https://ghostforum.org/u/ediology)
#### Post date: [October 5, 2026, 2:46pm UTC](https://ghostforum.org/t/critical-unlimited-token-minting-via-weaver-session-replay/100/4 "2026-10-05T14:46:24Z")

</div>

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.

---

<div class="post-metadata">

### Author: ![VoidDragon](https://ghostforum.org/user_avatar/ghostforum.org/voiddragon/32/16_2.png) [@VoidDragon](https://ghostforum.org/u/VoidDragon)
#### Post date: [October 5, 2026, 3:48pm UTC](https://ghostforum.org/t/critical-unlimited-token-minting-via-weaver-session-replay/100/5 "2026-10-05T15:48:48Z")

</div>

First of all, thank you for continuing this thread!

The fix you’re proposing will block any historical claims - that is, `pull_thread`s 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](https://git.ghostchain.io/ghostchain/ghost-node/src/commit/0162da4604d9931c16d12a0cec40dd85951368c5/pallets/weaver/src/lib.rs#L565).
3. On another `on_initialize`, it will fall into the [first branch here](https://git.ghostchain.io/ghostchain/ghost-node/src/commit/0162da4604d9931c16d12a0cec40dd85951368c5/pallets/weaver/src/lib.rs#L494) 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.

---

<div class="post-metadata">

### Author: ![VoidDragon](https://ghostforum.org/user_avatar/ghostforum.org/voiddragon/32/16_2.png) [@VoidDragon](https://ghostforum.org/u/VoidDragon)
#### Post date: [October 10, 2026, 3:57pm UTC](https://ghostforum.org/t/critical-unlimited-token-minting-via-weaver-session-replay/100/6 "2026-10-10T15:57:53Z")

</div>

Hey @ediology - it looks like the dev team has [fixed the issue](https://git.ghostchain.io/ghostchain/ghost-node/commit/f72387c9cbf3e9d140687685b4e0a146f24679d8) 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.

---

<div class="post-metadata">

### Author: ![Sands](https://ghostforum.org/letter_avatar_proxy/v4/letter/s/22d042/32.png) [@Sands](https://ghostforum.org/u/Sands)
#### Post date: [October 11, 2026, 2:01pm UTC](https://ghostforum.org/t/critical-unlimited-token-minting-via-weaver-session-replay/100/7 "2026-10-11T14:01:29Z")

</div>

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.

---

<div class="post-metadata">

### Author: ![VoidDragon](https://ghostforum.org/user_avatar/ghostforum.org/voiddragon/32/16_2.png) [@VoidDragon](https://ghostforum.org/u/VoidDragon)
#### Post date: [October 11, 2026, 4:58pm UTC](https://ghostforum.org/t/critical-unlimited-token-minting-via-weaver-session-replay/100/8 "2026-10-11T16:58:08Z")

</div>

@Sands is right. Here is the [link to the post](https://ghostforum.org/t/weavingstates-never-reset-stale-root-finalizes-into-every-new-session-with-cross-session-double-mint/87).

I have no idea why it was closed.
