Reports
BAJA[APIS]#3687543

Monero: relay_tx en monero-wallet-rpc se salta --restricted-rpc y corrompe el estado de la cartera

El método relay_tx de monero-wallet-rpc no comprobaba el modo restringido y aceptaba un pending_tx del llamante sin verificarlo, lo que permitía congelar salidas de la cartera víctima y plantar claves de transacción ajenas.

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

monero-wallet-rpc puede arrancarse con --restricted-rpc, un modo pensado para dar acceso limitado a la cartera (por ejemplo a un agente de monitorización, una integración de pagos o un pipeline de CI) sin permitir operaciones que muevan fondos. En el commit 604bb05d8 (PR #10378, fusionado el 23 de marzo de 2026) se añadió una comprobación m_restricted a todos los métodos de tipo transferencia: transfer, sweep_all, transfer_split, sign_transfer, submit_transfer y sweep_dust. Uno se quedó fuera: relay_tx.

El fallo está en el manejador on_relay_tx de src/wallet/wallet_rpc_server.cpp (rama master, commit 230de3794, 20 de abril de 2026), y son dos problemas que se suman:

  1. No comprueba el modo restringido. Cualquiera con credenciales restringidas puede invocarlo.
  2. Confía ciegamente en el pending_tx recibido. El método toma un blob hexadecimal del llamante, lo deserializa como wallet2::pending_tx y lo pasa tal cual a wallet2::commit_tx, sin comprobar que esa transacción la haya generado la propia cartera.

El manejador vulnerable carece del guardia que sí tienen sus hermanos. Donde los demás empiezan rechazando la llamada en modo restringido, on_relay_tx pasa directo del if (!m_wallet) a deserializar el blob y llamar a commit_tx con él.

El segundo problema está en commit_tx (wallet2.cpp). Tras aceptar el daemon la transacción, la función modifica el estado de la cartera usando los índices de selected_transfers que vienen en el blob, y lo único que comprueba es que estén dentro del rango de la lista de transferencias (idx >= m_transfers.size()), no que correspondan a las entradas reales de la transacción. Con esos índices:

  • escribe la transacción en m_unconfirmed_txs,
  • guarda el tx_key del blob en m_tx_keys[txid],
  • marca esas salidas como gastadas con set_spent(),
  • y borra de forma irreversible los nonces multifirma (m_multisig_k) de esos índices.

En ningún punto se verifica que m_transfers[idx].m_key_image aparezca entre las entradas de la transacción (ptx.tx.vin). Así, el llamante puede enviar una transacción perfectamente válida que gasta sus propios fondos, pero indicar en selected_transfers índices de salidas de la cartera víctima. El daemon la acepta porque es criptográficamente correcta; la incoherencia solo afecta a la memoria de la cartera, que es justo donde hace daño.

Pasos de reproducción

El investigador aportó un script (poc_relay_tx_state_corruption.sh) que monta un entorno regtest local y completa el caso de principio a fin. A grandes rasgos:

  1. Arranca un monerod --regtest en loopback, sin red.
  2. Crea dos carteras: wallet_a (atacante, sin restricciones) y wallet_b (víctima, con --restricted-rpc).
  3. Mina bloques hacia ambas carteras para que tengan salidas disponibles.
  4. En wallet_a llama a transfer con do_not_relay=true y get_tx_key=true para obtener un pending_tx firmado pero sin difundir, del que el atacante controla todos los campos.
  5. Envía ese blob al relay_tx de wallet_b. La llamada transfer en wallet_b se rechaza por estar en modo restringido (código -7, "Command unavailable in restricted mode."), pero relay_tx no: se ejecuta y commit_tx corre contra la lista de transferencias de la víctima.
  6. Consulta incoming_transfers y get_tx_key en wallet_b para confirmar la corrupción.

La salida del script confirma los tres efectos: relay_tx tiene éxito en la cartera restringida, dos salidas de la víctima pasan de "unspent" a "SPENT (frozen)", y el tx_key almacenado para esa transacción en la cartera víctima coincide con el del atacante.

Impacto

Un operador que ejecuta monero-wallet-rpc con --restricted-rpc —el modo que se supone seguro para dar acceso acotado— no tenía ninguna protección frente a este método. Cualquier proceso con credenciales restringidas podía llamar a relay_tx y disparar commit_tx sobre un blob manipulado. Si el atacante puede difundir una transacción válida (con sus propios fondos), consigue tres efectos concretos contra la cartera víctima:

  • Congelación de fondos. set_spent() marca como gastadas las salidas cuyos índices indique el blob. Quedan congeladas en la caché de la cartera y no se usarán en futuras transacciones hasta ejecutar rescan_blockchain. En una cartera caliente que procesa pagos, esto hace que los envíos fallen.
  • Envenenamiento del tx_key. commit_tx guarda el tx_key del atacante en m_tx_keys[txid]. Cualquier get_tx_key o get_tx_proof para ese txid devuelve material de clave controlado por el atacante. Un comercio que use get_tx_proof para verificar un pago recibiría una prueba que valida correctamente contra el txid pero generada con una clave que no es la suya, suficiente para falsificar confirmaciones de pago en cualquier sistema que confíe en las pruebas de wallet-rpc.
  • Rotura de multifirma. commit_tx borra incondicionalmente m_multisig_k de los índices seleccionados. Esos nonces de firma por transferencia no se pueden regenerar: una ronda de firma multifirma activa que dependiera de ellos queda rota de forma permanente y los cofirmantes tendrían que reexportar y reimportar claves.

El programa clasificó la severidad como baja ("low"); la debilidad se registró como "Improper Access Control - Generic".

Remediación

La corrección directa es añadir a on_relay_tx el mismo guardia que ya tenían los demás métodos, justo tras la comprobación !m_wallet:

if (m_restricted)
{
    er.code = WALLET_RPC_ERROR_CODE_DENIED;
    er.message = "Command unavailable in restricted mode.";
    return false;
}

Eso cierra el salto del modo restringido. Como defensa en profundidad para el problema de fondo (que commit_tx confía en selected_transfers sin contrastarlo con las entradas reales de la transacción), el investigador propone verificar en el propio relay_tx, antes de llamar a commit_tx, que el número de selected_transfers coincide con el de entradas y que para cada índice la imagen de clave de la transferencia (m_key_image) coincide con la de la entrada correspondiente (k_image), rechazando la petición si no cuadra. Arreglar commit_tx directamente es más delicado porque también lo llaman rutas legítimas en las que la propia cartera construyó la transacción.

Qué aprender de este caso

  • Cuando se endurece un conjunto de endpoints "sensibles" (aquí con el guardia m_restricted), hay que auditar todos los que mutan el mismo estado, no solo los más evidentes: relay_tx quedó fuera del parche aunque acaba llamando a la misma función que transfer. Una forma de encontrar huecos así es listar todos los llamantes de la función peligrosa (commit_tx) y comprobar que cada uno tiene el control de acceso.
  • Deserializar una estructura completa que viene del cliente y pasarla a una función que modifica estado interno es un patrón de riesgo: revisa qué campos de ese objeto se usan como índices o claves (selected_transfers, tx_key) y si se validan contra la realidad del objeto. Una comprobación de rango no es una comprobación de pertenencia.
  • En software de carteras y pagos, un fallo de "solo" corromper el estado local (congelar salidas, plantar claves) puede escalar a fraude si otros sistemas confían en sus salidas: aquí get_tx_proof convierte el envenenamiento del tx_key en pruebas de pago falsificables. Al diseñar verificaciones de pago, no confíes en que una prueba validada contra un txid la haya generado tu propia cartera.