HexBrain

Monero · Report #3719670

`monero-wallet-rpc::rescan_spent` accepts daemon-supplied spent-status from an untrusted daemon, enabling ring-intersection attack on transactions

Validstatus
mediumseverity
21upvotes
2 Nov 2026disclosed

How it was found

summary

monero-wallet-rpc's JSON-RPC handler wallet_rpc_server::on_rescan_spent (src/wallet/wallet_rpc_server.cpp:3493) invokes wallet2::rescan_spent() without checking is_trusted_daemon(). Every other caller of that same wallet method — simplewallet's interactive rescan_spent command, the post-import-multisig hooks in both simplewallet and wallet_rpc_server, and the sibling RPC handler on_import_key_images — gates the call by is_trusted_daemon(). The dedicated rescan_spent JSON-RPC endpoint is the single place that omits the gate.

rescan_spent() calls the daemon's /is_key_image_spent endpoint and overwrites the wallet's local m_spent flag with the daemon's reply. When the daemon is untrusted (the default state for users running monero-wallet-rpc --daemon-address <remote-node> without an explicit --trusted-daemon flag) and is in fact malicious, it can return UNSPENT for an output the wallet itself has already spent. The wallet flips td.m_spent = false, the input selector then treats that output as available, and the next spend the user initiates ends up constructing a brand-new ring around the same real input. The wallet broadcasts the resulting transaction to the same daemon, which now possesses two ring signatures over the identical key image. Intersection of the two rings reveals the real spend.

details

the missing check

src/wallet/wallet_rpc_server.cpp:3493-3514:

bool wallet_rpc_server::on_rescan_spent(const wallet_rpc::COMMAND_RPC_RESCAN_SPENT::request& req,
                                        wallet_rpc::COMMAND_RPC_RESCAN_SPENT::response& res,
                                        epee::json_rpc::error& er,
                                        const connection_context *ctx)
{
    if (!m_wallet) return not_open(er);
    if (m_restricted)                                         // wallet-RPC admin flag, not daemon trust
    {
      er.code = WALLET_RPC_ERROR_CODE_DENIED;
      er.message = "Command unavailable in restricted mode.";
      return false;
    }
    CHECK_IF_BACKGROUND_SYNCING();
    try
    {
      m_wallet->rescan_spent();                               // ← no trust check before this
      return true;
    }
    ...
}

Compare with the sibling handler on_import_key_images (src/wallet/wallet_rpc_server.cpp:3154-3195), which IS gated:

bool wallet_rpc_server::on_import_key_images(...)
{
    if (!m_wallet) return not_open(er);
    if (m_restricted) { ... }
    if (!m_wallet->is_trusted_daemon())                      // ← present here
    {
      er.code = WALLET_RPC_ERROR_CODE_UNKNOWN_ERROR;
      er.message = "This command requires a trusted daemon.";
      return false;
    }
    ...
    m_wallet->import_key_images(ski, req.offset, spent, unspent);
}

And the simplewallet CLI command (src/simplewallet/simplewallet.cpp:6386-6391) also gates:

bool simple_wallet::rescan_spent(const std::vector<std::string> &args)
{
    CHECK_IF_BACKGROUND_SYNCING("cannot rescan spent");
    if (!m_wallet->is_trusted_daemon())
    {
      fail_msg_writer() << tr("this command requires a trusted daemon. Enable with --trusted-daemon");
      return true;
    }
    ...
    m_wallet->rescan_spent();
}

The author of this code understood that rescan_spent requires daemon trust. Three call sites enforce it. The JSON-RPC handler skips it.

what rescan_spent actually does

src/wallet/wallet2.cpp:7198-7252:

void wallet2::rescan_spent()
{
  ...
  for (size_t start_offset = 0; start_offset < m_transfers.size(); start_offset += chunk_size)
  {
    ...
    bool r = epee::net_utils::invoke_http_json("/is_key_image_spent", req, daemon_resp, *m_http_client, rpc_timeout);
    ...
    std::copy(daemon_resp.spent_status.begin(), daemon_resp.spent_status.end(), std::back_inserter(spent_status));
  }

  // update spent status
  for (size_t i = 0; i < m_transfers.size(); ++i)
  {
    transfer_details& td = m_transfers[i];
    if (!td.m_key_image_known || td.m_key_image_partial)
      continue;
    if (td.m_spent != (spent_status[i] != COMMAND_RPC_IS_KEY_IMAGE_SPENT::UNSPENT))
    {
      if (td.m_spent)
      {
        LOG_PRINT_L0("Marking output " << i << "(" << td.m_key_image << ") as unspent, it was marked as spent");
        set_unspent(i);                                            // ← daemon-driven flip
        td.m_spent_height = 0;
      }
      else
      {
        LOG_PRINT_L0("Marking output " << i << "(" << td.m_key_image << ") as spent, it was marked as unspent");
        set_spent(i, td.m_spent_height);
      }
    }
  }
}

Both directions are taken on disagreement. The "marked spent → unmark unspent" path is the one a malicious daemon abuses.

the input selector trusts m_spent

Every input-selection site in wallet2.cpp filters candidates by is_spent(td, false), which reads td.m_spent. A non-exhaustive list:

  • pick_preferred_rct_inputs — line 10390
  • select_outputs (RCT path) — line 10410, 10426
  • select_available_outputs — line 11206
  • get_unlocked_balance / get_balance callers — line 7018, 7073, 12235, 13250
  • pick_specific_output — line 11799, 11847

Once td.m_spent is false, all of these will treat the output as a viable input candidate. There is no parallel "I just broadcast a tx using this" pending-set that overrides the flag for already-broadcast spends.

attack chain

Pre-conditions:

  1. User runs monero-wallet-rpc --daemon-address <remote_node> without --trusted-daemon (default).
  2. The wallet RPC server is reachable by some caller (RPC user, wallet UI, automation).
  3. The daemon at <remote_node> is operated by, or has been compromised by, the attacker.

Steps:

  1. User sends transaction tx1 spending output O. The wallet broadcasts tx1 and calls set_spent(idx) — td.m_spent = true.
  2. The wallet RPC client (UI / script) calls rescan_spent over JSON-RPC. (Many wallet front-ends do this as a routine state-sync action; the user can also invoke it explicitly.)
  3. wallet_rpc_server::on_rescan_spent does not check trust; calls wallet2::rescan_spent().
  4. rescan_spent() POSTs key_images=[…O.key_image…] to the daemon's /is_key_image_spent.
  5. The malicious daemon returns UNSPENT for O.key_image, even though tx1 is in its mempool or already mined.
  6. rescan_spent() detects the disagreement (line 7236), executes the td.m_spent → set_unspent(i) branch (line 7240–7242).
  7. User initiates a second send tx2 (any destination, any amount). The input selector picks O because is_spent(td, false) now returns false.
  8. Wallet builds tx2 with a new ring around O: same key image, fresh randomly-selected decoys.
  9. Wallet broadcasts tx2 to the same daemon via /sendrawtransaction.
  10. The daemon parses tx2 — including its full ring — to detect the double-spend, then either rejects locally or accepts-and-relays. Either way, the daemon now possesses both ring blobs.
  11. The attacker has (ring1, key_image) and (ring2, key_image). The ring decoys are independent random samples; the only deterministic common element is the real spend O. Intersection identifies O as the real input in both transactions, breaking the ring's anonymity for that input.

why the malicious daemon is realistic

  • is_trusted_daemon() defaults to false unless the user passes --trusted-daemon (src/wallet/wallet2.cpp:248-449).
  • Users connecting wallets to public remote nodes (node.community.rino.io, xmr-node.cakewallet.com, etc.) are exactly in this state.
  • Bootstrap-daemon proxy mode (--bootstrap-daemon-address) places the wallet's monerod in the same trust posture toward its bootstrap.
  • Compromised remote-node operators or MITM against an unauthenticated monerod-RPC endpoint suffices.
  • Past Monero advisories already accept this threat model: HackerOne #304770 ("Corrupt RPC responses from remote node", 2018) and the explicit comment at wallet2.cpp:1920-1924 show the project treats untrusted daemons as a real adversary.

why the existing untrusted flag does not save us

COMMAND_RPC_IS_KEY_IMAGE_SPENT::response carries an untrusted bit set by the bootstrap-proxy path on the daemon side. wallet2::rescan_spent does not consult it. Even if it did, the untrusted bit would not protect the direct monero-wallet-rpc → remote node configuration: there's no proxy in that flow, just a TCP connection to whatever --daemon-address points at, and the response carries no signal that distinguishes a benign reply from a malicious one.

The legitimate trust signal in this code base is m_trusted_daemon / is_trusted_daemon(), which is a wallet-side configuration, not a server-side flag. Every call path that consults it is correct. The one path that doesn't (on_rescan_spent) is the bug.

PoC

A complete dynamic PoC requires a multi-component setup (wallet + chain state + a controllable daemon impersonator that answers /is_key_image_spent adversarially). The bug itself is provable from source by inspection of the asymmetry above. A minimal verification that the unsafe write actually fires can be staged with:

  1. Build a test daemon shim that proxies all monero-wallet-rpc calls to a real monerod EXCEPT /is_key_image_spent, which it answers with all-UNSPENT.
  2. Run a local regtest monerod, fund a wallet, send one transaction (so a known output's td.m_spent becomes true).
  3. Point monero-wallet-rpc at the shim daemon (no --trusted-daemon).
  4. From a second client, call JSON-RPC rescan_spent against monero-wallet-rpc.
  5. Inspect monero-wallet-rpc logs — look for LOG_PRINT_L0("Marking output X as unspent, it was marked as spent") (wallet2.cpp:7240).

The log line confirms td.m_spent was flipped under attacker control. The downstream ring construction in transfer_selected_rct follows from standard wallet behavior; reproducing it against the same regtest setup confirms the second transaction reuses the spent output as a ring's real input.

The simplewallet CLI cannot reproduce this: its rescan_spent handler at simplewallet.cpp:6388-6391 rejects the operation when !is_trusted_daemon(). The PoC must go through monero-wallet-rpc's JSON-RPC interface.

impact

  • Ring-signature unlinkability is broken for the spent input of tx2. Since the malicious daemon already saw tx1's ring, intersection of the two rings (each containing the same key image) reveals the real spend with high probability. This violates Monero's central privacy property for that input.
  • The attack can be repeated: each call to rescan_spent is a fresh opportunity. Wallet UIs / automation that call this method routinely give the malicious daemon many shots.
  • Monetary loss is not direct (the second tx is a double-spend and the network rejects it; the user's funds remain in their first tx). Privacy loss is the impact.
  • For users running monero-wallet-rpc against community public nodes — a very large population — the threat surface is the operator of that public node plus anyone with MITM on the connection.

suggested fix

Mirror the check that on_import_key_images already performs. One line added to src/wallet/wallet_rpc_server.cpp::on_rescan_spent:

 bool wallet_rpc_server::on_rescan_spent(const wallet_rpc::COMMAND_RPC_RESCAN_SPENT::request& req,
                                         wallet_rpc::COMMAND_RPC_RESCAN_SPENT::response& res,
                                         epee::json_rpc::error& er,
                                         const connection_context *ctx)
 {
     if (!m_wallet) return not_open(er);
     if (m_restricted)
     {
       er.code = WALLET_RPC_ERROR_CODE_DENIED;
       er.message = "Command unavailable in restricted mode.";
       return false;
     }
+    if (!m_wallet->is_trusted_daemon())
+    {
+      er.code = WALLET_RPC_ERROR_CODE_UNKNOWN_ERROR;
+      er.message = "This command requires a trusted daemon.";
+      return false;
+    }
     CHECK_IF_BACKGROUND_SYNCING();
     try
     {
       m_wallet->rescan_spent();
       return true;
     }
     ...
 }

XMR wallet: 868yhxwhtbY8mUM47YygWNJ8BSYi9dhZHNsGQHvFcNku5oHoq5622ouAKXtAuz6d9C3KhrGwC5YyK3jAVtHPcvquJGSRxxL

Impact

monero-wallet-rpc's JSON-RPC handler wallet_rpc_server::on_rescan_spent (src/wallet/wallet_rpc_server.cpp:3493) invokes wallet2::rescan_spent() without checking is_trusted_daemon(). Every other caller of that same wallet method — simplewallet's interactive rescan_spent command, the post-import-multisig hooks in both simplewallet and wallet_rpc_server, and the sibling RPC handler on_import_key_images — gates the call by is_trusted_daemon(). The dedicated rescan_spent JSON-RPC endpoint is the single place that omits the gate.

rescan_spent() calls the daemon's /is_key_image_spent endpoint and overwrites the wallet's local m_spent flag with the daemon's reply. When the daemon is untrusted (the default state for users running monero-wallet-rpc --daemon-address <remote-node> without an explicit --trusted-daemon flag) and is in fact malicious, it can return UNSPENT for an output the wallet itself has already spent. The wallet flips td.m_spent = false, the input selector then treats that output as available, and the next spend the user initiates ends up constructing a brand-new ring around the same real input. The wallet broadcasts the resulting transaction to the same daemon, which now possesses two ring signatures over the identical key image. Intersection of the two rings reveals the real spend.

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