HexBrain

Monero · Report #3700036

SpendProofV1 txid-substitution: get_spend_proof/check_spend_proof do not verify returned transaction hash

Validstatus
mediumseverity
28upvotes
5 Aug 2026disclosed

How it was found

Repository: monero-project/monero — src/wallet/wallet2.cpp. Snapshot: master @ 3ad4a5ee8 (v0.18.1.0-3ad4a5ee8).

Summary

Both helpers fetch a pruned transaction with /gettransactions, parse it with get_pruned_tx(), then build the spend-proof challenge from the txid argument, not from the hash implied by the blob. Neither compares tx_hash (from parsing) to txid before signing or verifying. check_tx_proof() on the same RPC path does that comparison.

get_spend_proof (roughly wallet2.cpp 11868–11969): request, parse, then:

std::string sig_prefix_data((const char*)&txid, sizeof(crypto::hash));
sig_prefix_data += message;
crypto::hash sig_prefix_hash;
crypto::cn_fast_hash(sig_prefix_data.data(), sig_prefix_data.size(), sig_prefix_hash);
// ring sigs use tx / rings from res.txs[0], not a second hash check

check_spend_proof (same file, ~11979–12084) repeats the pattern: prefix hash from txid, ring checks from the parsed tx.

For comparison, after get_pruned_tx in check_tx_proof:

THROW_WALLET_EXCEPTION_IF(tx_hash != txid, error::wallet_internal_error,
    "Failed to get the right transaction from daemon");

So if the HTTP peer in front of the wallet is malicious (fake monerod, corporate proxy, or on-path MITM), it can answer a request for txid B with a valid serialized body for a different tx A. The wallet still signs H(B || message) while using inputs/key images/rings from A. Verifiers that use check_spend_proof against the same bad endpoint will see good: true for B even though nothing in the response actually authenticates B.

This is integrity of the spend proof binding, not key exfiltration or remote wallet compromise. You still need a broken or attacker-controlled daemon channel.

Releases Affected

Re-checked on master @ 3ad4a5ee8. The missing tx_hash == txid guard is plain in source; any branch with this spend-proof code path behaves the same unless someone already merged an equivalent check.

Steps to Reproduce

The practical check is end-to-end: real monerod --regtest, real monero-wallet-rpc, and a tiny Python reverse proxy that forwards to the honest node but, on POST /gettransactions, replaces a chosen FAKE txs_hashes entry with the REAL hash of a spend the wallet actually made. The node returns a consistent pruned tx for A; the RPC still asks for a proof on FAKE. No Monero source edits.

One command (paths match this machine; adjust MONERO_BUILD if your tree lives elsewhere):

bash /home/beni/Monero/reports/poc_spend_proof_txid_substitution.sh

The script mirrors what you would do by hand: mine and transfer on a direct daemon URL (so sync is boring), record the real spend txid, bring up the MITM, set_daemon to it with "ssl_support":"disabled" for plain HTTP, then call get_spend_proof with the random FAKE id, check_spend_proof with the returned signature, and finally point back at the honest daemon to show the same FAKE id no longer yields a proof. {F5813828} What you should see in the log: SpendProofV1… returned for FAKE, check_spend_proof reports good, then after dropping the MITM the wallet errors on get_spend_proof for FAKE (e.g. missing / wrong txs count).

Build the binaries once from the official tree, Release, no patches — same idea as the other lab scripts in reports/.

If you hate shell wrappers, the two attachments that matter are:

  • reports/poc_spend_proof_txid_substitution.sh
  • reports/poc_spend_proof_txid_substitution_proxy.py

Possible Solution

After get_pruned_tx(res.txs[0], tx, tx_hash) succeeds in both get_spend_proof and check_spend_proof, add the same guard as check_tx_proof:

THROW_WALLET_EXCEPTION_IF(tx_hash != txid, error::wallet_internal_error,
    "Failed to get the right transaction from daemon");

Add a regression test (daemon fixture or mocked HTTP) that returns a blob whose id does not match the requested hash; both entry points should bail before signing or verifying.

Impact

A malicious, compromised, or MITM daemon can break the integrity binding of SpendProofV1.

get_spend_proof and check_spend_proof are supposed to operate on the transaction identified by the caller-supplied txid. However, because the parsed transaction hash is not compared against that txid, the daemon can return transaction body A for a request for txid B. The wallet then builds/verifies the spend-proof challenge over B while using the inputs, key images, and rings from A.

In the PoC, a random FAKE txid cannot produce a spend proof against the honest daemon. When /gettransactions is MITM-substituted with a real wallet-originated transaction body, get_spend_proof(txid=FAKE) succeeds and check_spend_proof(txid=FAKE, signature=...) returns good:true behind the same malicious daemon.

This can mislead services or automation that rely on check_spend_proof as evidence that a user created/spent a specific transaction id, if their wallet-rpc verifier is connected to an attacker-controlled, compromised, or MITM'd daemon.

This is not a private-key leak, fund theft, consensus issue, or RCE. The precondition is control of the wallet-to-daemon response path. The impact is proof authenticity/integrity failure.

Note

Drafting and the PoC wiring had editor/AI help; line numbers, script runs, and daemon behaviour were checked against the local tree/build.

Bounty

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

47TUANy82gtBFMvsFwyA3hE98PzgKSmxNia3n9fpvSadDWs234M7oWfevXqYxKmy3d9hSp1TH82gX4JWvNEUupse2s92maV

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