From 7d176d06c61d57c09f84eeb89197bc1477115220 Mon Sep 17 00:00:00 2001 From: Oracle Knots Developer Date: Mon, 28 Sep 2026 16:19:33 -0400 Subject: [PATCH] policy: reject fake 1-of-N multisig data carriers (bpub/bitfiles) Spends that reveal a 1-of-N CHECKMULTISIG (N >= MULTISIG_DATACARRIER_MIN_KEYS) wrapped in P2SH/P2WSH are a common on-chain shape for file-embedding overlays (bitfiles/bpub): 14 of 15 'pubkeys' are payload, only one signs. AreInputsStandard solves the executed script of every P2SH/P2WSH/P2SH-P2WSH input and rejects this shape as datacarrier-fakemultisig, in PreChecks, so it covers relay and block templates. Gated by its own -rejectfakemultisig option (default on, reset by -corepolicy), independent of -permitbaremultisig. Threshold multisig (m >= 2) and 1-of-2 are untouched. The m == 1 shape is a strong but not exclusive signal: a survey of the fork chain rejects all known bpub reveals plus a small number of genuine 1-of-N wallet spends, and an embedder could migrate to m >= 2. Documented as relay/mempool policy (not consensus), complementary to the funding-side dust filter, and reconcilable with a general unproven-key allowance. --- doc/policy/README.md | 1 + doc/policy/datacarrier-fakemultisig.md | 90 +++++++++++++ src/init.cpp | 2 + src/kernel/mempool_options.h | 1 + src/node/mempool_args.cpp | 2 + src/policy/policy.cpp | 42 ++++++ src/policy/policy.h | 11 ++ .../feature_datacarrier_fakemultisig.py | 120 ++++++++++++++++++ test/functional/test_runner.py | 1 + 9 files changed, 270 insertions(+) create mode 100644 doc/policy/datacarrier-fakemultisig.md create mode 100755 test/functional/feature_datacarrier_fakemultisig.py diff --git a/doc/policy/README.md b/doc/policy/README.md index 4392ffb61034..fba4d1fad89e 100644 --- a/doc/policy/README.md +++ b/doc/policy/README.md @@ -9,6 +9,7 @@ contents. Policy is *not* applied to transactions in blocks. This documentation is not an exhaustive list of all policy rules. +- [Fake Multisig Data Carriers](datacarrier-fakemultisig.md) - [Mempool Limits](mempool-limits.md) - [Mempool Replacements](mempool-replacements.md) - [Packages](packages.md) diff --git a/doc/policy/datacarrier-fakemultisig.md b/doc/policy/datacarrier-fakemultisig.md new file mode 100644 index 000000000000..6ab4ad24d6db --- /dev/null +++ b/doc/policy/datacarrier-fakemultisig.md @@ -0,0 +1,90 @@ +# Fake multisig data carriers + +## Summary + +Spends that reveal a **1-of-N `CHECKMULTISIG` script** (with N ≥ 3) wrapped in +P2SH or P2WSH are treated as data carriers and are non-standard when +`-rejectfakemultisig` is set (the default). The rule has its own option so it is +independent of `-permitbaremultisig`, and it is reset by `-corepolicy`. It has +no effect on real threshold multisig (2-of-3, 3-of-5, …) or on 1-of-2 "either +party" scripts. + +## Background + +Data-embedding services such as [bitfiles](https://bitfiles.io/) (via the +[`bpub`](https://github.com/bitfiles-io/bpub) library) store arbitrary files +on-chain by hiding the payload inside the "public keys" of a bare multisig +script: + +* A **funding** transaction creates many small (dust) P2WSH outputs. At this + point the outputs are ordinary P2WSH and are indistinguishable from a real + wallet's — there is nothing to filter. +* A **reveal** transaction later spends those outputs. Each input's witness + exposes a redeem script of the form + + ``` + OP_1 ... OP_15 OP_CHECKMULTISIG + ``` + + where 14 of the 15 "public keys" are 33-byte chunks of file data (≈31 payload + bytes each) and only one is a real signer. Because the script is a 1-of-N, + the other keys are never checked against a signature. + +Each fake key includes a brute-forced trailing "ground nonce" byte so that its +x-coordinate lands on the secp256k1 curve. A naïve "reject keys that are not +valid curve points" filter therefore does **not** catch it. + +The reveal transaction is otherwise perfectly standard: the witnessScript is +under `MAX_STANDARD_P2WSH_SCRIPT_SIZE`, every other witness item is under +`MAX_STANDARD_P2WSH_STACK_ITEM_SIZE`, and the wrapping P2WSH bypasses +`-permitbaremultisig`, which only inspects output scripts. This is why the +existing datacarrier, parasite and bare-multisig filters miss it. + +## The rule + +The signal is the **shape** of the revealed script rather than the content of +its keys. A 1-of-N multisig lets any single one of its N keys spend the output, +which is strictly weaker than a plain single-key output — nobody constructs one +for security. Once N is 3 or more, the most plausible reason to pay for the +extra keys is to carry data. + +`AreInputsStandard` extracts the executed script for every P2SH, P2WSH and +P2SH-P2WSH input (via `GetScriptForTransactionInput`) and, when +`-rejectfakemultisig` is set, rejects the transaction if that script solves to a +multisig with `m == 1` and `n >= MULTISIG_DATACARRIER_MIN_KEYS` (3). The +rejection reason is `datacarrier-fakemultisig`. + +Because the check runs in `AreInputsStandard` (part of `PreChecks`), it fires +before signature validation and applies to relay and to block-template +construction, so a node running this policy neither relays nor mines these +reveals. + +## What is *not* affected + +* Threshold multisig (`m >= 2`): 2-of-3, 3-of-5, 4-of-7, m-of-15, etc. +* 1-of-2 multisig (below the N threshold). +* The funding transactions themselves (ordinary P2WSH outputs). These are not + distinguishable in isolation, but their *shape* — a long run of identical + dust P2WSH outputs — is filtered on the funding side by the parasite/dust + policy (`#389`), which is complementary to this reveal-side rule. + +## Limitations + +* This is **relay/mempool policy**, not consensus. It stops a node from + relaying or mining these transactions, but a node still accepts a valid block + that contains one. On a network where a filtering node produces the block + templates, that is enough to keep the reveals out of blocks; where a + non-filtering miner includes them, only a consensus rule would prevent + confirmation. +* **The `m == 1`, `n >= 3` shape is not exclusive to data carriers.** A survey + of the fork chain (blocks 961640–974573) found the rule rejects the 19 known + bpub reveals but also ~105 spends of genuine 1-of-3…1-of-8 wallet scripts + (some reused across many transactions, which bpub never does). The shape is a + strong but not perfect signal; operators who need those spends can disable it + with `-rejectfakemultisig=0` (or `-corepolicy`). +* An embedder could migrate to `m >= 2` to dodge the `m == 1` test, at the cost + of a second real key and signature per input, and this rule would no longer + see it. A key-counting policy that bounds the number of *unproven* keys + regardless of `m` (`#422`) generalises this rule; the `m == 1` shape is the + floor of that allowance. The threshold constant is kept in + `src/policy/policy.h` so the two can be reconciled. diff --git a/src/init.cpp b/src/init.cpp index 50c4d691bf67..a0992df7784a 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -767,6 +767,7 @@ void SetupServerArgs(ArgsManager& argsman, bool can_listen_ipc) argsman.AddArg("-minrelaytxfee=", strprintf("Fees (in %s/kvB) smaller than this are considered zero fee for relaying, mining and transaction creation (default: %s)", CURRENCY_UNIT, FormatMoney(DEFAULT_MIN_RELAY_TX_FEE)), ArgsManager::ALLOW_ANY, OptionsCategory::NODE_RELAY); argsman.AddArg("-rejectparasites", strprintf("Refuse to relay or mine parasitic overlay protocols (default: %u)", DEFAULT_REJECT_PARASITES), ArgsManager::ALLOW_ANY, OptionsCategory::NODE_RELAY); + argsman.AddArg("-rejectfakemultisig", strprintf("Refuse to relay or mine spends revealing a 1-of-N (N>=%u) multisig data carrier wrapped in P2SH/P2WSH (bpub/bitfiles). Threshold multisig is not affected (default: %u)", MULTISIG_DATACARRIER_MIN_KEYS, DEFAULT_REJECT_FAKEMULTISIG), ArgsManager::ALLOW_ANY, OptionsCategory::NODE_RELAY); argsman.AddArg("-rejecttokens", strprintf("Refuse to relay or mine transactions involving non-bitcoin tokens (default: %u)", DEFAULT_REJECT_TOKENS), @@ -874,6 +875,7 @@ void InitParameterInteraction(ArgsManager& args) args.SoftSetArg("-permitbarepubkey", "1"); args.SoftSetArg("-permitbaremultisig", "1"); args.SoftSetArg("-rejectparasites", "0"); + args.SoftSetArg("-rejectfakemultisig", "0"); args.SoftSetArg("-rejecttokens", "0"); args.SoftSetArg("-subdustfeepenalty", "0"); args.SoftSetArg("-datacarriercost", "0.25"); diff --git a/src/kernel/mempool_options.h b/src/kernel/mempool_options.h index 0ce9ac96b1e5..d1d139698f95 100644 --- a/src/kernel/mempool_options.h +++ b/src/kernel/mempool_options.h @@ -85,6 +85,7 @@ struct MemPoolOptions { bool permit_bare_pubkey{DEFAULT_PERMIT_BAREPUBKEY}; bool permit_bare_multisig{DEFAULT_PERMIT_BAREMULTISIG}; bool reject_parasites{DEFAULT_REJECT_PARASITES}; + bool reject_fakemultisig{DEFAULT_REJECT_FAKEMULTISIG}; bool reject_tokens{DEFAULT_REJECT_TOKENS}; bool subdustfeepenalty{DEFAULT_SUBDUSTFEEPENALTY}; bool accept_non_std_datacarrier{DEFAULT_ACCEPT_NON_STD_DATACARRIER}; diff --git a/src/node/mempool_args.cpp b/src/node/mempool_args.cpp index d74d74f548ba..5fa87fc1a35f 100644 --- a/src/node/mempool_args.cpp +++ b/src/node/mempool_args.cpp @@ -203,6 +203,8 @@ util::Result ApplyArgsManOptions(const ArgsManager& argsman, const CChainP mempool_opts.reject_parasites = argsman.GetBoolArg("-rejectparasites", DEFAULT_REJECT_PARASITES); + mempool_opts.reject_fakemultisig = argsman.GetBoolArg("-rejectfakemultisig", DEFAULT_REJECT_FAKEMULTISIG); + mempool_opts.reject_tokens = argsman.GetBoolArg("-rejecttokens", DEFAULT_REJECT_TOKENS); mempool_opts.subdustfeepenalty = argsman.GetBoolArg("-subdustfeepenalty", DEFAULT_SUBDUSTFEEPENALTY); diff --git a/src/policy/policy.cpp b/src/policy/policy.cpp index 679c0c967a1f..c775b5e0928c 100644 --- a/src/policy/policy.cpp +++ b/src/policy/policy.cpp @@ -340,6 +340,34 @@ static bool CheckSigopsBIP54(const CTransaction& tx, const CCoinsViewCache& inpu return true; } +/** + * Detect a bare multisig template being (ab)used as a data carrier. + * + * Data-embedding services such as bitfiles (via its bpub library) hide payload + * bytes inside the "public keys" of a 1-of-N CHECKMULTISIG script, then wrap it + * in P2SH or P2WSH so the script is invisible at funding time and only lands + * on-chain, in the witness/redeem, when the dust output is spent (the "reveal" + * transaction). Each fake key carries ~31 bytes of data plus a brute-forced + * nonce byte that keeps the encoded point on the secp256k1 curve, so a + * validity check on the keys does not catch it. Only one of the N keys is ever + * a real signer. + * + * The reliable, false-positive-free signature is the shape itself: a 1-of-N + * multisig with N >= MULTISIG_DATACARRIER_MIN_KEYS. Such a script has no + * legitimate custody use, because any single one of the N keys can spend it, + * making it strictly weaker than a plain single-key output — nobody builds one + * for security. Threshold multisig (m >= 2, e.g. 2-of-3, 3-of-5, 4-of-7) is + * never matched and continues to relay normally. + */ +static bool IsDatacarrierMultisig(const CScript& script) +{ + std::vector> solutions; + if (Solver(script, solutions) != TxoutType::MULTISIG) return false; + const unsigned int required{solutions.front()[0]}; + const unsigned int total{solutions.back()[0]}; + return required == 1 && total >= MULTISIG_DATACARRIER_MIN_KEYS; +} + /** * Check transaction inputs to mitigate two * potential denial-of-service attacks: @@ -416,6 +444,20 @@ bool AreInputsStandard(const CTransaction& tx, const CCoinsViewCache& mapInputs, MaybeReject("scriptcheck-sigops"); } } + + // Reject bare multisig used as a data carrier, wrapped in P2SH or P2WSH + // (see IsDatacarrierMultisig). This targets the wrapped forms that make + // the script visible only in the redeem/witness of the reveal + // transaction. Gated by its own -rejectfakemultisig option (default on, + // reset by -corepolicy) so it is independent of -permitbaremultisig. + // Threshold multisig is not affected. + if (opts.reject_fakemultisig && + (whichType == TxoutType::SCRIPTHASH || whichType == TxoutType::WITNESS_V0_SCRIPTHASH)) { + const CScript exec_script{GetScriptForTransactionInput(prev.scriptPubKey, tx.vin[i]).first}; + if (IsDatacarrierMultisig(exec_script)) { + MaybeReject("datacarrier-fakemultisig"); + } + } } return true; diff --git a/src/policy/policy.h b/src/policy/policy.h index 954c79f4bd17..3d266f684298 100644 --- a/src/policy/policy.h +++ b/src/policy/policy.h @@ -85,6 +85,8 @@ static constexpr bool DEFAULT_PERMIT_BAREPUBKEY{false}; static constexpr bool DEFAULT_PERMIT_BAREMULTISIG{false}; /** Default for -rejectparasites */ static constexpr bool DEFAULT_REJECT_PARASITES{true}; +/** Default for -rejectfakemultisig */ +static constexpr bool DEFAULT_REJECT_FAKEMULTISIG{true}; /** The maximum number of witness stack items in a standard P2WSH script */ static constexpr unsigned int MAX_STANDARD_P2WSH_STACK_ITEMS{100}; /** The maximum size in bytes of each witness stack item in a standard P2WSH script */ @@ -93,6 +95,15 @@ static constexpr unsigned int MAX_STANDARD_P2WSH_STACK_ITEM_SIZE{80}; static constexpr unsigned int MAX_STANDARD_TAPSCRIPT_STACK_ITEM_SIZE{80}; /** The maximum size in bytes of a standard witnessScript */ static constexpr unsigned int MAX_STANDARD_P2WSH_SCRIPT_SIZE{3600}; +/** Minimum number of keys in a 1-of-N CHECKMULTISIG for it to be treated as a + * data carrier rather than a spend script. Data-embedding tools (e.g. + * bitfiles/bpub) hide their payload in the "public keys" of a 1-of-N multisig + * wrapped in P2SH/P2WSH; only one key is ever a real signer. A 1-of-N multisig + * with N >= this value has no legitimate custody use (any single key spends, so + * it is strictly weaker than a plain single-key output) and is rejected as a + * data carrier when -rejectfakemultisig is set. Threshold multisig + * (m >= 2, e.g. 2-of-3, 3-of-5) is never affected. */ +static constexpr unsigned int MULTISIG_DATACARRIER_MIN_KEYS{3}; /** The maximum size of a standard ScriptSig */ static constexpr unsigned int MAX_STANDARD_SCRIPTSIG_SIZE{1650}; /** Min feerate for defining dust. diff --git a/test/functional/feature_datacarrier_fakemultisig.py b/test/functional/feature_datacarrier_fakemultisig.py new file mode 100755 index 000000000000..04f983dcc282 --- /dev/null +++ b/test/functional/feature_datacarrier_fakemultisig.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Bitcoin Knots developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Test rejection of fake 1-of-N multisig data carriers (bpub/bitfiles). + +Data-embedding services such as bitfiles (via the bpub library) hide payload +bytes inside the "public keys" of a 1-of-N CHECKMULTISIG script, wrapped in +P2WSH (or P2SH-P2WSH) so the script only appears on-chain, in the witness/redeem, +when the dust funding output is spent (the "reveal" tx). Only one of the N keys +is ever a real signer; the rest are pure data. + +With -rejectfakemultisig set (the default), a revealed 1-of-N multisig with +N >= MULTISIG_DATACARRIER_MIN_KEYS (3) is rejected as a data carrier. Threshold +multisig (m >= 2) and small 1-of-2 multisig are unaffected. + +The policy check runs in PreChecks (AreInputsStandard), before script/signature +verification, so these tests fund a real P2WSH/P2SH output and then submit a +reveal transaction with a dummy signature: the data-carrier rejection fires +first, which is exactly what we assert. +""" +from hashlib import sha256 + +from test_framework.messages import ( + COutPoint, + CTransaction, + CTxIn, + CTxInWitness, + CTxOut, +) +from test_framework.script import CScript, OP_0 +from test_framework.script_util import ( + keys_to_multisig_script, + key_to_p2wpkh_script, + script_to_p2wsh_script, + script_to_p2sh_p2wsh_script, +) +from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import assert_equal +from test_framework.wallet import MiniWallet + +from random import randbytes + +REJECT = "datacarrier-fakemultisig" + + +def fake_pubkey(): + """A 33-byte compressed-looking pubkey full of payload bytes.""" + return bytes([0x02 + (randbytes(1)[0] & 1)]) + randbytes(32) + + +class DatacarrierFakeMultisigTest(BitcoinTestFramework): + def set_test_params(self): + self.num_nodes = 1 + # -rejectfakemultisig defaults on; set it explicitly so the test is + # independent of any -corepolicy profile that might reset it. + self.extra_args = [["-rejectfakemultisig=1"]] + + def build_reveal(self, funding, witness_script, *, p2sh_p2wsh=False): + """Build a reveal tx spending `funding` with a dummy single-sig witness.""" + tx = CTransaction() + tx.vin = [CTxIn(COutPoint(int(funding["txid"], 16), funding["sent_vout"]))] + tx.vout = [CTxOut(funding["value"] - 1000, key_to_p2wpkh_script(fake_pubkey()))] + tx.wit.vtxinwit = [CTxInWitness()] + # CHECKMULTISIG dummy element, one (dummy) signature, then the script. + tx.wit.vtxinwit[0].scriptWitness.stack = [b"", b"\x00" * 72, bytes(witness_script)] + if p2sh_p2wsh: + # scriptSig pushes the P2WSH program (the P2SH redeemScript). + program = CScript([OP_0, sha256(bytes(witness_script)).digest()]) + tx.vin[0].scriptSig = CScript([bytes(program)]) + return tx + + def fund(self, m, n, *, p2sh_p2wsh=False): + """Create and confirm a P2WSH / P2SH-P2WSH output over a fake m-of-n multisig.""" + ws = keys_to_multisig_script([fake_pubkey() for _ in range(n)], k=m) + spk = script_to_p2sh_p2wsh_script(ws) if p2sh_p2wsh else script_to_p2wsh_script(ws) + funding = self.wallet.send_to(from_node=self.nodes[0], scriptPubKey=spk, amount=100_000) + funding["value"] = 100_000 + self.generate(self.nodes[0], 1) # confirm + sync to node1 + return funding, ws + + def check(self, node, m, n, *, p2sh_p2wsh=False): + funding, ws = self.fund(m, n, p2sh_p2wsh=p2sh_p2wsh) + reveal = self.build_reveal(funding, ws, p2sh_p2wsh=p2sh_p2wsh) + return node.testmempoolaccept([reveal.serialize().hex()])[0] + + def run_test(self): + self.wallet = MiniWallet(self.nodes[0]) + node = self.nodes[0] + + self.log.info("1-of-15 P2WSH reveal (the real bpub shape) is rejected") + res = self.check(node, 1, 15) + assert_equal(res["allowed"], False) + assert res["reject-reason"].endswith(REJECT), res["reject-reason"] + + self.log.info("1-of-3 P2WSH reveal (threshold boundary) is rejected") + res = self.check(node, 1, 3) + assert_equal(res["allowed"], False) + assert res["reject-reason"].endswith(REJECT), res["reject-reason"] + + self.log.info("1-of-15 P2SH-P2WSH reveal is also rejected") + res = self.check(node, 1, 15, p2sh_p2wsh=True) + assert_equal(res["allowed"], False) + assert res["reject-reason"].endswith(REJECT), res["reject-reason"] + + self.log.info("1-of-2 P2WSH is below threshold -> not a data-carrier rejection") + res = self.check(node, 1, 2) + assert not res["reject-reason"].endswith(REJECT), res["reject-reason"] + + self.log.info("2-of-3 threshold multisig -> not a data-carrier rejection") + res = self.check(node, 2, 3) + assert not res["reject-reason"].endswith(REJECT), res["reject-reason"] + + self.log.info("3-of-5 threshold multisig -> not a data-carrier rejection") + res = self.check(node, 3, 5) + assert not res["reject-reason"].endswith(REJECT), res["reject-reason"] + + +if __name__ == "__main__": + DatacarrierFakeMultisigTest(__file__).main() diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index 337d66721045..f6536721d41b 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -425,6 +425,7 @@ 'feature_logging.py', 'feature_anchors.py', 'mempool_datacarrier.py', + 'feature_datacarrier_fakemultisig.py', 'feature_coinstatsindex.py', 'wallet_orphanedreward.py', 'wallet_timelock.py',