Monero · Report #3719670
`monero-wallet-rpc::rescan_spent` accepts daemon-supplied spent-status from an untrusted daemon, enabling ring-intersection attack on transactions
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 10390select_outputs(RCT path) — line 10410, 10426select_available_outputs— line 11206get_unlocked_balance/get_balancecallers — line 7018, 7073, 12235, 13250pick_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:
- User runs
monero-wallet-rpc --daemon-address <remote_node>without--trusted-daemon(default). - The wallet RPC server is reachable by some caller (RPC user, wallet UI, automation).
- The daemon at
<remote_node>is operated by, or has been compromised by, the attacker.
Steps:
- User sends transaction
tx1spending outputO. The wallet broadcaststx1and callsset_spent(idx)—td.m_spent = true. - The wallet RPC client (UI / script) calls
rescan_spentover JSON-RPC. (Many wallet front-ends do this as a routine state-sync action; the user can also invoke it explicitly.) wallet_rpc_server::on_rescan_spentdoes not check trust; callswallet2::rescan_spent().rescan_spent()POSTskey_images=[…O.key_image…]to the daemon's/is_key_image_spent.- The malicious daemon returns
UNSPENTforO.key_image, even thoughtx1is in its mempool or already mined. rescan_spent()detects the disagreement (line 7236), executes thetd.m_spent → set_unspent(i)branch (line 7240–7242).- User initiates a second send
tx2(any destination, any amount). The input selector picksObecauseis_spent(td, false)now returns false. - Wallet builds
tx2with a new ring aroundO: same key image, fresh randomly-selected decoys. - Wallet broadcasts
tx2to the same daemon via/sendrawtransaction. - 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. - 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 spendO. Intersection identifiesOas the real input in both transactions, breaking the ring's anonymity for that input.
why the malicious daemon is realistic
is_trusted_daemon()defaults tofalseunless 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-1924show 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:
- Build a test daemon shim that proxies all
monero-wallet-rpccalls to a real monerod EXCEPT/is_key_image_spent, which it answers with all-UNSPENT. - Run a local regtest monerod, fund a wallet, send one transaction (so a known output's
td.m_spentbecomes true). - Point
monero-wallet-rpcat the shim daemon (no--trusted-daemon). - From a second client, call JSON-RPC
rescan_spentagainstmonero-wallet-rpc. - Inspect
monero-wallet-rpclogs — look forLOG_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 sawtx1'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_spentis 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-rpcagainst 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.