tor: limit torcontrol line size that is processed to prevent OOM #35087

pull davidgumberg wants to merge 3 commits into bitcoin:master from davidgumberg:2026-04-14-torcontrol-linelimit changing 2 files +60 −17
  1. davidgumberg commented at 10:28 PM on April 15, 2026: contributor

    LLM disclosure: Found with the help of Claude Opus 4.6, fix, test, description, and commit messages written by me.


    This fixes a low-severity issue where a misbehaving Tor control daemon can cause bitcoind to OOM by sending continuation lines without sending 250 OK or similar.

    This issue is not that serious because if your tor control daemon is malicious you are already in all kinds of trouble, but as a matter of robustness this should be fixed.

    The fix is to prevent the TorControlConnection::m_message buffer from growing without bound by by limiting the number of lines handled by TorControlConnection::ProcessBuffer() to MAX_LINE_COUNT = 1000. Now the most memory that can be occupied by m_message is on the order of MAX_LINE_LENGTH * MAX_LINE_COUNT= 100MB

    Although this is not compliant with the Tor control protocol in general, where commands like GETINFO ns/all will likely return thousands of lines, it is more than sufficient for handling the replies from the commands that are used by a node:

    <details>

    <summary>

    Tor control commands used by Bitcoin Core

    </summary>

    AUTHENTICATE: 1 line: The server responds with 250 OK on success or 515 Bad authentication if the authentication cookie is incorrect. Tor closes the connection on an authentication failure.

    https://spec.torproject.org/control-spec/commands.html#authenticate

    GETINFO net/listener/socks: 2 lines A quoted, space-separated list of the locations where Tor is listening...

    https://spec.torproject.org/control-spec/commands.html#getinfo

    AUTHCHALLENGE SAFECOOKIE: 1 line If the server accepts the command, the server reply format is:

    ```
    "250 AUTHCHALLENGE" SP "SERVERHASH=" ServerHash SP "SERVERNONCE="
    ServerNonce CRLF
    ```

    https://spec.torproject.org/control-spec/commands.html#authenticate

    PROTOCOLINFO: 4-5 lines

    The server reply format is:
    
    ```
    250-PROTOCOLINFO" SP PIVERSION CRLF \*InfoLine "250 OK" CRLF
    InfoLine = AuthLine / VersionLine / OtherLine
    ```

    (https://spec.torproject.org/control-spec/commands.html#protocolinfo)

    ADD_ONION: 2-3 lines for Bitcoin Core's tor control client.

    The server reply format is:
    
    ```
    "250-ServiceID=" ServiceID CRLF
    ["250-PrivateKey=" KeyType ":" KeyBlob CRLF]
    *("250-ClientAuth=" ClientName ":" ClientBlob CRLF)
    "250 OK" CRLF
    ```
    
    ...
    
    The server response will only include a private key if the server
    was requested to generate a new keypair
    
    ...
    
    If client authorization is enabled using the “BasicAuth” flag (which
    is v2 only), the service will not be accessible to clients without
    valid authorization data (configured with the “HidServAuth” option).
    The list of authorized clients is specified with one or more
    “ClientAuth” parameters. If “ClientBlob” is not specified for a
    client, a new credential will be randomly generated and returned."

    https://spec.torproject.org/control-spec/commands.html#add_onion

    We don't set the BasicAuth flag, so the response will not include any ClientAuthLines.

    </details>

    Reproduce

    To reproduce this issue, the following script or similar can be used as the misbehaving Tor control daemon:

    #!/usr/bin/env python3
    """
    A fake Tor control service that never finishes its reply. Sends unlimited
    continuation lines ("250-...") without ever sending the final "250 ...".
    Each line accumulates in m_message.lines with no cap. Bitcoind OOMs.
    """
    
    import socket
    import time
    
    PORT = 19191
    
    server = socket.create_server(("127.0.0.1", PORT))
    conn, _ = server.accept()
    conn.recv(4096)  # Receive PROTOCOLINFO
    
    time_start = time.time()
    
    try:
        while True:
            conn.sendall(b"250-Ceaseless\r\n" * 10000)
    except (BrokenPipeError, ConnectionResetError):
        elapsed = time.time() - time_start
        print(f"Node disconnected after {elapsed:.2f}s")
    

    🟡¡This will OOM, run in a container, VM, or some sandbox with memory limits!🟡 Start a node with -torcontrol=127.0.0.1=19191.

    E.g. with systemd:

    systemd-run --user --scope -p MemoryMax=2G -p MemorySwapMax=0 bitcoind -regtest -torcontrol=127.0.0.1:19191
    
  2. DrahtBot added the label P2P on Apr 15, 2026
  3. DrahtBot commented at 10:28 PM on April 15, 2026: contributor

    <!--e57a25ab6845829454e8d69fc972939a-->

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

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

    See the guideline for information on the review process.

    Type Reviewers
    ACK fjahr, danielabrozzoni

    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. davidgumberg force-pushed on Apr 15, 2026
  5. DrahtBot added the label CI failed on Apr 15, 2026
  6. DrahtBot removed the label CI failed on Apr 15, 2026
  7. in src/torcontrol.cpp:194 in 12cd60b05a
     189 |  
     190 |          // Parse: <code><separator><data>
     191 |          // <status>(-|+| )<data>
     192 |          m_message.code = ToIntegral<int>(line->substr(0, 3)).value_or(0);
     193 |          m_message.lines.push_back(line->substr(4));
     194 | +
    


    fjahr commented at 7:43 AM on April 16, 2026:

    nit: Newline seems unnecessary


    davidgumberg commented at 6:17 PM on April 16, 2026:

    Oops, thanks for catching, this was left over from an earlier variation of the branch

  8. in test/functional/feature_torcontrol.py:230 in 12cd60b05a
     231 | +        msg = "250-" + ("A" * (MAX_LINE_LENGTH - 4)) + "\r"
     232 | +        assert_equal(len(msg), MAX_LINE_LENGTH + 1)
     233 | +        mock_tor.send_raw(msg + "\n")
     234 | +
     235 | +        # Connection should be dropped and retried, causing another PROTOCOLINFO.
     236 | +        self.wait_until(lambda: len(mock_tor.received_commands) == 2, timeout=10)
    


    fjahr commented at 7:48 AM on April 16, 2026:

    Can probably deduplicate the wait for PROTOCOLINFO into a function at this point


  9. in src/torcontrol.cpp:67 in 12cd60b05a
      62 | @@ -63,6 +63,10 @@ constexpr std::chrono::duration<double> RECONNECT_TIMEOUT_MAX{600.0};
      63 |   * this is belt-and-suspenders sanity limit to prevent memory exhaustion.
      64 |   */
      65 |  constexpr int MAX_LINE_LENGTH = 100000;
      66 | +/** Maximum number of lines received on TorControlConnection, also a sanity
      67 | + *  check against memory exhaustion.
    


    fjahr commented at 7:54 AM on April 16, 2026:

    This should be: "Maximum number of lines received on TorControlConnection per reply"

    Could also add something like "The most lines we currently expect are 5 in a PROTOCOLINFO" so that if there are changes to how we use Tor Control in the future maybe this is double checked if it needs to be re-evaluated.


    davidgumberg commented at 1:34 AM on April 17, 2026:

    Good idea, taken.

  10. in test/functional/feature_torcontrol.py:246 in 12cd60b05a
     247 | +        self.wait_until(lambda: len(mock_tor.received_commands) == 1, timeout=10)
     248 | +        assert_equal(mock_tor.received_commands[0], "PROTOCOLINFO 1")
     249 | +
     250 | +        MAX_LINE_COUNT = 1000
     251 | +
     252 | +        self.log.info("Test that tor control does not disconnect on receiving MAX_LINE_COUNT lines.")
    


    fjahr commented at 7:57 AM on April 16, 2026:

    super-nit (here and log info below): s/tor/Tor/


    davidgumberg commented at 1:33 AM on April 17, 2026:

    Fixed, thanks

  11. fjahr commented at 8:11 AM on April 16, 2026: contributor

    Concept ACK

    Pretty much good as is but I would like to see the comment updated with "per reply" at least.

  12. in test/functional/feature_torcontrol.py:224 in 12cd60b05a
     225 | +        mock_tor.send_raw(msg + "\n")
     226 | +
     227 | +        # No disconnect, so no reconnect message
     228 | +        ensure_for(duration=2, f=lambda: len(mock_tor.received_commands) == 1)
     229 | +
     230 | +        self.log.info("Test that Tor control disconnects with a MAX_LINE_LENGTH + 1 lines")
    


    ViniciusCestarii commented at 6:52 PM on April 16, 2026:

    super-nit: this test is testing for a single oversized line (singular)

            self.log.info("Test that Tor control disconnects with a MAX_LINE_LENGTH + 1 line")
    

    davidgumberg commented at 1:32 AM on April 17, 2026:

    Fixed, thanks

  13. in src/torcontrol.cpp:184 in 12cd60b05a outdated
     180 | @@ -177,13 +181,17 @@ bool TorControlConnection::ProcessBuffer()
     181 |      auto start = reader.it;
     182 |  
     183 |      while (auto line = reader.ReadLine()) {
     184 | +        if (m_message.lines.size() == MAX_LINE_COUNT) {
    


    luke-jr commented at 8:15 PM on April 16, 2026:

    So we read a line, then unconditionally error? Why not check this after determining the new line is needed?


    davidgumberg commented at 1:31 AM on April 17, 2026:

    Not because I'm being pedantic, but because I don't follow: the condition is m_message.lines.size() == MAX_LINE_COUNT.

    If my guess of your meaning is correct, that you would prefer the check to appear at the bottom of the loop as an else if:

    diff --git a/src/torcontrol.cpp b/src/torcontrol.cpp
    index 0893596d37..1b3823aa97 100644
    --- a/src/torcontrol.cpp
    +++ b/src/torcontrol.cpp
    @@ -182,9 +182,6 @@ bool TorControlConnection::ProcessBuffer()
         auto start = reader.it;
    
         while (auto line = reader.ReadLine()) {
    -        if (m_message.lines.size() == MAX_LINE_COUNT) {
    -            throw std::runtime_error(strprintf("Control port reply exceeded %d lines, disconnecting", MAX_LINE_COUNT));
    -        }
             // Skip short lines
             if (line->size() < 4) continue;
    
    @@ -207,6 +204,8 @@ bool TorControlConnection::ProcessBuffer()
                     LogDebug(BCLog::TOR, "Received unexpected sync reply %i", m_message.code);
                 }
                 m_message.Clear();
    +        } else if (m_message.lines.size() == MAX_LINE_COUNT) {
    +            throw std::runtime_error(strprintf("Control port reply exceeded %d lines, disconnecting", MAX_LINE_COUNT));
             }
         }
    

    This works, and is a bit more clever, but IMO this is less legible and more fragile than the caveman check at the top of the loop that the invariant is not violated, and reading an extra 100kb is harmless.

  14. luke-jr referenced this in commit 7cbc2a14b1 on Apr 16, 2026
  15. luke-jr referenced this in commit 30a0b27593 on Apr 16, 2026
  16. refactor: torcontrol add connection checks to restart_with_mock ab5889796f
  17. davidgumberg force-pushed on Apr 17, 2026
  18. in test/functional/feature_torcontrol.py:140 in 3f76105027 outdated
     135 | +            # commands length.
     136 | +            self.wait_until(lambda: len(mock_tor.received_commands) == initial_len + 1)
     137 | +            assert_equal(mock_tor.received_commands[initial_len], "PROTOCOLINFO 1")
     138 | +        else:
     139 | +            # No disconnect, so no reconnect message
     140 | +            ensure_for(duration=2, f=lambda: len(mock_tor.received_commands) == 1)
    


    fjahr commented at 7:42 AM on April 17, 2026:

    This should probably be:

                ensure_for(duration=2, f=lambda: len(mock_tor.received_commands) == initial_len)
    

    davidgumberg commented at 6:49 PM on April 17, 2026:

    Dang, my mistake, thanks for catching.

  19. in test/functional/feature_torcontrol.py:141 in 3f76105027 outdated
     136 | +            self.wait_until(lambda: len(mock_tor.received_commands) == initial_len + 1)
     137 | +            assert_equal(mock_tor.received_commands[initial_len], "PROTOCOLINFO 1")
     138 | +        else:
     139 | +            # No disconnect, so no reconnect message
     140 | +            ensure_for(duration=2, f=lambda: len(mock_tor.received_commands) == 1)
     141 | +
    


    fjahr commented at 7:44 AM on April 17, 2026:

    another micro-nit: Usually on newline between class functions


    davidgumberg commented at 6:54 PM on April 17, 2026:

    Fixed

  20. fjahr commented at 7:55 AM on April 17, 2026: contributor

    Looks great except for the small fix in expect_disconnect.

  21. luke-jr referenced this in commit c78ade1ecc on Apr 17, 2026
  22. luke-jr referenced this in commit d4bf9ca599 on Apr 17, 2026
  23. luke-jr referenced this in commit abb6aa337d on Apr 17, 2026
  24. test: Make torcontrol max line length test stricter and test boundaries.
    Adds a check that at the boundary of MAX_LINE_LENGTH, no disconnect
    occurs.
    
    Also makes the overlength test message exactly MAX_LINE_LENGTH + 1 to
    test the boundary.
    
    Drops the redundant node liveness check, which is covered by the later
    check that the node reconnects.
    8b68287bf9
  25. tor: torcontrol disconnect on too many lines to avoid OOM
    This commit ensures the `TorControlConnection::m_message` buffer doesn't
    grow unbounded and exhaust memory, by limiting the number of lines
    handled by `TorControlConnection::ProcessBuffer()` to `MAX_LINE_COUNT =
    1000`. Now the most memory that can be occupied by `m_message` is on the
    order of `MAX_LINE_LENGTH * MAX_LINE_COUNT= 100MB`
    
    Although this is not compliant with the tor control protocol in general,
    where commands like `GETINFO ns/all` will likely return thousands of
    lines, it is more than sufficient for handling the replies from the
    commands that are used by a node:
    
    `AUTHENTICATE`: 1 line:
        The server responds with 250 OK on success or 515 Bad
        authentication if the authentication cookie is incorrect. Tor closes
        the connection on an authentication failure.
    
    https://spec.torproject.org/control-spec/commands.html#authenticate
    
    `GETINFO net/listener/socks`: 2 lines
        A quoted, space-separated list of the locations where Tor is
        listening...
    
    https://spec.torproject.org/control-spec/commands.html#getinfo
    
    `AUTHCHALLENGE SAFECOOKIE`: 1 line
        If the server accepts the command, the server reply format is:
    
        ```
        "250 AUTHCHALLENGE" SP "SERVERHASH=" ServerHash SP "SERVERNONCE="
        ServerNonce CRLF
        ```
    
    https://spec.torproject.org/control-spec/commands.html#authenticate
    
    `PROTOCOLINFO`: 4-5 lines
    
        The server reply format is:
    
        ```
        250-PROTOCOLINFO" SP PIVERSION CRLF \*InfoLine "250 OK" CRLF
        InfoLine = AuthLine / VersionLine / OtherLine
        ```
    
    (https://spec.torproject.org/control-spec/commands.html#protocolinfo)
    
    `ADD_ONION`: 2-3 lines for Bitcoin Core's tor control client.
    
        The server reply format is:
    
        ```
        "250-ServiceID=" ServiceID CRLF
        ["250-PrivateKey=" KeyType ":" KeyBlob CRLF]
        *("250-ClientAuth=" ClientName ":" ClientBlob CRLF)
        "250 OK" CRLF
        ```
    
        ...
    
        The server response will only include a private key if the server
    was requested to generate a new keypair
    
        ...
    
        If client authorization is enabled using the “BasicAuth” flag (which
        is v2 only), the service will not be accessible to clients without
        valid authorization data (configured with the “HidServAuth” option).
        The list of authorized clients is specified with one or more
        “ClientAuth” parameters. If “ClientBlob” is not specified for a
        client, a new credential will be randomly generated and returned."
    
    https://spec.torproject.org/control-spec/commands.html#add_onion
    
    We don't set the `BasicAuth` flag, so the response will not include any
    `ClientAuthLines`.
    9fe5896a44
  26. davidgumberg force-pushed on Apr 17, 2026
  27. davidgumberg commented at 6:58 PM on April 17, 2026: contributor

    Small push to address feedback from @fjahr:

    $ git diff 9fe5896a44 8b3cdf3d8a
    

    (https://github.com/bitcoin/bitcoin/compare/8b3cdf3d8ac133091f45b177589224d607cf68c0..9fe5896a446d5a1acb5834561ffbca841f8a970a)

    diff --git a/test/functional/feature_torcontrol.py b/test/functional/feature_torcontrol.py
    index b3a8df2313..485ffc4455 100755
    --- a/test/functional/feature_torcontrol.py
    +++ b/test/functional/feature_torcontrol.py
    @@ -137,8 +137,7 @@ class TorControlTest(BitcoinTestFramework):
                 assert_equal(mock_tor.received_commands[initial_len], "PROTOCOLINFO 1")
             else:
                 # No disconnect, so no reconnect message
    -            ensure_for(duration=2, f=lambda: len(mock_tor.received_commands) == 1)
    -
    +            ensure_for(duration=2, f=lambda: len(mock_tor.received_commands) == initial_len)
     
         def test_basic(self):
             self.log.info("Test Tor control basic functionality")
    
  28. fjahr commented at 10:32 AM on April 18, 2026: contributor

    ACK 9fe5896a446d5a1acb5834561ffbca841f8a970a

  29. fanquake added this to the milestone 32.0 on Apr 18, 2026
  30. danielabrozzoni approved
  31. danielabrozzoni commented at 3:56 PM on April 20, 2026: member

    Code review ACK 9fe5896a446d5a1acb5834561ffbca841f8a970a

    I haven't reproduced the OOM, and haven't done any manual testing, I only ran tests locally :)


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-04-21 09:12 UTC

This site is hosted by @0xB10C
More mirrored repositories can be found on mirror.b10c.me