Monero · Report #3700036
SpendProofV1 txid-substitution: get_spend_proof/check_spend_proof do not verify returned transaction hash
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.shreports/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