Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/policy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
90 changes: 90 additions & 0 deletions doc/policy/datacarrier-fakemultisig.md
Original file line number Diff line number Diff line change
@@ -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 <pubkey_1> <pubkey_2> ... <pubkey_15> 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.
2 changes: 2 additions & 0 deletions src/init.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,7 @@ void SetupServerArgs(ArgsManager& argsman, bool can_listen_ipc)
argsman.AddArg("-minrelaytxfee=<amt>", 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),
Expand Down Expand Up @@ -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");
Expand Down
1 change: 1 addition & 0 deletions src/kernel/mempool_options.h
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
2 changes: 2 additions & 0 deletions src/node/mempool_args.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,8 @@ util::Result<void> 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);
Expand Down
42 changes: 42 additions & 0 deletions src/policy/policy.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::vector<unsigned char>> 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:
Expand Down Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions src/policy/policy.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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.
Expand Down
120 changes: 120 additions & 0 deletions test/functional/feature_datacarrier_fakemultisig.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions test/functional/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down