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).
- 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). - Craft
EvmThreadProof { slot_index: u64::MAX, proof_index: i, receiver: <attacker>, amount: <arbitrary>, proof, slots: vec![] }. - Call
pull_thread(RawOrigin::None, network_id, session, ThreadProof::EvmThreadProof(p)). - Observe: returns
Ok,Currency::mint_into(attacker, amount)executes,
PulledThreadsrecords 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 owngenerate_tree
does. Suggested verification: confirm the padding scheme against the EVM
contract. pull_threadis 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.