util: keep wallet names literal in notification commands #36048

pull l0rinc wants to merge 5 commits into bitcoin:master from l0rinc:l0rinc/walletnotify-literal-replacement changing 5 files +51 −13
  1. l0rinc commented at 8:30 PM on August 20, 2026: contributor

    Problem: On non-Windows builds, operators can configure -walletnotify to run a command for wallet transactions, with %w replaced by the shell-escaped wallet name. An authenticated RPC caller allowed to create wallets can supply a name containing $', request an address, and send a transaction to it. While replacing %w, ReplaceAll() passes the escaped wallet name to std::regex_replace() as replacement text. There, $' copies the command suffix into the escaped name, breaking its quote accounting and allowing shell metacharacters in the wallet name to alter the command. runCommand() passes the result to system(), so a suitable command template could execute additional shell commands as the node process account. It is not reachable over P2P or by an unauthenticated network peer. #25803 introduced this behavior in v24 when it replaced Boost's literal substitution with std::regex_replace().

    Fix: Restore the literal, non-recursive contract ReplaceAll() had before #25803, matching every current caller's literal search and replacement text, while the wallet notification test covers a wallet name containing $'.

    Related: #35833 restricts control characters in new wallet names, while this change fixes replacement metacharacters in ReplaceAll().

    This was found and disclosed responsibly by the Red Team 🟥.

  2. DrahtBot added the label Utils/log/libs on Aug 20, 2026
  3. DrahtBot commented at 8:31 PM on August 20, 2026: contributor

    <!--e57a25ab6845829454e8d69fc972939a-->

    The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

    <!--006a51241073e994b41acfe9ec718e94-->

    Code Coverage & Benchmarks

    For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/36048.

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

    See the guideline and AI policy for information on the review process.

    Type Reviewers
    ACK maflcko, jeanpablojp, stickies-v
    Concept ACK achow101
    Stale ACK Sjors

    If your review is incorrectly listed, please copy-paste <code>&lt;!--meta-tag:bot-skip--&gt;</code> into the comment that the bot should ignore.

    <!--5faf32d7da4f0f540f40219e4f7537a3-->

  4. achow101 commented at 9:08 PM on August 20, 2026: member

    Concept ACK

  5. jeanpablojp commented at 11:54 PM on August 21, 2026: contributor

    tACK c88468d2aea8c7225d7ba724ccc461ea788713ef

    I went through the ReplaceAll call sites and reproduced the -walletnotify regression.

  6. DrahtBot requested review from achow101 on Aug 21, 2026
  7. Sjors commented at 6:02 PM on August 24, 2026: member

    Concept ACK, particularly for util: make ReplaceAll literal.

  8. in src/util/string.cpp:27 in 526053e038 outdated
      24 | +    for (; pos != std::string::npos; pos = in_out.find(search, start)) {
      25 | +        result.append(in_out, start, pos - start).append(substitute);
      26 | +        start = pos + search.size();
      27 | +    }
      28 | +    result.append(in_out, start);
      29 | +    in_out.swap(result);
    


    achow101 commented at 10:09 PM on August 24, 2026:

    In 526053e0383b80e28b5c11976b65513926f71897 "util: make ReplaceAll literal"

    This can be simplified by using std::string::replace:

        for (; pos != std::string::npos; pos = in_out.find(search, pos + substitute.size())) {
            in_out.replace(pos, search.size(), substitute);
        }
    

    l0rinc commented at 11:26 PM on August 26, 2026:

    Thanks for the review, I deliberately avoided std::string::replace() because each call shifts the remaining suffix before the next match is searched (assuming different sizes). The result builder (this PR) reads from the original string and constructs the output once, avoiding those repeated suffix copies.

    I benchmarked both implementations (see code below, lower is better).

    Grow: 2-byte search → 32-byte substitute
    result builder (PR)  ██▒░░░░░░░░░░░░░░░░░  14.62 ns/replacement
    in-place replace     ████████████████████  127.71 ns/replacement  (+773.5%, 8.74x slower)
    
    Same size: 2-byte search → 2-byte substitute
    result builder (PR)  ████████████████████  12.08 ns/replacement
    in-place replace     █████████████████░░░  10.33 ns/replacement   (-14.5%)
    
    Shrink: 2-byte search → 1-byte substitute
    result builder (PR)  █▓░░░░░░░░░░░░░░░░░░  11.40 ns/replacement
    in-place replace     ████████████████████  120.06 ns/replacement  (+953.2%, 10.53x slower)
    

    The result builder is basically the same speed regardless of the replacement type, while the in-place version is 8.74x slower when replacements grow and 10.53x slower when they shrink. I would therefore prefer to keep the result builder for this general helper.

    Note that the equal-size row is only a control and is not relevant in this particular case, but it's a general helper method, so it should be considered.

    <details> <summary>Benchmark code and raw results</summary>

    // Copyright (c) 2026-present The Bitcoin Core developers
    // Distributed under the MIT software license, see the accompanying
    // file COPYING or https://opensource.org/license/mit/.
    
    #include <bench/bench.h>
    
    #include <cstddef>
    #include <stdexcept>
    #include <string>
    #include <string_view>
    
    namespace {
    
    constexpr size_t NUM_REPLACEMENTS{8192};
    constexpr std::string_view SEARCH{"%w"};
    constexpr std::string_view GROWING_SUBSTITUTE{"0123456789abcdef0123456789abcdef"};
    constexpr std::string_view SAME_SIZE_SUBSTITUTE{"xx"};
    constexpr std::string_view SHRINKING_SUBSTITUTE{"x"};
    
    void ReplaceAllBuilder(std::string& in_out, std::string_view search, std::string_view substitute)
    {
        if (search.empty()) return;
        auto pos{in_out.find(search)};
        if (pos == std::string::npos) return;
    
        std::string result;
        result.reserve(in_out.size());
        std::string::size_type start{0};
        for (; pos != std::string::npos; pos = in_out.find(search, start)) {
            result.append(in_out, start, pos - start).append(substitute);
            start = pos + search.size();
        }
        result.append(in_out, start);
        in_out.swap(result);
    }
    
    void ReplaceAllInPlace(std::string& in_out, std::string_view search, std::string_view substitute)
    {
        if (search.empty()) return;
        auto pos{in_out.find(search)};
        if (pos == std::string::npos) return;
        for (; pos != std::string::npos; pos = in_out.find(search, pos + substitute.size())) {
            in_out.replace(pos, search.size(), substitute);
        }
    }
    
    using ReplaceAllFn = void (*)(std::string&, std::string_view, std::string_view);
    
    void BenchReplaceAll(benchmark::Bench& bench, ReplaceAllFn replace_all, std::string_view substitute)
    {
        std::string input;
        input.reserve(NUM_REPLACEMENTS * SEARCH.size());
        std::string expected;
        expected.reserve(NUM_REPLACEMENTS * substitute.size());
        for (size_t i{0}; i < NUM_REPLACEMENTS; ++i) {
            input.append(SEARCH);
            expected.append(substitute);
        }
        auto result{input};
        replace_all(result, SEARCH, substitute);
        if (result != expected) throw std::runtime_error{"Unexpected ReplaceAll result"};
    
        bench.batch(NUM_REPLACEMENTS).unit("replacement").run([&] {
            auto value{input};
            replace_all(value, SEARCH, substitute);
            ankerl::nanobench::doNotOptimizeAway(value);
        });
    }
    
    void ReplaceAllBuilderGrow(benchmark::Bench& bench) { BenchReplaceAll(bench, ReplaceAllBuilder, GROWING_SUBSTITUTE); }
    void ReplaceAllInPlaceGrow(benchmark::Bench& bench) { BenchReplaceAll(bench, ReplaceAllInPlace, GROWING_SUBSTITUTE); }
    void ReplaceAllBuilderSameSize(benchmark::Bench& bench) { BenchReplaceAll(bench, ReplaceAllBuilder, SAME_SIZE_SUBSTITUTE); }
    void ReplaceAllInPlaceSameSize(benchmark::Bench& bench) { BenchReplaceAll(bench, ReplaceAllInPlace, SAME_SIZE_SUBSTITUTE); }
    void ReplaceAllBuilderShrink(benchmark::Bench& bench) { BenchReplaceAll(bench, ReplaceAllBuilder, SHRINKING_SUBSTITUTE); }
    void ReplaceAllInPlaceShrink(benchmark::Bench& bench) { BenchReplaceAll(bench, ReplaceAllInPlace, SHRINKING_SUBSTITUTE); }
    
    } // namespace
    
    BENCHMARK(ReplaceAllBuilderGrow);
    BENCHMARK(ReplaceAllInPlaceGrow);
    BENCHMARK(ReplaceAllBuilderSameSize);
    BENCHMARK(ReplaceAllInPlaceSameSize);
    BENCHMARK(ReplaceAllBuilderShrink);
    BENCHMARK(ReplaceAllInPlaceShrink);
    

    AppleClang 21, arm64, Release:

    $ build-bench/bin/bench_bitcoin -filter='ReplaceAll.*' -min-time=5000
    
    |      ns/replacement |       replacement/s |    err% |     total | benchmark
    |--------------------:|--------------------:|--------:|----------:|:----------
    |               14.62 |       68,412,155.97 |    3.5% |      5.65 | `ReplaceAllBuilderGrow`
    |               12.08 |       82,773,428.57 |    1.8% |      5.57 | `ReplaceAllBuilderSameSize`
    |               11.40 |       87,704,897.85 |    4.7% |      5.17 | `ReplaceAllBuilderShrink`
    |              127.71 |        7,830,201.42 |    1.5% |      5.65 | `ReplaceAllInPlaceGrow`
    |               10.33 |       96,762,552.16 |    1.9% |      5.37 | `ReplaceAllInPlaceSameSize`
    |              120.06 |        8,329,402.21 |    1.1% |      5.55 | `ReplaceAllInPlaceShrink`
    

    </details>


    Sjors commented at 9:34 AM on August 27, 2026:

    Would be good to add a code comment that it's intentionally not using std::string::replace().


    l0rinc commented at 5:25 PM on August 27, 2026:

    Thanks, rebased and added a code comment

  9. DrahtBot requested review from achow101 on Aug 24, 2026
  10. l0rinc force-pushed on Aug 27, 2026
  11. in src/util/string.h:101 in 6c3c1bb8af
      97 | @@ -98,7 +98,8 @@ struct ConstevalFormatString {
      98 |      consteval ConstevalFormatString(const char* str) : fmt{str} { detail::CheckNumFormatSpecifiers<num_params>(fmt); }
      99 |  };
     100 |  
     101 | -void ReplaceAll(std::string& in_out, const std::string& search, const std::string& substitute);
     102 | +/** Replace every non-overlapping occurrence of `search` with `substitute`, treating both literally; the replacement text is not searched again. */
    


    maflcko commented at 5:00 PM on August 28, 2026:
    /// Replace every non-overlapping occurrence of `search` with `substitute`, treating both literally; the replacement text is not searched again.
    

    nano style nit in the third commit: Doesn't matter, but I personally prefer the /// doxygen comments for new code, because (as seen in this file), the /** comments are inconsistent and also longer. But just a nit.


    l0rinc commented at 5:24 PM on August 28, 2026:

    Thank you, I'll take it if I need to push again


    l0rinc commented at 4:49 PM on August 31, 2026:

    Changed to the shorter /// Doxygen form.

  12. maflcko commented at 5:10 PM on August 28, 2026: member

    Heh, nice find and nice fix.

    review ACK 6c3c1bb8af5a4e085b5174bacb65393ff4a80fe1 🍄

    <details><summary>Show signature</summary>

    Signature:

    untrusted comment: signature from minisign secret key on empty file; verify via: minisign -Vm "${path_to_any_empty_file}" -P RWTRmVTMeKV5noAMqVlsMugDDCyyTSbA3Re5AkUrhvLVln0tSaFWglOw -x "${path_to_this_whole_four_line_signature_blob}"
    RUTRmVTMeKV5npGrKx1nqXCw5zeVHdtdYURB/KlyA/LMFgpNCs+SkW9a8N95d+U4AP1RJMi+krxU1A3Yux4bpwZNLvVBKy0wLgM=
    trusted comment: review ACK 6c3c1bb8af5a4e085b5174bacb65393ff4a80fe1 🍄
    DghgOpD23iWVzqHcI+JdSnJbBIbdltG+oAWYy3t1KkwNSPp9JbMeor+Cv5p3VuDevQ1J8akoCtLhFWpQF/ZWCQ==
    

    </details>

    TIL about $' (python and sed don't have that), so I reconstructed the corrupt notify path and the unit test results manually. For reference, the corrupt file looks like this (on the second commit):

    diff --git a/test/functional/feature_notifications.py b/test/functional/feature_notifications.py
    index c90312f0cf..220fe87e42 100755
    --- a/test/functional/feature_notifications.py
    +++ b/test/functional/feature_notifications.py
    @@ -186,2 +186,4 @@ class NotificationsTest(BitcoinTestFramework):
                     self.wait_until(lambda: os.path.exists(command_marker) or os.path.exists(notify_path), timeout=10)
    +                corrupt_notify_path = os.path.join(self.walletnotify_dir, f'_{txid}"\'_{txid}"')
    +                assert os.path.exists(corrupt_notify_path)
                     assert os.path.exists(command_marker)  # TODO: Wallet names must not inject shell commands.
    
  13. DrahtBot requested review from jeanpablojp on Aug 28, 2026
  14. DrahtBot requested review from Sjors on Aug 28, 2026
  15. maflcko commented at 5:10 PM on August 28, 2026: member

    .

  16. maflcko added this to the milestone 32.0 on Aug 29, 2026
  17. jeanpablojp commented at 9:36 PM on August 29, 2026: contributor

    re-ACK 6c3c1bb8af5a4e085b5174bacb65393ff4a80fe1

  18. in src/test/util_tests.cpp:303 in 6c3c1bb8af outdated
     299 | @@ -300,7 +300,7 @@ BOOST_AUTO_TEST_CASE(util_Join)
     300 |  BOOST_AUTO_TEST_CASE(util_ReplaceAll)
     301 |  {
     302 |      const std::string original("A test \"%s\" string '%s'.");
     303 | -    auto test_replaceall{[](std::string test, const std::string& search, const std::string& substitute, const std::string& expected) {
     304 | +    auto test_replaceall{[](std::string test, std::string_view search, std::string_view substitute, std::string_view expected) {
    


    Sjors commented at 9:59 AM on August 31, 2026:

    In 6c3c1bb8af5a4e085b5174bacb65393ff4a80fe1 refactor: use string views in ReplaceAll: the implementation is safe even when the search and substitute string views are related. Could document that in a test, so hopefully some tooling catches regressions:

    diff --git a/src/test/util_tests.cpp b/src/test/util_tests.cpp
    index 413236f455..ae148c5a24 100644
    --- a/src/test/util_tests.cpp
    +++ b/src/test/util_tests.cpp
    @@ -315,4 +315,10 @@ BOOST_AUTO_TEST_CASE(util_ReplaceAll)
         test_replaceall("%w and %w", "%w", "$&$`$'$1$$", "$&$`$'$1$$ and $&$`$'$1$$");
         test_replaceall("x", "x", "xx", "xx");
    +
    +    std::string test{"abcabc"};
    +    const std::string_view search{test.data(), 3}; // "abc"
    +    const std::string_view substitute{test.data() + 1, 2}; // "bc"
    +    ReplaceAll(test, search, substitute);
    +    BOOST_CHECK_EQUAL(test, "bcbc");
     }
    

    maflcko commented at 10:45 AM on August 31, 2026:

    the implementation is safe even when the search and substitute string views are related.

    I don't think it is, because C++ has no borrow checker. The codebase has repeated calls to ReplaceAll, so one could imagine UB, like:

    diff --git a/src/test/util_tests.cpp b/src/test/util_tests.cpp
    index 413236f455..7d9d9efa60 100644
    --- a/src/test/util_tests.cpp
    +++ b/src/test/util_tests.cpp
    @@ -316,2 +316,13 @@ BOOST_AUTO_TEST_CASE(util_ReplaceAll)
         test_replaceall("x", "x", "xx", "xx");
    +
    +    std::string test{"abcabc"};
    +    test.append(64, 'x'); // Keep the string above the small-string optimization threshold.
    +    const std::string_view test_view{test};
    +    const std::string_view search{test_view.substr(1, 1)}; // "b"
    +    const std::string_view substitute{test_view.substr(1, 2)}; // "bc"
    +    ReplaceAll(test, search, substitute);
    +    ReplaceAll(test, search, substitute); // Deliberately reuse views invalidated by the first call.
    +    std::string expected{"abcccabccc"};
    +    expected.append(64, 'x');
    +    BOOST_CHECK_EQUAL(test, expected);
     }
    

    Which fails with:

    ==1270356== Using Valgrind
    ==1270356== Command: ./bld-cmake/bin/test_bitcoin -t util_tests/util_ReplaceAll
    ==1270356== 
    Running 1 test case...
    ==1270356== Invalid read of size 1
    ==1270356==    at 0x5C13411: find (basic_string.tcc:701)
    ==1270356==    by 0x5C13411: find<std::basic_string_view<char, std::char_traits<char> > > (basic_string.h:2981)
    ==1270356==    by 0x5C13411: util::ReplaceAll(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&, std::basic_string_view<char, std::char_traits<char> >, std::basic_string_view<char, std::char_traits<char> >) (src/util/string.cpp:17)
    

    My recommendation would be to keep this as-is for now (not add the test for now), and add the test in the future, when there is need to.


    l0rinc commented at 4:53 PM on August 31, 2026:

    Valid point, but since no current caller aliases these parameters, I'd prefer to address it in a follow-up if needed.

  19. Sjors approved
  20. Sjors commented at 10:00 AM on August 31, 2026: member

    ACK 6c3c1bb8af5a4e085b5174bacb65393ff4a80fe1

  21. in test/functional/feature_notifications.py:184 in 6c3c1bb8af
     179 | +                self.log.info("test -walletnotify replacement metacharacters in wallet name")
     180 | +                self.nodes[1].unloadwallet(self.wallet)
     181 | +                command_marker = os.path.join(self.options.tmpdir, "walletnotify_injected")
     182 | +                wallet_name = self.nodes[1].createwallet(f"$'$'; echo Pwned > {os.path.basename(command_marker)}; #")["name"]
     183 | +                txid = self.nodes[0].sendtoaddress(self.nodes[1].get_wallet_rpc(wallet_name).getnewaddress(), 1)
     184 | +                self.sync_mempools()
    


    stickies-v commented at 3:16 PM on August 31, 2026:

    On my machine I'm often seeing ~10 second windows required to execute this part of the test, which seems unnecessarily long. I think it's because tx relay has a random delay timer for announcement, so sync_mempools() slows things down. Using block synchronization would improve this, I think without affecting the effectiveness of this test?

    2026-08-31T14:49:44.745774Z TestFramework (INFO): test -walletnotify replacement metacharacters in wallet name
    2026-08-31T14:49:54.842461Z TestFramework (INFO): test -alertnotify with large work invalid chain
    

    <details> <summary>git diff on 8bf7d427bb</summary>

    diff --git a/test/functional/feature_notifications.py b/test/functional/feature_notifications.py
    index c90312f0cf..44b9958f97 100755
    --- a/test/functional/feature_notifications.py
    +++ b/test/functional/feature_notifications.py
    @@ -181,7 +181,7 @@ class NotificationsTest(BitcoinTestFramework):
                     command_marker = os.path.join(self.options.tmpdir, "walletnotify_injected")
                     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()
    +                self.generateblock(self.nodes[0], output=ADDRESS_BCRT1_UNSPENDABLE, transactions=[txid], sync_fun=self.sync_blocks)
                     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.
    
    

    </details>


    maflcko commented at 4:09 PM on August 31, 2026:

    An alternative would be to use immediate tx relay in the test, assuming it doesn't need to check the delay. Or create a self-transfer on node 1, if possible. Haven't checked either option.


    l0rinc commented at 4:57 PM on August 31, 2026:

    Thanks, good catch - full mempool sync is indeed unnecessary here because the test only needs node 1's wallet to observe this one transaction. I haven't noticed the long pauses locally, thanks for spotting it.

    I ended up with @maflcko's suggestion instead, changing it to submit the exact raw transaction directly to node 1, which keeps it unconfirmed and avoids waiting for randomized relay. I'd normally add you as coauthor, but after recent IRC discussions I'll just thank you in a comment instead :)

  22. stickies-v commented at 3:34 PM on August 31, 2026: contributor

    Concept ACK.

    On master, $' would first get shell-escaped into $'"'"', and then the $' in the escaped regex-replaced into <postmatch>"'"', breaking the quote accounting by removing the first single quote. This in turn can cause part of the wallet name to be passed to the shell as an unescaped string, as demonstrated in the new functional/characterisation test.

    This allows arbitrary code execution when an authenticated user has access to the createwallet RPC and when -walletnotify is configured.

  23. in test/functional/feature_notifications.py:182 in 6c3c1bb8af outdated
     174 | @@ -175,6 +175,18 @@ def run_test(self):
     175 |              self.expect_wallet_notify([(bump2, blockheight2, blockhash2), (tx2, -1, UNCONFIRMED_HASH_STRING)])
     176 |              assert_equal(self.nodes[1].gettransaction(bump2)["confirmations"], 1)
     177 |  
     178 | +            if platform.system() != 'Windows':
     179 | +                self.log.info("test -walletnotify replacement metacharacters in wallet name")
     180 | +                self.nodes[1].unloadwallet(self.wallet)
     181 | +                command_marker = os.path.join(self.options.tmpdir, "walletnotify_injected")
     182 | +                wallet_name = self.nodes[1].createwallet(f"$'$'; echo Pwned > {os.path.basename(command_marker)}; #")["name"]
    


    stickies-v commented at 4:00 PM on August 31, 2026:

    nit: This wallet name is specifically chosen for the current implementation on master. The magic value of $'$' doesn't make much sense without that, and will probably be confusing to future readers. I think we should either add a docstring that this specifically regression tests the combination of escaping and regex that existed prior to this PR, or generalize the test into testing all kinds of possible weird names and their rationale?

    I think just adding the docstring is the most pragmatic.


    l0rinc commented at 5:09 PM on August 31, 2026:

    Thanks, added a comment

  24. stickies-v approved
  25. stickies-v commented at 5:05 PM on August 31, 2026: contributor

    ACK 6c3c1bb8af5a4e085b5174bacb65393ff4a80fe1. The slow functional test would be nice to be improved in this PR, but can also be done in a follow-up, not blocking.

    This should probably have release notes, and be backported.

  26. l0rinc commented at 5:06 PM on August 31, 2026: contributor

    Thanks for the reviews, rebased and addressed all remaining concerns.

    Edit: I don't think this needs release notes, since it only breaks the attacker usecase, but I don't mind pushing again if others think it's necessarry

  27. l0rinc force-pushed on Aug 31, 2026
  28. stickies-v commented at 5:26 PM on August 31, 2026: contributor

    re-ACK 9b9d242cac9f5dd1a13def270a8d8addf248cd02

    Edit: I don't think this needs release notes, since it only breaks the attacker usecase

    My rationale is that it's useful for people under this specific configuration to know that their machine may have been exposed to arbitrary code execution.

  29. DrahtBot requested review from Sjors on Aug 31, 2026
  30. DrahtBot requested review from maflcko on Aug 31, 2026
  31. in test/functional/feature_notifications.py:185 in 9b9d242cac
     180 | +                self.nodes[1].unloadwallet(self.wallet)
     181 | +                command_marker = os.path.join(self.options.tmpdir, "walletnotify_injected")
     182 | +                # `$'` inserts the command suffix in regex replacement text, breaking the shell-escaped wallet name's quote accounting
     183 | +                wallet_name = self.nodes[1].createwallet(f"$'$'; echo Pwned > {os.path.basename(command_marker)}; #")["name"]
     184 | +                txid = self.nodes[0].sendtoaddress(self.nodes[1].get_wallet_rpc(wallet_name).getnewaddress(), 1)
     185 | +                self.nodes[1].sendrawtransaction(self.nodes[0].getrawtransaction(txid))  # Deliver directly to trigger node 1's unconfirmed wallet notification
    


    maflcko commented at 6:11 AM on September 1, 2026:
                    self.sync_mempools()
    

    Nit: What I wanted to say is that the existing test can be sped up at no downside:

    • The p2p tx relay delay is not tested by this test, so it is not needed
    • The pre-existing test cases here are slowed down by it.

    I get half the test time when I apply this diff:

    diff --git a/test/functional/feature_notifications.py b/test/functional/feature_notifications.py
    index e983d5a2b8..8e16c9cfdb 100755
    --- a/test/functional/feature_notifications.py
    +++ b/test/functional/feature_notifications.py
    @@ -44,2 +44,5 @@ class NotificationsTest(BitcoinTestFramework):
             self.uses_wallet = None
    +        # noban permission to speed up tx relay / mempool sync
    +        self.noban_tx_relay = True
    +
     
    

    (Maybe this can be done in 5th commit, or later, and the simpler self.sync_mempools() can be restored.


    l0rinc commented at 6:38 PM on September 1, 2026:

    Thanks, I wasn't aware of this setting, added noban_tx_relay and restored sync_mempools() so every mempool sync in this test uses immediate relay.

  32. in test/functional/feature_notifications.py:182 in 9b9d242cac
     174 | @@ -175,6 +175,19 @@ def run_test(self):
     175 |              self.expect_wallet_notify([(bump2, blockheight2, blockhash2), (tx2, -1, UNCONFIRMED_HASH_STRING)])
     176 |              assert_equal(self.nodes[1].gettransaction(bump2)["confirmations"], 1)
     177 |  
     178 | +            if platform.system() != 'Windows':
     179 | +                self.log.info("test -walletnotify replacement metacharacters in wallet name")
     180 | +                self.nodes[1].unloadwallet(self.wallet)
     181 | +                command_marker = os.path.join(self.options.tmpdir, "walletnotify_injected")
     182 | +                # `$'` inserts the command suffix in regex replacement text, breaking the shell-escaped wallet name's quote accounting
    


    maflcko commented at 6:15 AM on September 1, 2026:
                    # `$'` inserted the command suffix in regex replacement text, breaking the shell-escaped wallet name's quote accounting
    

    nit in the fix commit: Not sure if this comment should be adjusted to say it no longer happens after the fix?


    l0rinc commented at 6:37 PM on September 1, 2026:

    Fair, clarified that this behavior belonged to the previous regex implementation.

  33. maflcko approved
  34. maflcko commented at 6:17 AM on September 1, 2026: member

    Only change are test/doc nits. Left two more new test/doc nits 😅

    re-ACK 9b9d242cac9f5dd1a13def270a8d8addf248cd02 🐺

    <details><summary>Show signature</summary>

    Signature:

    untrusted comment: signature from minisign secret key on empty file; verify via: minisign -Vm "${path_to_any_empty_file}" -P RWTRmVTMeKV5noAMqVlsMugDDCyyTSbA3Re5AkUrhvLVln0tSaFWglOw -x "${path_to_this_whole_four_line_signature_blob}"
    RUTRmVTMeKV5npGrKx1nqXCw5zeVHdtdYURB/KlyA/LMFgpNCs+SkW9a8N95d+U4AP1RJMi+krxU1A3Yux4bpwZNLvVBKy0wLgM=
    trusted comment: re-ACK 9b9d242cac9f5dd1a13def270a8d8addf248cd02 🐺
    ZxnkCtHSLuzbi1Rngqp1dIk49u1FsXkbl1CqE4zkz6ExVXUFgvhmCsZDZeBQlK4uM/KAh0O/AiUjupyT6yMfCQ==
    

    </details>

  35. maflcko commented at 6:39 AM on September 1, 2026: member

    Maybe the release note could say something like: "An authenticated RPC caller allowed to create wallets on a node that also has -walletnotify enabled could run arbitrary code by crafting a wallet name with special regex replacement characters. This was fixed by making the replacement literal before shell quoting." (or so)

  36. jeanpablojp commented at 3:47 PM on September 1, 2026: contributor

    re-ACK 9b9d242cac9f5dd1a13def270a8d8addf248cd02

  37. test: simplify `ReplaceAll` coverage
    Let each case provide its input so strings outside the original fixture can use the same table without separate temporary variables.
    4efaa6763a
  38. 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.
    604d7e8fdd
  39. util: make `ReplaceAll` literal
    `ReplaceAll()` substitutes fixed tokens in notification commands and other strings.
    PR #25803 replaced the Boost helper with `std::regex_replace()`, treating searches as regular expressions and substitutes as replacement-format syntax.
    
    Restore literal, non-recursive replacement so callers match fixed tokens and preserve replacement bytes exactly, while avoiding a new string when the search text is absent.
    
    Co-authored-by: Rob Hamilton <6456095+Rob1Ham@users.noreply.github.com>
    469b0e59a2
  40. refactor: use string views in `ReplaceAll`
    PR #25803 changed these parameters to `const std::string&` for `std::regex_replace()`.
    The literal implementation no longer needs owned strings, so restore the original `std::string_view` interface.
    1f9dfabef6
  41. l0rinc force-pushed on Sep 1, 2026
  42. doc: add `-walletnotify` security note
    Co-authored-by: maflcko <6399679+maflcko@users.noreply.github.com>
    db39de5601
  43. l0rinc force-pushed on Sep 1, 2026
  44. l0rinc commented at 6:50 PM on September 1, 2026: contributor

    Thanks for the reviews, added back sync_mempools with noban_tx_relay and added a release notes (and rebased)

  45. DrahtBot added the label CI failed on Sep 1, 2026
  46. maflcko commented at 6:55 PM on September 1, 2026: member

    Only change is doc/test nits.

    re-ACK db39de5601094dc3f0b15ce4759e1b88025403c2 💈

    <details><summary>Show signature</summary>

    Signature:

    untrusted comment: signature from minisign secret key on empty file; verify via: minisign -Vm "${path_to_any_empty_file}" -P RWTRmVTMeKV5noAMqVlsMugDDCyyTSbA3Re5AkUrhvLVln0tSaFWglOw -x "${path_to_this_whole_four_line_signature_blob}"
    RUTRmVTMeKV5npGrKx1nqXCw5zeVHdtdYURB/KlyA/LMFgpNCs+SkW9a8N95d+U4AP1RJMi+krxU1A3Yux4bpwZNLvVBKy0wLgM=
    trusted comment: re-ACK db39de5601094dc3f0b15ce4759e1b88025403c2 💈
    I6n/CfX12hmyxqIBJWhyAdYLVjyuMmNVUP9ROE15nS4PEwN83NPiAxymWvR8rWHhhL7a9aqASg08OzEzXx6eDg==
    

    </details>

  47. DrahtBot requested review from stickies-v on Sep 1, 2026
  48. DrahtBot removed the label CI failed on Sep 1, 2026
  49. jeanpablojp commented at 11:09 PM on September 1, 2026: contributor

    re-ACK db39de5601094dc3f0b15ce4759e1b88025403c2

  50. stickies-v commented at 10:19 AM on September 2, 2026: contributor

    re-ACK db39de5601094dc3f0b15ce4759e1b88025403c2

    noban_tx_relay speeds up the entire functional test, I couldn't find an instance where relay delay was meaningful to the test case so that's a nice improvement along the way.

  51. sedited merged this on Sep 2, 2026
  52. sedited closed this on Sep 2, 2026

  53. l0rinc deleted the branch on Sep 2, 2026
  54. ryanofsky commented at 11:58 AM on September 3, 2026: contributor

    Related: #35833 restricts control characters in new wallet names, while this change fixes replacement metacharacters in ReplaceAll().

    Note: #35833 is about the effect control characters have on logging. Control characters do not cause a problem for -walletnotify because they are properly escaped. When I saw this I thought it was implying this PR was only a partial fix for -walletnotify, but it is a complete fix.

    In the longer run also it would also be good to move away from special arguments in hooks and just use environment variables instead, because they make it easier to provide hooks with a lot more contextual information and avoid escaping issues entirely


github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin/bitcoin. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-09-09 07:56 UTC