Resumen
La cartera de Monero tiene una operación, wallet2::rescan_spent(), que pregunta al nodo (el daemon monerod) qué salidas de la cartera están gastadas y sobrescribe con esa respuesta el indicador local m_spent de cada una. Como el resultado depende por completo de lo que diga el nodo, el propio código de Monero solo la permite cuando el nodo está marcado como de confianza (is_trusted_daemon()).
Esa comprobación existe en todos los sitios que llaman a rescan_spent(): la orden interactiva de simplewallet, los pasos posteriores a importar datos multifirma (en simplewallet y en wallet_rpc_server) y el manejador hermano on_import_key_images. La única excepción era el manejador del método JSON-RPC rescan_spent de monero-wallet-rpc, wallet_rpc_server::on_rescan_spent (src/wallet/wallet_rpc_server.cpp:3493), que solo miraba si el servidor estaba en modo restringido (m_restricted), un ajuste de administración que no tiene nada que ver con la confianza en el nodo:
bool wallet_rpc_server::on_rescan_spent(const wallet_rpc::COMMAND_RPC_RESCAN_SPENT::request& req,
wallet_rpc::COMMAND_RPC_RESCAN_SPENT::response& res,
epee::json_rpc::error& er,
const connection_context *ctx)
{
if (!m_wallet) return not_open(er);
if (m_restricted) // wallet-RPC admin flag, not daemon trust
{
er.code = WALLET_RPC_ERROR_CODE_DENIED;
er.message = "Command unavailable in restricted mode.";
return false;
}
CHECK_IF_BACKGROUND_SYNCING();
try
{
m_wallet->rescan_spent(); // ← no trust check before this
return true;
}
...
}
En cambio, on_import_key_images sí lo exige:
if (!m_wallet->is_trusted_daemon()) // ← present here
{
er.code = WALLET_RPC_ERROR_CODE_UNKNOWN_ERROR;
er.message = "This command requires a trusted daemon.";
return false;
}
Y simplewallet (src/simplewallet/simplewallet.cpp:6386-6391) rechaza la orden con el mensaje this command requires a trusted daemon. Enable with --trusted-daemon.
Por defecto el nodo no se considera de confianza: es lo que pasa a quien arranca monero-wallet-rpc --daemon-address <remote-node> sin --trusted-daemon, que es el caso habitual de quien usa nodos públicos.
Por qué es un problema
Dentro de rescan_spent() (src/wallet/wallet2.cpp:7198-7252), la cartera manda sus key images al endpoint /is_key_image_spent del nodo y, si la respuesta no coincide con lo que tiene guardado, corrige el estado en los dos sentidos. El que interesa al atacante es el de "estaba gastada → pasa a no gastada":
if (td.m_spent)
{
LOG_PRINT_L0("Marking output " << i << "(" << td.m_key_image << ") as unspent, it was marked as spent");
set_unspent(i); // ← daemon-driven flip
td.m_spent_height = 0;
}
Todas las funciones que eligen entradas para una transacción nueva (pick_preferred_rct_inputs, select_outputs, select_available_outputs, pick_specific_output…) filtran con is_spent(td, false), que lee justo ese m_spent. No hay ninguna lista aparte de "salidas que acabo de gastar" que lo corrija. Así que, si el nodo miente, la cartera vuelve a usar una salida ya gastada como entrada real de otra transacción, con un anillo nuevo de señuelos elegidos al azar.
El reporte señala además que la respuesta de /is_key_image_spent trae un bit untrusted (lo pone el modo proxy de bootstrap), pero rescan_spent() no lo consulta, y en una conexión directa a un nodo remoto tampoco serviría: no hay nada en la respuesta que distinga una contestación honrada de una maliciosa. La señal de confianza válida es la configuración local de la cartera, is_trusted_daemon().
Pasos de reproducción
Condiciones previas:
- La víctima ejecuta
monero-wallet-rpc --daemon-address <remote_node>sin--trusted-daemon(el valor por defecto). - Algún cliente puede llamar al servidor RPC de la cartera (una interfaz gráfica, un script o el propio usuario).
- El nodo
<remote_node>lo controla el atacante: un operador de nodo público malicioso o comprometido, o alguien que intercepta una conexión RPC sin autenticar amonerod.
Cadena de ataque:
- La víctima envía una transacción
tx1que gasta la salidaO. La cartera la difunde y marcaOcomo gastada (set_spent,m_spent = true). - Un cliente llama al método JSON-RPC
rescan_spentdemonero-wallet-rpc. Según el reporte, muchas interfaces lo hacen de forma rutinaria para sincronizar el estado; el usuario también puede pedirlo a mano. on_rescan_spentno comprueba la confianza y llama awallet2::rescan_spent().- La cartera envía la key image de
Oal endpoint/is_key_image_spentdel nodo. - El nodo malicioso responde
UNSPENT, aunquetx1ya esté en su mempool o minada. - La cartera detecta la discrepancia y ejecuta
set_unspent(i):Ovuelve a contar como disponible. - La víctima hace un segundo envío
tx2, a cualquier destino y por cualquier importe. El selector de entradas escogeO. - La cartera construye
tx2con un anillo nuevo alrededor deO: la misma key image y señuelos distintos. - La cartera difunde
tx2al mismo nodo con/sendrawtransaction. - Para detectar el doble gasto, el nodo tiene que analizar
tx2, anillo incluido. La rechace o la reenvíe, ya tiene los dos anillos. - El atacante cruza
(anillo1, key image)con(anillo2, key image). Los señuelos son muestras aleatorias independientes, así que el único elemento común seguro esO, la entrada real.
Prueba de concepto propuesta
El investigador explica que el fallo se demuestra leyendo el código (la asimetría entre los manejadores) y propone este montaje para ver la escritura insegura en acción:
- Crear un intermediario que reenvíe a un
monerodreal todas las llamadas demonero-wallet-rpcsalvo/is_key_image_spent, a la que responde siempreUNSPENT. - Levantar un
monerodlocal en regtest, dar fondos a una cartera y enviar una transacción para que una salida quede conm_spenta verdadero. - Apuntar
monero-wallet-rpcal intermediario, sin--trusted-daemon. - Desde otro cliente, llamar al método JSON-RPC
rescan_spent. - Buscar en el log de
monero-wallet-rpcla líneaMarking output X as unspent, it was marked as spent(wallet2.cpp:7240), que confirma que el nodo ha cambiado el estado.
Repitiendo un envío con el mismo montaje se comprueba que la segunda transacción reutiliza la salida gastada como entrada real del anillo. Con simplewallet no se reproduce, porque su orden rescan_spent rechaza la operación sin nodo de confianza; hay que pasar por la interfaz JSON-RPC.
Impacto
- Se rompe la no vinculación de la firma de anillo para la entrada gastada: al cruzar los anillos de
tx1ytx2, el nodo identifica con alta probabilidad cuál es el gasto real. Es la propiedad central de privacidad de Monero. - El ataque se puede repetir en cada llamada a
rescan_spent; las interfaces o automatismos que lo usan a menudo le dan al nodo muchas oportunidades. - No hay pérdida directa de dinero:
tx2es un doble gasto que la red rechaza y los fondos siguen entx1. El daño es la pérdida de privacidad. - Afecta a quien usa
monero-wallet-rpccontra nodos públicos de la comunidad, expuesto al operador del nodo y a cualquiera que pueda interceptar la conexión.
Remediación
El reporte figura como resuelto en HackerOne. La corrección que propuso el investigador es añadir a on_rescan_spent la misma comprobación que ya hace on_import_key_images:
if (m_restricted)
{
er.code = WALLET_RPC_ERROR_CODE_DENIED;
er.message = "Command unavailable in restricted mode.";
return false;
}
+ if (!m_wallet->is_trusted_daemon())
+ {
+ er.code = WALLET_RPC_ERROR_CODE_UNKNOWN_ERROR;
+ er.message = "This command requires a trusted daemon.";
+ return false;
+ }
CHECK_IF_BACKGROUND_SYNCING();
Qué aprender de este caso
- Si una función delicada se llama desde varios sitios y casi todos la protegen con la misma comprobación (aquí
is_trusted_daemon()), busca con grep todos los que la llaman: el que no la tenga suele ser el fallo. Mejor aún, mete la comprobación dentro de la propia función (rescan_spent()) y no en cada llamada. - Que exista un control de acceso (
m_restricted) no significa que se esté comprobando lo que importa: confirma que cada control protege frente a la amenaza concreta, en este caso un nodo que miente, y no frente a otra. - Desconfía de cualquier código que sobrescriba el estado local con datos de un servidor ajeno, sobre todo si así se pierde algo que la cartera ya sabía con certeza (que ella misma gastó la salida). Un registro local de "gastado por mí" que el servidor no pueda borrar habría evitado que se reutilizara la salida.
- En sistemas de privacidad, revisa qué pasa si el cliente repite una operación ante el mismo observador: dos anillos para la misma key image bastan para desanonimizar la entrada, aunque la red rechace la segunda transacción.