Reports
MEDIA[TLS Y CRIPTOGRAFÍA]#3698862

Monero: check_reserve_proof sumaba importes RingCT sin comprobar el compromiso de la salida

La verificación de pruebas de reservas del monedero de Monero descifraba el importe de cada salida RingCT y lo sumaba sin compararlo con su compromiso de Pedersen, lo que permitía aparentar más fondos de los reales.

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

Las reserve proofs de Monero sirven para demostrar a un tercero (un exchange, un auditor, un custodio…) cuántos fondos controla un monedero. La función que las verifica, wallet2::check_reserve_proof (en src/wallet/wallet2.cpp), hacía bien la primera mitad del trabajo: comprobaba que cada salida incluida en la prueba pertenece de verdad al monedero que la presenta. El problema estaba en la segunda mitad.

Para las salidas RingCT, el importe no está en claro: va cifrado en el campo ecdhInfo de la transacción. La función lo descifraba con ecdhDecode(), lo convertía con h2d() y lo sumaba al total… sin más. Faltaba el paso criptográfico que da validez a ese número: recalcular el compromiso de Pedersen a partir de la máscara y el importe descifrados (mask*G + amount*H) y compararlo con el compromiso real de la salida, tx.rct_signatures.outPk[n].mask, que es lo que el consenso de la red ha verificado.

El fragmento afectado (alrededor de las líneas 12751–12764, en master @ 3ad4a5ee8, v0.18.1.0-3ad4a5ee8):

uint64_t amount = tx.vout[proof.index_in_tx].amount;
if (amount == 0)
{
  crypto::secret_key shared_secret;
  crypto::derivation_to_scalar(derivation, proof.index_in_tx, shared_secret);
  rct::ecdhTuple ecdh_info = tx.rct_signatures.ecdhInfo[proof.index_in_tx];
  rct::ecdhDecode(ecdh_info, rct::sk2rct(shared_secret),
      tx.rct_signatures.type == rct::RCTTypeBulletproof2 ||
      tx.rct_signatures.type == rct::RCTTypeCLSAG ||
      tx.rct_signatures.type == rct::RCTTypeBulletproofPlus);
  amount = rct::h2d(ecdh_info.amount);   // no C == mask*G + amount*H check
}
total += amount;

Lo llamativo es que otras partes del código ya hacían esta comprobación. Cuando el monedero recibe fondos, no se fía del importe descifrado si no cuadra con el compromiso:

const rct::key C = tx.rct_signatures.outPk[n].mask;
rct::key Ctmp;
rct::addKeys2(Ctmp, ecdh_info.mask, ecdh_info.amount, rct::H);
if (rct::equalKeys(C, Ctmp))
  amount = rct::h2d(ecdh_info.amount);
else
  amount = 0;

Y la función de bajo nivel decodeRctSimple() (en src/ringct/rctSigs.cpp) es aún más estricta: lanza una excepción con el mensaje warning, amount decoded incorrectly, will be unable to spend si el importe no encaja. El verificador de reservas, en cambio, se quedaba con la versión débil: autenticaba la propiedad de la salida, pero no el importe que leía de ella, un dato que controla quien genera la prueba.

Pasos de reproducción

El investigador no montó un ataque de extremo a extremo contra la red; lo demostró con una prueba unitaria que reproduce la lógica de descifrado que usa check_reserve_proof, sin tocar wallet2.cpp ni el verificador.

  1. Añadir esta prueba en tests/unit_tests/ringct.cpp. Crea una salida cuyo compromiso corresponde a un importe real de 1, pero cuyos metadatos ECDH cifran un importe falso de 1000000000000:
TEST(ringct, reserveProofStyleDecodeNeedsCommitmentCheck)
{
  static constexpr uint64_t real_amount = 1;
  static constexpr uint64_t fake_amount = 1000000000000;

  const rct::key amount_key = rct::skGen();
  const rct::key commitment_mask = rct::genCommitmentMask(amount_key);
  rct::key output_commitment;
  rct::addKeys2(output_commitment, commitment_mask, rct::d2h(real_amount), rct::H);

  rct::ecdhTuple encoded = {};
  encoded.amount = rct::d2h(fake_amount);
  rct::ecdhEncode(encoded, amount_key, true);

  rct::ecdhTuple reserve_proof_style_decode = encoded;
  rct::ecdhDecode(reserve_proof_style_decode, amount_key, true);
  ASSERT_EQ(fake_amount, rct::h2d(reserve_proof_style_decode.amount));

  rct::key recomputed_commitment;
  rct::addKeys2(recomputed_commitment, reserve_proof_style_decode.mask,
      reserve_proof_style_decode.amount, rct::H);
  ASSERT_NE(output_commitment, recomputed_commitment);

  rct::rctSig rv;
  rv.type = rct::RCTTypeCLSAG;
  rv.ecdhInfo.push_back(encoded);
  rv.outPk.push_back({});
  rv.outPk[0].mask = output_commitment;

  rct::key decoded_mask;
  EXPECT_THROW(
    (void)rct::decodeRctSimple(rv, amount_key, 0, decoded_mask, hw::get_device("default")),
    std::runtime_error
  );
}
  1. Compilar el proyecto con las pruebas activadas y ejecutar solo esa prueba (la ruta de origen es la del equipo del investigador):
cmake -S /home/[REDACTADO]/Monero/monero -B /tmp/monero-official-build \
  -D CMAKE_BUILD_TYPE=Release -D BUILD_TESTS=ON -D MANUAL_SUBMODULES=1
cmake --build /tmp/monero-official-build -j"$(nproc)" --target unit_tests

/tmp/monero-official-build/tests/unit_tests/unit_tests \
  --gtest_filter=ringct.reserveProofStyleDecodeNeedsCommitmentCheck
  1. Comprobar que la prueba pasa:
Note: Google Test filter = ringct.reserveProofStyleDecodeNeedsCommitmentCheck
[==========] Running 1 test from 1 test suite.
[----------] Global test environment set-up.
[----------] 1 test from ringct
[ RUN      ] ringct.reserveProofStyleDecodeNeedsCommitmentCheck
2026-04-27 16:12:56.768 E warning, amount decoded incorrectly, will be unable to spend
[       OK ] ringct.reserveProofStyleDecodeNeedsCommitmentCheck (3 ms)
[----------] 1 test from ringct (3 ms total)

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

Que pase demuestra cuatro cosas a la vez: los metadatos ECDH pueden descifrarse como 1000000000000; ese importe no corresponde al compromiso real de la salida (que es de 1); decodeRctSimple() sí detecta la discrepancia; y cualquier verificador que se limite a ecdhDecode() + h2d() y sume el resultado, como hacía check_reserve_proof, se la traga.

Impacto

No se trata de robar fondos, sino de fingir solvencia. Si un atacante consigue una salida confirmada en la cadena con esta forma y la incluye en una prueba de reservas, el verificador oficial podría atribuirle más fondos de los que realmente respaldan los compromisos. Un exchange, un custodio de un puente, un prestamista, una mesa OTC o un auditor que se apoyase en check_reserve_proof podría aceptar como garantía o como reservas dinero que no existe.

El propio investigador acota el alcance: la evidencia es la prueba unitaria, no un exploit completo contra la red. Lo demostrado es que el verificador de reservas usaba un camino de descifrado más débil que el del propio monedero.

Datos del reporte:

  • Debilidad: falta de un paso criptográfico obligatorio (CWE-325).
  • Severidad: media.
  • Enviado el 27 de abril de 2026 y divulgado el 5 de agosto de 2026; el programa lo marcó como resuelto.

Remediación

El reporte figura como resuelto, pero no detalla el cambio aplicado por el equipo de Monero. La corrección que propuso el investigador consiste en aplicar en check_reserve_proof la misma comprobación que ya usan el camino de recepción del monedero y decodeRctSimple(): validar que la máscara y el importe descifrados son escalares válidos, recalcular el compromiso y rechazar la prueba si no coincide con outPk:

crypto::secret_key shared_secret;
crypto::derivation_to_scalar(derivation, proof.index_in_tx, shared_secret);

rct::ecdhTuple ecdh_info = tx.rct_signatures.ecdhInfo[proof.index_in_tx];
const bool v2 =
    tx.rct_signatures.type == rct::RCTTypeBulletproof2 ||
    tx.rct_signatures.type == rct::RCTTypeCLSAG ||
    tx.rct_signatures.type == rct::RCTTypeBulletproofPlus;
rct::ecdhDecode(ecdh_info, rct::sk2rct(shared_secret), v2);

THROW_WALLET_EXCEPTION_IF(sc_check(ecdh_info.mask.bytes) != 0,
    error::wallet_internal_error, "Bad ECDH input mask");
THROW_WALLET_EXCEPTION_IF(sc_check(ecdh_info.amount.bytes) != 0,
    error::wallet_internal_error, "Bad ECDH input amount");

rct::key Ctmp;
rct::addKeys2(Ctmp, ecdh_info.mask, ecdh_info.amount, rct::H);
if (!rct::equalKeys(tx.rct_signatures.outPk[proof.index_in_tx].mask, Ctmp))
  return false;

amount = rct::h2d(ecdh_info.amount);

Además recomendó añadir una prueba de regresión parecida a la anterior (y, a ser posible, otra a nivel de monedero) y corregirlo junto con otro problema de la misma función: rechazar entradas duplicadas, tanto imágenes de clave repetidas como pares (txid, index_in_tx) repetidos, antes de buscar transacciones y sumar importes.