Skip to content

Non-canonical shred padding forges a duplicate-block proof against an innocent leader #101

Description

@latent-9

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions