Reports
MEDIA[EXPOSICIÓN DE DATOS]#3719670

Monero: rescan_spent de monero-wallet-rpc se fía de un nodo no confiable y permite desanonimizar un gasto

El método JSON-RPC rescan_spent de monero-wallet-rpc no comprobaba si el nodo era de confianza. Un nodo remoto malicioso podía marcar como no gastada una salida ya gastada y, al cruzar dos anillos con la misma key image, identificar la entrada real.

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 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:

  1. La víctima ejecuta monero-wallet-rpc --daemon-address <remote_node> sin --trusted-daemon (el valor por defecto).
  2. Algún cliente puede llamar al servidor RPC de la cartera (una interfaz gráfica, un script o el propio usuario).
  3. 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 a monerod.

Cadena de ataque:

  1. La víctima envía una transacción tx1 que gasta la salida O. La cartera la difunde y marca O como gastada (set_spent, m_spent = true).
  2. Un cliente llama al método JSON-RPC rescan_spent de monero-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.
  3. on_rescan_spent no comprueba la confianza y llama a wallet2::rescan_spent().
  4. La cartera envía la key image de O al endpoint /is_key_image_spent del nodo.
  5. El nodo malicioso responde UNSPENT, aunque tx1 ya esté en su mempool o minada.
  6. La cartera detecta la discrepancia y ejecuta set_unspent(i): O vuelve a contar como disponible.
  7. La víctima hace un segundo envío tx2, a cualquier destino y por cualquier importe. El selector de entradas escoge O.
  8. La cartera construye tx2 con un anillo nuevo alrededor de O: la misma key image y señuelos distintos.
  9. La cartera difunde tx2 al mismo nodo con /sendrawtransaction.
  10. Para detectar el doble gasto, el nodo tiene que analizar tx2, anillo incluido. La rechace o la reenvíe, ya tiene los dos anillos.
  11. 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 es O, 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:

  1. Crear un intermediario que reenvíe a un monerod real todas las llamadas de monero-wallet-rpc salvo /is_key_image_spent, a la que responde siempre UNSPENT.
  2. Levantar un monerod local en regtest, dar fondos a una cartera y enviar una transacción para que una salida quede con m_spent a verdadero.
  3. Apuntar monero-wallet-rpc al intermediario, sin --trusted-daemon.
  4. Desde otro cliente, llamar al método JSON-RPC rescan_spent.
  5. Buscar en el log de monero-wallet-rpc la línea Marking 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 tx1 y tx2, 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: tx2 es un doble gasto que la red rechaza y los fondos siguen en tx1. El daño es la pérdida de privacidad.
  • Afecta a quien usa monero-wallet-rpc contra 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.