Reports
MEDIA[TLS Y CRIPTOGRAFÍA]#3700036

Monero: las pruebas de gasto (SpendProofV1) no comprobaban que el daemon devolviera la transacción pedida

get_spend_proof y check_spend_proof en wallet2.cpp no comparaban el hash de la transacción recibida del daemon con el txid solicitado, así que un daemon malicioso podía hacer válida una prueba de gasto para un txid ajeno.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

Resumen

La cartera de Monero permite generar y verificar spend proofs (pruebas de gasto, formato SpendProofV1): una firma que demuestra que quien la presenta gastó las entradas de una transacción concreta, identificada por su txid. Las dos funciones que se encargan de ello, get_spend_proof y check_spend_proof en src/wallet/wallet2.cpp (revisado sobre master @ 3ad4a5ee8, v0.18.1.0-3ad4a5ee8), tenían un paso criptográfico ausente: nunca comprobaban que la transacción que les devolvía el daemon fuese realmente la que se había pedido.

Ambas funciones piden la transacción podada al daemon con /gettransactions, la interpretan con get_pruned_tx() y, a partir de ahí, mezclan dos fuentes distintas:

  • El desafío de la firma se calcula con el txid que pasa quien llama.
  • Las entradas, key images y anillos salen de la transacción que ha devuelto el daemon.

El hash real de esa transacción (tx_hash, obtenido al parsearla) nunca se comparaba con txid. El fragmento relevante de get_spend_proof es este:

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 repite el mismo patrón: el hash del prefijo sale de txid y las comprobaciones de los anillos, de la tx parseada. Lo llamativo es que otra función del mismo fichero y con el mismo flujo RPC, check_tx_proof, sí hace la comprobación justo después de get_pruned_tx:

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

En la práctica, si quien responde a la cartera por HTTP es hostil (un monerod falso, un proxy corporativo o un atacante en medio), puede contestar a una petición del txid B con el cuerpo válido de otra transacción A. La cartera firma entonces H(B || mensaje) usando las entradas, key images y anillos de A, y un verificador conectado al mismo daemon manipulado obtiene good: true para B, aunque nada en la respuesta autentica realmente a B.

Pasos de reproducción

El investigador lo demostró de extremo a extremo sin modificar el código de Monero, con binarios oficiales compilados en modo Release y sin parches:

  1. Levantar un monerod --regtest real y un monero-wallet-rpc real.
  2. Con la cartera conectada directamente al daemon honesto, minar bloques y hacer un transfer. Anotar el txid real de ese gasto (transacción A).
  3. Generar un txid aleatorio FAKE que no corresponde a ninguna transacción.
  4. Arrancar un pequeño proxy inverso en Python que reenvía todo al daemon honesto, salvo en POST /gettransactions: si en txs_hashes aparece el hash FAKE, lo sustituye por el hash REAL de la transacción A. El daemon devuelve así una transacción podada coherente, pero la de A.
  5. Apuntar la cartera al proxy con set_daemon, usando "ssl_support":"disabled" para tráfico HTTP plano.
  6. Llamar a get_spend_proof con el txid FAKE. La cartera devuelve una firma SpendProofV1….
  7. Llamar a check_spend_proof con el txid FAKE y esa firma. El resultado es good.
  8. Como control, volver a apuntar la cartera al daemon honesto y pedir de nuevo get_spend_proof para FAKE: ahora falla (por ejemplo, porque falta txs o su número no es el esperado).

Los dos ficheros de la prueba de concepto adjuntos al reporte eran:

reports/poc_spend_proof_txid_substitution.sh
reports/poc_spend_proof_txid_substitution_proxy.py

Impacto

Se rompe la vinculación entre la prueba de gasto y la transacción que supuestamente demuestra. Un daemon malicioso, comprometido o interceptado puede conseguir que:

  • get_spend_proof genere una prueba "válida" para un txid que el usuario nunca gastó.
  • check_spend_proof dé por buena esa prueba, devolviendo good: true.

Esto puede engañar a servicios o automatismos que usan check_spend_proof como evidencia de que un usuario creó o gastó una transacción concreta, siempre que su monero-wallet-rpc verificador esté conectado a un daemon controlado por el atacante o interceptado.

El propio investigador acota el alcance: no hay fuga de claves privadas, robo de fondos, problema de consenso ni ejecución de código. La condición previa es controlar el camino de respuestas entre la cartera y el daemon; el fallo afecta a la autenticidad e integridad de la prueba.

Remediación

El reporte figura como resuelto. La corrección propuesta por el investigador consiste en añadir, en ambas funciones y justo después de que get_pruned_tx(res.txs[0], tx, tx_hash) tenga éxito, la misma guarda que ya usa check_tx_proof:

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

También recomendó un test de regresión (con un daemon de prueba o HTTP simulado) que devuelva una transacción cuyo id no coincida con el pedido, para comprobar que las dos funciones abortan antes de firmar o verificar.