Zebra Transparent SIGHASH_SINGLE Corresponding-Output Handling Diverges From zcashdFor V5+ transparent spends, Zebra and zcashd disagree on the same consensus rule: SIGHASH_SINGLE must fail when the input index has no corresponding output. zcashd treats this as consensus-invalid under ZIP-244, while Zebra's transparent verification path computes a digest for the missing-output case instead of failing.
The result is a direct block-validity split. A malformed V5 transparent transaction can be accepted by Zebra, retained in Zebra's mempool, selected into Zebra getblocktemplate, mined into a block, and then rejected by zcashd.
Validated code revisions used during analysis:
zcashd: 2c63e9aa08cb170b0feb374161bea94720c3e1f5Zebra: a905fa19e3a91c7b4ead331e2709e6dec5db12cbScope note:
zcashd side:
TransactionSignatureChecker::CheckSig() and SignatureHash(): zcash/src/script/interpreter.cpp.SignatureHash() explicitly throws when SIGHASH_SINGLE or SIGHASH_SINGLE|ANYONECANPAY is used with nIn >= txTo.vout.size(): zcash/src/script/interpreter.cpp.CheckSig() catches that exception and returns false, causing the transparent script to fail.Zebra side:
zebra/zebra-consensus/src/transaction.rs.Zebra converts the decoded hash type and asks its Rust sighash engine for a digest without adding the corresponding-output pre-check that zcashd enforces first: zebra/zebra-script/src/lib.rs, zebra/zebra-chain/src/primitives/zcash_primitives.rs.Zebra forwards canonical SIGHASH_SINGLE into the Rust ZIP-244 implementation.input.index() >= bundle.vout.len(), the code uses transparent_outputs_hash::<TxOut>(&[]) instead of erroring: zcash_primitives/src/transaction/sighash_v5.rs, zcash_primitives/src/transaction/sighash_v5.rs.Why this is exploitable:
Zebra computes for the missing-output case;Zebra then sees a valid transparent signature, while zcashd never reaches the same digest because it fails first.Ordinary path viability:
zcashd ordinary mempool admission is not the practical trigger path, because the same ZIP-244 SignatureHash() checks fail there first: zcash/src/main.cpp, zcash/src/script/interpreter.cpp.Zebra ordinary mempool admission is viable because Zebra uses the same transparent verifier for mempool and block validation and does not have a separate "one output per input" standardness rule here: zebra/zebra-consensus/src/transaction.rs, zebra/zebrad/src/components/mempool/storage.rs.Zebra is a block-template producer, so the realistic stock path is Zebra mempool -> Zebra getblocktemplate -> external miner: zebra/zebra-rpc/src/methods/types/get_block_template/zip317.rs.Validated commits:
zcashd: 2c63e9aa08cb170b0feb374161bea94720c3e1f5Zebra: a905fa19e3a91c7b4ead331e2709e6dec5db12cbManual reproduction steps:
0 normally.1 with canonical SIGHASH_SINGLE or SIGHASH_SINGLE|ANYONECANPAY.Zebra's ZIP-244 path, where the missing output contributes transparent_outputs_hash([]).Zebra and to zcashd.Zebra accepts it into the mempool;Zebra selects it into getblocktemplate;Zebra can mine and accept a block containing it;zcashd rejects it in the ordinary mempool path.This is a direct V5+ transparent consensus split.
Who can trigger it:
Zebra's mempool and block-template path;Who is impacted:
Zebra can accept and template a transaction / block that zcashd rejects;Zebra block-template safety problem.{
"github_reviewed": true,
"github_reviewed_at": "2026-05-07T21:02:30Z",
"cwe_ids": [
"CWE-573",
"CWE-354"
],
"severity": "CRITICAL",
"nvd_published_at": null
}