Files
bitcoin/test/functional/feature_notifications.py
Lőrinc 604d7e8fdd test: characterize walletnotify shell injection
`-walletnotify` shell-escapes wallet names before substituting `%w` into the configured command.
`ReplaceAll()` uses `%w` as the regex pattern and the escaped wallet name as replacement text, where `$'` copies the command suffix into the escaped name and allows its shell metacharacters to alter the command.

Record the command execution, missing notification file, regex pattern matching, replacement expansion, and non-recursive replacement.
2026-09-01 11:46:55 -07:00

256 lines
13 KiB
Python
Executable File

#!/usr/bin/env python3
# Copyright (c) 2014-present The Bitcoin Core developers
# Distributed under the MIT software license, see the accompanying
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
"""Test the -alertnotify, -blocknotify and -walletnotify options."""
import os
import platform
from test_framework.address import ADDRESS_BCRT1_UNSPENDABLE
from test_framework.blocktools import (
create_block,
)
from test_framework.descriptors import descsum_create
from test_framework.extendedkey import ExtendedPrivateKey
from test_framework.test_framework import BitcoinTestFramework
from test_framework.util import (
assert_equal,
)
# Linux allow all characters other than \x00
# Windows disallow control characters (0-31) and /\?%:|"<>
FILE_CHAR_START = 32 if platform.system() == 'Windows' else 1
FILE_CHAR_END = 128
FILE_CHARS_DISALLOWED = '/\\?%*:|"<>' if platform.system() == 'Windows' else '/'
UNCONFIRMED_HASH_STRING = 'unconfirmed'
LARGE_WORK_INVALID_CHAIN_WARNING = (
"Warning: Found invalid chain more than 6 blocks longer than our best chain. This could be due to database corruption or consensus incompatibility with peers."
)
def notify_outputname(walletname, txid):
return txid if platform.system() == 'Windows' else f'{walletname}_{txid}'
def shell_escape_posix(arg):
# Identical to ShellEscape() in the C++ code
return "'" + arg.replace("'", "'\"'\"'") + "'"
class NotificationsTest(BitcoinTestFramework):
def set_test_params(self):
self.num_nodes = 2
self.setup_clean_chain = True
self.uses_wallet = None
self.noban_tx_relay = True
def setup_network(self):
self.wallet = ''.join(chr(i) for i in range(FILE_CHAR_START, FILE_CHAR_END) if chr(i) not in FILE_CHARS_DISALLOWED)
self.alertnotify_dir = os.path.join(self.options.tmpdir, "alertnotify")
self.alertnotify_file = os.path.join(self.alertnotify_dir, "alertnotify.txt")
self.blocknotify_dir = os.path.join(self.options.tmpdir, "blocknotify")
self.walletnotify_dir = os.path.join(self.options.tmpdir, "walletnotify")
self.shutdownnotify_dir = os.path.join(self.options.tmpdir, "shutdownnotify")
self.shutdownnotify_file = os.path.join(self.shutdownnotify_dir, "shutdownnotify.txt")
os.mkdir(self.alertnotify_dir)
os.mkdir(self.blocknotify_dir)
os.mkdir(self.walletnotify_dir)
os.mkdir(self.shutdownnotify_dir)
if platform.system() == 'Windows':
walletnotify_path = f"\"{os.path.join(self.walletnotify_dir, notify_outputname('%w', '%s'))}\""
else:
walletnotify_path = f"{shell_escape_posix(os.path.join(self.walletnotify_dir, ''))}{notify_outputname('%w', '%s')}"
# -alertnotify and -blocknotify on node0, walletnotify on node1
self.extra_args = [[
f"-alertnotify=echo %s >> \"{self.alertnotify_file}\"",
f"-blocknotify=echo > \"{os.path.join(self.blocknotify_dir, '%s')}\"",
f"-shutdownnotify=echo > \"{self.shutdownnotify_file}\"",
], [
f"-walletnotify=echo %h_%b > {walletnotify_path}",
]]
self.wallet_names = [self.default_wallet_name, self.wallet]
super().setup_network()
def run_test(self):
if self.is_wallet_compiled():
# Setup the descriptors to be imported to the wallet
xpriv = ExtendedPrivateKey.generate().to_string()
desc_imports = [{
"desc": descsum_create(f"wpkh({xpriv}/0/*)"),
"timestamp": 0,
"active": True,
"keypool": True,
},{
"desc": descsum_create(f"wpkh({xpriv}/1/*)"),
"timestamp": 0,
"active": True,
"keypool": True,
"internal": True,
}]
# Make the wallets and import the descriptors
# Ensures that node 0 and node 1 share the same wallet for the conflicting transaction tests below.
for i, name in enumerate(self.wallet_names):
self.nodes[i].createwallet(wallet_name=name, blank=True, load_on_startup=True)
self.nodes[i].importdescriptors(desc_imports)
self.log.info("test -blocknotify")
block_count = 10
blocks = self.generatetoaddress(self.nodes[1], block_count, self.nodes[1].getnewaddress() if self.is_wallet_compiled() else ADDRESS_BCRT1_UNSPENDABLE)
# wait at most 10 seconds for expected number of files before reading the content
self.wait_until(lambda: len(os.listdir(self.blocknotify_dir)) == block_count, timeout=10)
# directory content should equal the generated blocks hashes
assert_equal(sorted(blocks), sorted(os.listdir(self.blocknotify_dir)))
if self.is_wallet_compiled():
self.log.info("test -walletnotify")
# wait at most 10 seconds for expected number of files before reading the content
self.wait_until(lambda: len(os.listdir(self.walletnotify_dir)) == block_count, timeout=10)
# directory content should equal the generated transaction hashes
tx_details = list(map(lambda t: (t['txid'], t['blockheight'], t['blockhash']), self.nodes[1].listtransactions("*", block_count)))
self.expect_wallet_notify(tx_details)
self.log.info("test -walletnotify after rescan")
# rescan to force wallet notifications
self.nodes[1].rescanblockchain()
self.wait_until(lambda: len(os.listdir(self.walletnotify_dir)) == block_count, timeout=10)
self.connect_nodes(0, 1)
# directory content should equal the generated transaction hashes
tx_details = list(map(lambda t: (t['txid'], t['blockheight'], t['blockhash']), self.nodes[1].listtransactions("*", block_count)))
self.expect_wallet_notify(tx_details)
# Conflicting transactions tests.
# Generate spends from node 0, and check notifications
# triggered by node 1
self.log.info("test -walletnotify with conflicting transactions")
self.nodes[0].rescanblockchain()
self.generatetoaddress(self.nodes[0], 100, ADDRESS_BCRT1_UNSPENDABLE)
# Generate transaction on node 0, sync mempools, and check for
# notification on node 1.
tx1 = self.nodes[0].sendtoaddress(address=ADDRESS_BCRT1_UNSPENDABLE, amount=1, replaceable=True)
assert_equal(tx1 in self.nodes[0].getrawmempool(), True)
self.sync_mempools()
self.expect_wallet_notify([(tx1, -1, UNCONFIRMED_HASH_STRING)])
# Generate bump transaction, sync mempools, and check for bump1
# notification. In the future, per
# https://github.com/bitcoin/bitcoin/pull/9371, it might be better
# to have notifications for both tx1 and bump1.
bump1 = self.nodes[0].bumpfee(tx1)["txid"]
assert_equal(bump1 in self.nodes[0].getrawmempool(), True)
self.sync_mempools()
self.expect_wallet_notify([(bump1, -1, UNCONFIRMED_HASH_STRING)])
# Add bump1 transaction to new block, checking for a notification
# and the correct number of confirmations.
blockhash1 = self.generatetoaddress(self.nodes[0], 1, ADDRESS_BCRT1_UNSPENDABLE)[0]
blockheight1 = self.nodes[0].getblockcount()
self.sync_blocks()
self.expect_wallet_notify([(bump1, blockheight1, blockhash1)])
assert_equal(self.nodes[1].gettransaction(bump1)["confirmations"], 1)
# Generate a second transaction to be bumped.
tx2 = self.nodes[0].sendtoaddress(address=ADDRESS_BCRT1_UNSPENDABLE, amount=1, replaceable=True)
assert_equal(tx2 in self.nodes[0].getrawmempool(), True)
self.sync_mempools()
self.expect_wallet_notify([(tx2, -1, UNCONFIRMED_HASH_STRING)])
# Bump tx2 as bump2 and generate a block on node 0 while
# disconnected, then reconnect and check for notifications on node 1
# about newly confirmed bump2 and newly conflicted tx2.
self.disconnect_nodes(0, 1)
bump2 = self.nodes[0].bumpfee(tx2)["txid"]
blockhash2 = self.generatetoaddress(self.nodes[0], 1, ADDRESS_BCRT1_UNSPENDABLE, sync_fun=self.no_op)[0]
blockheight2 = self.nodes[0].getblockcount()
assert_equal(self.nodes[0].gettransaction(bump2)["confirmations"], 1)
assert_equal(tx2 in self.nodes[1].getrawmempool(), True)
self.connect_nodes(0, 1)
self.sync_blocks()
self.expect_wallet_notify([(bump2, blockheight2, blockhash2), (tx2, -1, UNCONFIRMED_HASH_STRING)])
assert_equal(self.nodes[1].gettransaction(bump2)["confirmations"], 1)
if platform.system() != 'Windows':
self.log.info("test -walletnotify replacement metacharacters in wallet name")
self.nodes[1].unloadwallet(self.wallet)
command_marker = os.path.join(self.options.tmpdir, "walletnotify_injected")
# The previous regex replacement expanded `$'` to the command suffix, breaking the shell-escaped wallet name's quote accounting
wallet_name = self.nodes[1].createwallet(f"$'$'; echo Pwned > {os.path.basename(command_marker)}; #")["name"]
txid = self.nodes[0].sendtoaddress(self.nodes[1].get_wallet_rpc(wallet_name).getnewaddress(), 1)
self.sync_mempools()
notify_path = os.path.join(self.walletnotify_dir, notify_outputname(wallet_name, txid))
self.wait_until(lambda: os.path.exists(command_marker) or os.path.exists(notify_path), timeout=10)
assert os.path.exists(command_marker) # TODO: Wallet names must not inject shell commands.
assert not os.path.exists(notify_path) # TODO: Wallet names must remain literal in notification paths.
self.log.info("test -alertnotify with large work invalid chain")
# create a bunch of invalid blocks
tip = self.nodes[0].getbestblockhash()
height = self.nodes[0].getblockcount() + 1
block_time = self.nodes[0].getblock(tip)['time'] + 1
invalid_blocks = []
for _ in range(7): # invalid chain must be longer than 6 blocks to trigger warning
block = create_block(int(tip, 16), height=height, ntime=block_time)
# make block invalid by exceeding block subsidy
block.vtx[0].vout[0].nValue += 1
block.hashMerkleRoot = block.calc_merkle_root()
block.solve()
invalid_blocks.append(block)
tip = block.hash_hex
height += 1
block_time += 1
# submit headers of invalid blocks
for invalid_block in invalid_blocks:
self.nodes[0].submitheader(invalid_block.serialize().hex())
# submit invalid blocks in reverse order (tip first, to set m_best_invalid)
for invalid_block in reversed(invalid_blocks):
self.nodes[0].submitblock(invalid_block.serialize().hex())
self.wait_until(lambda: os.path.isfile(self.alertnotify_file), timeout=10)
self.wait_until(self.large_work_invalid_chain_warning_in_alert_file, timeout=10)
self.log.info("test -shutdownnotify")
self.stop_nodes()
self.wait_until(lambda: os.path.isfile(self.shutdownnotify_file), timeout=10)
def large_work_invalid_chain_warning_in_alert_file(self):
with open(self.alertnotify_file, 'r') as f:
alert_text = f.read()
return LARGE_WORK_INVALID_CHAIN_WARNING in alert_text
def expect_wallet_notify(self, tx_details):
self.wait_until(lambda: len(os.listdir(self.walletnotify_dir)) >= len(tx_details), timeout=10)
# Should have no more and no less files than expected
assert_equal(sorted(notify_outputname(self.wallet, tx_id) for tx_id, _, _ in tx_details), sorted(os.listdir(self.walletnotify_dir)))
# Should now verify contents of each file
for tx_id, blockheight, blockhash in tx_details:
fname = os.path.join(self.walletnotify_dir, notify_outputname(self.wallet, tx_id))
# Wait for the cached writes to hit storage
self.wait_until(lambda: os.path.getsize(fname) > 0, timeout=10)
with open(fname, 'rt') as f:
text = f.read()
# Universal newline ensures '\n' on 'nt'
assert_equal(text[-1], '\n')
text = text[:-1]
if platform.system() == 'Windows':
# On Windows, echo as above will append a whitespace
assert_equal(text[-1], ' ')
text = text[:-1]
expected = str(blockheight) + '_' + blockhash
assert_equal(text, expected)
for tx_file in os.listdir(self.walletnotify_dir):
os.remove(os.path.join(self.walletnotify_dir, tx_file))
if __name__ == '__main__':
NotificationsTest(__file__).main()