From 604d7e8fdd95d22203120ca15d98d89f8668c240 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C5=91rinc?= Date: Sat, 15 Aug 2026 12:10:07 -0700 Subject: [PATCH] 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. --- src/test/util_tests.cpp | 4 ++++ test/functional/feature_notifications.py | 14 ++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/test/util_tests.cpp b/src/test/util_tests.cpp index a2125c4ae1c..2ce1ef6463e 100644 --- a/src/test/util_tests.cpp +++ b/src/test/util_tests.cpp @@ -306,10 +306,14 @@ BOOST_AUTO_TEST_CASE(util_ReplaceAll) }}; test_replaceall(original, "", "foo", original); + test_replaceall(original, "missing", "foo", original); test_replaceall(original, original, "foo", "foo"); test_replaceall(original, "%s", "foo", "A test \"foo\" string 'foo'."); test_replaceall(original, "\"", "foo", "A test foo%sfoo string '%s'."); test_replaceall(original, "'", "foo", "A test \"%s\" string foo%sfoo."); + test_replaceall("a.b", ".", "x", "xxx"); // TODO: Search text must remain literal. + test_replaceall("%w and %w", "%w", "$&$`$'$1$$", "%w and %w$ and %w and $"); // TODO: Replacement bytes must remain literal. + test_replaceall("x", "x", "xx", "xx"); } BOOST_AUTO_TEST_CASE(util_TrimString) diff --git a/test/functional/feature_notifications.py b/test/functional/feature_notifications.py index e983d5a2b83..fb573d3af98 100755 --- a/test/functional/feature_notifications.py +++ b/test/functional/feature_notifications.py @@ -42,6 +42,7 @@ class NotificationsTest(BitcoinTestFramework): 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) @@ -175,6 +176,19 @@ class NotificationsTest(BitcoinTestFramework): 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()