Filing as a public issue per the maintainer request (the program is undeployed and unaudited, so this does not need private handling).
Summary
An attacker can cause an honest leader to be recorded as a duplicate-block violator without that leader ever equivocating. The attacker needs only one genuinely signed non-resigned merkle shred S (publicly observable on the wire) within the one-epoch statute window.
Affected
program/src/shred.rs (Shred::new_from_payload, and is_shred_duplicate / its inner get_payload) at HEAD bb6b24c.
Root cause
Shred::new_from_payload stores the payload slice without truncating it to the canonical shred size, and the inner get_payload in is_shred_duplicate returns the full untruncated payload for non-resigned shreds. Because merkle_root() reads fixed offsets, trailing bytes are ignored, so a padded copy keeps the same merkle root and the same valid leader signature.
Upstream agave truncates every merkle shred to its canonical size at ledger/src/shred/merkle.rs:525 and :586 (payload.truncate(Self::SIZE_OF_PAYLOAD), canonical 1203 for data and 1228 for coding); this Core BPF port omits that step.
Reproduction
Submit a DuplicateBlockProof with shred1 = S.payload() and shred2 = S.payload() followed by one extra 0x00 byte. Both carry S's real signature and an identical merkle root, the same slot, index, and shred type. sigverify_shreds passes. is_shred_duplicate returns true only because the two byte lengths differ, so check_shreds returns Ok(()) and a violation report is stored against the leader.
Verified with a test added to the repo harness (cargo test, HEAD bb6b24c):
[POC] data non-resigned len1=1203 len2=1204 verify_proof=Ok(()) (innocent leader slashed)
[POC] code non-resigned len1=1228 len2=1229 verify_proof=Ok(()) (innocent leader slashed)
[POC] data resigned len1=1203 len2=1204 verify_proof=Err(InvalidPayloadProof)
[POC] code resigned len1=1228 len2=1229 verify_proof=Err(InvalidPayloadProof)
Non-resigned shreds are produced by the canonical shredder for every FEC set that is not the last in a slot, so this is a normal shred variant, not an edge case.
Fix
Always truncate to proof_offset + proof_size in get_payload, removing the non-resigned early return:
fn get_payload<'a>(shred: &Shred<'a>) -> &'a [u8] {
let Ok((proof_offset, proof_size)) = shred.get_proof_offset_and_size() else {
return shred.payload;
};
let Some(offset) = proof_offset.checked_add(proof_size) else {
return shred.payload;
};
shred.payload.get(..offset).unwrap_or(shred.payload)
}
For non-resigned shreds proof_offset + proof_size equals the canonical size, so this strips only attacker padding and never drops a real violation. With the fix applied, the padded cases return InvalidPayloadProof and all 23 pre-existing tests still pass.
Filing as a public issue per the maintainer request (the program is undeployed and unaudited, so this does not need private handling).
Summary
An attacker can cause an honest leader to be recorded as a duplicate-block violator without that leader ever equivocating. The attacker needs only one genuinely signed non-resigned merkle shred
S(publicly observable on the wire) within the one-epoch statute window.Affected
program/src/shred.rs(Shred::new_from_payload, andis_shred_duplicate/ its innerget_payload) at HEADbb6b24c.Root cause
Shred::new_from_payloadstores the payload slice without truncating it to the canonical shred size, and the innerget_payloadinis_shred_duplicatereturns the full untruncated payload for non-resigned shreds. Becausemerkle_root()reads fixed offsets, trailing bytes are ignored, so a padded copy keeps the same merkle root and the same valid leader signature.Upstream agave truncates every merkle shred to its canonical size at
ledger/src/shred/merkle.rs:525and:586(payload.truncate(Self::SIZE_OF_PAYLOAD), canonical 1203 for data and 1228 for coding); this Core BPF port omits that step.Reproduction
Submit a
DuplicateBlockProofwithshred1 = S.payload()andshred2 = S.payload()followed by one extra0x00byte. Both carryS's real signature and an identical merkle root, the same slot, index, and shred type.sigverify_shredspasses.is_shred_duplicatereturns true only because the two byte lengths differ, socheck_shredsreturnsOk(())and a violation report is stored against the leader.Verified with a test added to the repo harness (
cargo test, HEADbb6b24c):Non-resigned shreds are produced by the canonical shredder for every FEC set that is not the last in a slot, so this is a normal shred variant, not an edge case.
Fix
Always truncate to
proof_offset + proof_sizeinget_payload, removing the non-resigned early return:For non-resigned shreds
proof_offset + proof_sizeequals the canonical size, so this strips only attacker padding and never drops a real violation. With the fix applied, the padded cases returnInvalidPayloadProofand all 23 pre-existing tests still pass.