Epoch approval signatures lack chain binding, enabling cross-chain approval replay
- Dominant language
- Go
- Stars
- 22
- Forks
- 4
- Avg merge
- 2d 14h
- Merged PRs (30d)
- 34
Description
## Details
Validator-set-change approvals are signed over assembleApprovalToBeSigned(pChainHeight, auxInfoDigest): an 8-byte big-endian P-chain height plus a 32-byte aux-info digest, wrapped in an asn1-marshalled SignedMessage with the fixed context string "MSM approval" (msm/msm.go:1111-1118). The payload contains no identifier of the network, chain/subnet, Simplex instance, epoch, or validator set being approved. Both verification paths — ApprovalStore.checkApprovalSignature for individually gossiped approvals and StateMachine.verifyNextEpochApprovalsSignature for the aggregated approvals embedded in proposed blocks — reconstruct the same unbound payload.
The library owns domain separation of its signed payloads (it adds per-message-type context strings) but delegates signing to an embedder-supplied CryptoOps (Sign(message []byte), config.go:74-80); nothing in the interface contract or documentation requires that implementation to add instance-specific separation, and the msm README documents the approval signature as covering only the aux info and next_p_chain_reference_height fields.
Avalanche nodes use a single BLS key across all chains they validate. If node V is a next-epoch validator on two L1s A and B that both run this MSM, an approval V signs for A's epoch transition verifies as an approval for B whenever both transitions reference the same NextPChainReferenceHeight (P-chain heights are global; one P-chain event, e.g. a weight change of a shared validator, can change both L1s' validator sets at the same height) and the aux-info digests match. The orchestration layer hardcodes NoopAuxiliaryInfoApp (instance.go:460), so the aux history is always empty and the digest is the constant sha256("") on every chain — only the height must coincide.
Any network participant who observes V's approval broadcast on chain A can deliver it to chain B's validators: ApprovalStore.HandleApproval accepts approvals from any sender, attributing them solely by the embedded NodeID and signature, and sanitizeApprovals only requires the (height, digest) pair to match the local transition. B's builders then count V as approving B's epoch change even though V never did. This defeats the stated purpose of the approval mechanism (approvals ensure next-epoch nodes have finished replicating before the current epoch seals): chain B can seal its epoch and hand over to a validator set that is not ready, stalling the chain until the new validators catch up, and the sealing block permanently records approvals its signers never issued for that chain. Same-chain replay across transitions is prevented by the strictly increasing reference height; the missing binding is exploitable across instances that share validator BLS keys, unless the embedder's CryptoOps independently adds per-chain domain separation — which this library's contract does not require.
## Evidence
1. [msm/msm.go:1109–1118](https://github.com/ava-labs/Simplex/blob/main/msm/msm.go#L1109-L1118)
assembleApprovalToBeSigned: the signed approval payload is only the 8-byte big-endian P-chain height and the 32-byte aux-info digest under the fixed context string "MSM approval". No network ID, chain/subnet ID, epoch number, or validator-set identifier binds the approval to a specific Simplex instance. Primary location: the fix must bind instance identity into this payload (or its signing context).
2. [msm/approvals.go:149–157](https://github.com/ava-labs/Simplex/blob/main/msm/approvals.go#L149-L157)
checkApprovalSignature reconstructs the same unbound payload, so an approval captured on one chain verifies and is stored on any other chain where the claimed node is a next-epoch validator with the same BLS key. HandleApproval accepts approvals from any sender, attributing them solely by the embedded NodeID and signature.
3. [msm/msm.go:1083–1107](https://github.com/ava-labs/Simplex/blob/main/msm/msm.go#L1083-L1107)
verifyNextEpochApprovalsSignature verifies the aggregated approvals embedded in proposed blocks against the same unbound payload, so replayed cross-chain approvals count toward the n-f sealing quorum and the aggregated BLS verification succeeds.
4. [adapters.go:179–194](https://github.com/ava-labs/Simplex/blob/main/adapters.go#L179-L194)
NoopAuxiliaryInfoApp: IsSufficient always returns true on an empty history and Generate returns nil, so the aux-info history stays empty and the aux-info digest is the constant sha256("") on every chain, leaving the P-chain height as the only differentiator between instances.
5. [instance.go:445–460](https://github.com/ava-labs/Simplex/blob/main/instance.go#L445-L460)
The MSM's Signer and SignatureVerifier are the embedder-supplied CryptoOps (a bare Sign(message []byte) contract with no documented requirement to add per-chain domain separation), and AuxiliaryInfoApp is hardcoded to NoopAuxiliaryInfoApp — the library neither performs nor requires instance-level domain separation.
## Impact
Integrity: the sealing block records epoch-change approvals that the affected validators never issued for this chain, subverting the n-f readiness guarantee (limited, protocol-metadata level - no direct safety violation of finalized blocks). Availability: the epoch can seal before next-epoch validators are ready, stalling the chain temporarily until they catch up.
## Reproduction steps
1. The attacker passively captures approval broadcasts on chain A and forwards them to chain B's validators; approvals are accepted from any sender and attributed by the embedded NodeID and signature. Requirements: validators shared between the two chains (routine on Avalanche — one node/BLS key validates many L1s), both chains transitioning at the same NextPChainReferenceHeight with matching aux digests (digest is constant under the hardcoded Noop app), and an embedder-supplied CryptoOps that does not itself add per-chain domain separation — the library neither performs nor requires it. Enough shared validators' approvals must be replayed to tip the n-f quorum.
## Recommended fix
The approval signature payload omits any identifier of the specific chain/Simplex instance and epoch being approved, so a signature made for one chain verifies on another chain that shares the signer's BLS key. Fix criteria: An approval signed for one chain/epoch must not verify on any other chain or for any other epoch transition: the signed payload (or signing context) uniquely identifies the chain/subnet and the specific transition. Verified by replaying an approval produced for one chain instance against another instance sharing the same validator key and confirming both HandleApproval and verifyNextEpochApprovalsSignature reject it.
---
**Severity:** MEDIUM
**Status:** Open
**Category:** Signature replay
**CWE:** [CWE-294](https://cwe.mitre.org/data/definitions/294.html)
**Repository:** ava-labs/Simplex
**Branch:** main
**Date created:** 2026-08-21
---
---
_Related existing issue: #395_
Contributor guide
No contributing guide indexed for this repository
Research direction
Start with assembleApprovalToBeSigned and the verification paths in msm/msm.go and msm/approvals.go, then read the CryptoOps wiring in instance.go and the NoopAuxiliaryInfoApp in adapters.go. Reproduce an approval replay between two instances sharing a validator key, then ensure the signed transition identity is instance-specific and both HandleApproval and aggregated verification reject the replay.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- cryptography, distributed-systems, security
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 38/100