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
txidque 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:
- Levantar un
monerod --regtestreal y unmonero-wallet-rpcreal. - Con la cartera conectada directamente al daemon honesto, minar bloques y hacer un
transfer. Anotar el txid real de ese gasto (transacción A). - Generar un txid aleatorio FAKE que no corresponde a ninguna transacción.
- Arrancar un pequeño proxy inverso en Python que reenvía todo al daemon honesto, salvo en
POST /gettransactions: si entxs_hashesaparece 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. - Apuntar la cartera al proxy con
set_daemon, usando"ssl_support":"disabled"para tráfico HTTP plano. - Llamar a
get_spend_proofcon el txid FAKE. La cartera devuelve una firmaSpendProofV1…. - Llamar a
check_spend_proofcon el txid FAKE y esa firma. El resultado esgood. - Como control, volver a apuntar la cartera al daemon honesto y pedir de nuevo
get_spend_proofpara FAKE: ahora falla (por ejemplo, porque faltatxso 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_proofgenere una prueba "válida" para un txid que el usuario nunca gastó.check_spend_proofdé por buena esa prueba, devolviendogood: 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.