[Critical] weaver: EVM arbitrary mint via slot_index OOB fail-open

Summary

EvmThreadProof::get_preimage_slice (pallets/weaver/src/types.rs:365-380)
returns H256::zero() instead of failing when the attacker-controlled
slot_index is out of bounds of slots. verify_thread_proof then checks
keccak(0x00 || 0x00×32) against the loom root — exactly the empty-leaf hash
that generate_tree (pallets/helpers/src/merkle_tree.rs:44-47) pads unfilled
leaves with. A fully public proof for any empty leaf therefore verifies, while
receiver and amount (attacker-chosen struct fields, never bound to the proof
in the OOB path) are returned for minting. **Unbounded wrapped-asset mint with
zero deposit.**

Severity

Critical. Direct unauthorized mint of wrapped assets; no deposit, no
privileged role, fully deterministic once the (public) empty-leaf proof is known.

Steps to Reproduce

Prerequisites: a loom root for an EVM network whose deposit tree is not
completely full (the standard case generate_tree zero-pads unfilled leaves).

  1. Pick any empty leaf index i (e.g. 255) in the 8-deep tree and obtain its
    8-level sibling proof (publicly computable from the tree / observable).
  2. Craft EvmThreadProof { slot_index: u64::MAX, proof_index: i, receiver: <attacker>, amount: <arbitrary>, proof, slots: vec![] }.
  3. Call pull_thread(RawOrigin::None, network_id, session, ThreadProof::EvmThreadProof(p)).
  4. Observe: returns Ok, Currency::mint_into(attacker, amount) executes,
    PulledThreads records the key. No deposit was ever made.

Proof of Concept

Local PoC (byte-for-byte reimplementation of merkle_tree.rs +
get_preimage_slice/verify_thread_proof, keccak, depth 8). Full PoC source
available on request.

cd poc-gh01 && cargo test
# 4 passed:
# - gh01_oob_preimage_verifies_against_empty_leaf_proof
# - gh01_arbitrary_receiver_and_amount_pass_verification
# - gh01_negative_control_wrong_preimage_fails
# - gh01_negative_control_full_tree_has_no_empty_leaf

The vulnerable branch (pallets/weaver/src/types.rs:373-378):

match slot_values.get_mut(slot_index_usize) {
    Some(slot) => {
        *slot = self.compute_arguments_hash(network_id);
        cumulative_hash::<SubstrateKeccakHasher, _>(slot_values)
    }
    None => {
        SubstrateKeccakHasher::empty()   // <-- fail-open: should be an Err
    }
}

Impact

Anyone can mint arbitrary amounts of wrapped EVM assets to any account without
depositing anything, limited only by the number of empty leaves (up to 255 per
tree). This breaks the bridge’s 1:1 backing invariant completely.

Remediation

Return an error on OOB slot_index instead of empty():

None => return None, // and propagate as InvalidMerkleProof / new error

Additionally, bind proof_index to slot_index (currently independent), and
consider rejecting proofs for leaves the pallet knows are empty.

Caveats

  • The PoC is a faithful local reimplementation, not the compiled pallet
    (Substrate workspace exceeds the test machine); the code path is quoted verbatim.
  • Full exploitability assumes the EVM-side deposit contract zero-pads unfilled
    leaves — standard practice and exactly what the pallet’s own generate_tree
    does. Suggested verification: confirm the padding scheme against the EVM
    contract.
  • pull_thread is currently unreachable via the tx pool (unsigned validation
    gap); it is includable by a block author. Fixing the unsigned-validation gap
    without this fix makes the mint permissionless — these must ship together.

Thank you for your report!

Can you please verify whether this is fixed on the working branch for pallet-weaver? It sounds close to something the dev team found internally a week ago. Please confirm whether the commit above fixes it, or if you still see a way to abuse it - in that case, please continue the thread.

By the way, the working branches for the pallets are not merged yet, because for our current testnet we haven’t redeployed the smart contracts, so we don’t have any external network to watch at the moment.

Verified the fix is good. I checked out pallet-weaver @ 77bf441 and re-ran my PoC against the new logic:

  • OOB slot_index now fails closed (get_preimage_hash returns None, propagated via ?) instead of returning empty().
  • The fix goes further than fail-closed: the provided slot must equal compute_arguments_hash(network_id), binding (amount, receiver, slot_index, network_id) — so slot spoofing is dead too.
  • I could not find a remaining abuse path: matching a foreign or empty leaf through cumulative_hash would require a keccak preimage attack.
  • Positive control: an honest deposit flow (slot matching the computed hash) still verifies, so legitimate usage is unaffected.

5/5 verification tests pass on my end. Confirmed fixed no further abuse visible in this function. (And noted: since your team found it internally before my report, I understand if this one is treated as a duplicate.)