Resumen
monero-wallet-rpc puede conectarse a un nodo (monerod) remoto por TLS y fijar el certificado que espera de ese nodo mediante una lista de huellas SHA-256. Hay dos formas de dar esa lista: al arrancar, con la opción --daemon-ssl-allowed-fingerprints, o en caliente, con el método JSON-RPC set_daemon y su parámetro ssl_allowed_fingerprints. Ambas aceptan el mismo formato documentado (la huella en hexadecimal, 64 caracteres), pero solo la primera funcionaba.
El fallo estaba en el manejador de set_daemon en src/wallet/wallet_rpc_server.cpp. En vez de decodificar el hexadecimal, copiaba cada carácter de la cadena como si fuese un byte:
std::vector<std::vector<uint8_t>> ssl_allowed_fingerprints;
ssl_allowed_fingerprints.reserve(req.ssl_allowed_fingerprints.size());
for (const std::string &fp: req.ssl_allowed_fingerprints)
{
ssl_allowed_fingerprints.push_back({});
std::vector<uint8_t> &v = ssl_allowed_fingerprints.back();
for (auto c: fp)
v.push_back(c);
}
Así, una huella correcta acababa guardada como 64 bytes de texto ASCII en lugar de los 32 bytes del resumen SHA-256. Más tarde, has_fingerprint() compara el SHA-256 real del certificado del nodo (32 bytes) con esas entradas de 64 bytes usando std::binary_search, de modo que la comparación no puede salir bien nunca.
Eso por sí solo debería cortar la conexión, pero aquí entra el segundo ingrediente. El valor por defecto de ssl_support en set_daemon es "autodetect" (KV_SERIALIZE_OPT(ssl_support, (std::string)"autodetect") en wallet_rpc_server_commands_defs.h), y la verificación en contrib/epee/src/net_ssl.cpp solo rechaza la conexión si el modo no es ese:
if (!verified && !has_fingerprint(ctx))
{
if (support != ssl_support_t::e_ssl_support_autodetect)
{
MERROR("SSL certificate is not in the allowed list, connection dropped");
return false;
}
MWARNING("SSL peer has not been verified");
}
return true;
Resultado: con autodetect, que ninguna huella coincida se convierte en un simple aviso y la conexión se acepta. El anclaje se descarta sin que nada lo indique.
La vía de arranque, en cambio, sí lo hacía bien en src/wallet/wallet2.cpp: decodifica con epee::from_hex_locale::to_vector y exige que cada huella mida exactamente SSL_FINGERPRINT_SIZE (32 bytes):
std::vector<std::vector<uint8_t>> ssl_allowed_fingerprints{ daemon_ssl_allowed_fingerprints.size() };
std::transform(daemon_ssl_allowed_fingerprints.begin(), daemon_ssl_allowed_fingerprints.end(),
ssl_allowed_fingerprints.begin(), epee::from_hex_locale::to_vector);
for (const auto &fpr: ssl_allowed_fingerprints)
{
THROW_WALLET_EXCEPTION_IF(fpr.size() != SSL_FINGERPRINT_SIZE, tools::error::wallet_internal_error,
"SHA-256 fingerprint should be " BOOST_PP_STRINGIZE(SSL_FINGERPRINT_SIZE) " bytes long.");
}
Según el investigador, el problema existía desde que se añadió el RPC set_daemon (commit 67aa4adcf, marzo de 2019): todas las versiones de la v0.14.x a la v0.18.x y master en el commit 230de3794. Comprobó directamente las ramas release-v0.17 y release-v0.18, y también lo reprodujo con el paquete monero 0.17.2.0 de Ubuntu 22.04. HackerOne lo clasifica como validación incorrecta de certificados, severidad alta.
Pasos de reproducción
El investigador lo automatizó en un script (poc_set_daemon_pinning_bypass.sh) que no necesita sudo en Ubuntu 22.04. Lo que hace, paso a paso:
- Extrae
monerodymonero-wallet-rpcdel paquete.debde Ubuntu a/tmp/monero_extract(conapt-get downloadydpkg-deb -x). - Genera dos certificados autofirmados distintos:
real.crt(huella$FP_REAL) yevil.crt(huella$FP_EVIL). - Arranca dos nodos en regtest, ambos con
--offlinepara que no se comuniquen entre sí: el legítimo en127.0.0.1:48080conreal.crty el malicioso en127.0.0.1:49080conevil.crt. - Arranca
monero-wallet-rpc, crea una cartera nueva y mina 5 bloques solo en el nodo malicioso. Consultandoget_block_countdirectamente, el legítimo devuelve1y el malicioso6. - Situación de partida: fija la cartera a
$FP_REALy la apunta al nodo legítimo. Trasrefresh,get_heightdevuelve 1, lo esperado. - Vuelve a llamar a
set_daemon, ahora apuntando al nodo malicioso pero manteniendossl_allowed_fingerprints=[$FP_REAL]yssl_support="autodetect". Elrefreshresponde:
``json {"result":{"blocks_fetched":5,"received_money":true}} ``
y get_height devuelve 6: la cartera, supuestamente anclada al certificado legítimo, ha aceptado la cadena del nodo malicioso. En el log solo aparece:
`` WARNING net.ssl contrib/epee/src/net_ssl.cpp SSL peer has not been verified ``
Ninguna línea del log indica que la lista de huellas fuese incorrecta o se hubiese ignorado.
- Como contraste, reinicia el mismo binario usando la opción de línea de órdenes con la misma huella:
`` monero-wallet-rpc --daemon-address 127.0.0.1:49080 --daemon-ssl enabled \ --daemon-ssl-allowed-fingerprints "$FP_REAL" ``
Ahora refresh falla como debe:
``json {"error":{"code":-38,"message":"no connection to daemon"}} ``
y el log muestra SSL certificate is not in the allowed list, connection dropped. Mismo binario, misma huella y mismo nodo: lo único que cambia es por qué decodificador pasa la configuración.
Las llamadas esenciales, a mano:
FP_REAL=$(openssl x509 -noout -fingerprint -sha256 -in real.crt \
| sed 's/.*=//' | tr -d ':' | tr '[:upper:]' '[:lower:]')
# baseline
curl -s http://127.0.0.1:48090/json_rpc -H 'Content-Type: application/json' -d "$(cat <<EOF
{"jsonrpc":"2.0","id":"0","method":"set_daemon","params":{
"address":"127.0.0.1:48080","trusted":true,"ssl_support":"autodetect",
"ssl_allowed_fingerprints":["$FP_REAL"]}}
EOF
)"
# bypass
curl -s http://127.0.0.1:48090/json_rpc -H 'Content-Type: application/json' -d "$(cat <<EOF
{"jsonrpc":"2.0","id":"0","method":"set_daemon","params":{
"address":"127.0.0.1:49080","trusted":true,"ssl_support":"autodetect",
"ssl_allowed_fingerprints":["$FP_REAL"]}}
EOF
)"
curl -s http://127.0.0.1:48090/json_rpc -d '{"jsonrpc":"2.0","id":"0","method":"refresh"}' -H 'Content-Type: application/json'
curl -s http://127.0.0.1:48090/json_rpc -d '{"jsonrpc":"2.0","id":"0","method":"get_height"}' -H 'Content-Type: application/json'
Impacto
Quien usara set_daemon para anclar el certificado de su nodo (algo habitual al automatizar monero-wallet-rpc contra un nodo remoto, por ejemplo en exchanges, pasarelas de pago o pools, según el investigador) no tenía en realidad ningún anclaje. Un atacante situado en la red entre la cartera y el nodo podía presentar cualquier certificado autofirmado, completar el handshake y hacer de intermediario en el canal, que transporta, entre otras cosas:
- las credenciales HTTP digest del nodo;
- las consultas
/get_outsy las de comprobación de key images gastadas; - las respuestas con señuelos para los anillos que se usan al construir transacciones;
- las transacciones firmadas enviadas para su difusión;
- las órdenes de control de
/start_mining.
Además, el operador no tenía forma de notarlo: el log solo mostraba el mismo aviso SSL peer has not been verified que sale en cualquier conexión autodetect, sin indicar que la lista de huellas se había descartado.
Remediación
El reporte figura como resuelto. La corrección que propuso el investigador es que el manejador del RPC haga lo mismo que la vía de arranque: decodificar cada huella como hexadecimal y rechazar la petición si no mide 32 bytes.
std::vector<std::vector<uint8_t>> ssl_allowed_fingerprints;
ssl_allowed_fingerprints.reserve(req.ssl_allowed_fingerprints.size());
for (const std::string &fp : req.ssl_allowed_fingerprints)
{
std::vector<uint8_t> decoded;
try { decoded = epee::from_hex_locale::to_vector(fp); }
catch (const std::exception &) {
er.code = WALLET_RPC_ERROR_CODE_NO_DAEMON_CONNECTION;
er.message = "ssl_allowed_fingerprints[] entries must be hex-encoded SHA-256";
return false;
}
if (decoded.size() != SSL_FINGERPRINT_SIZE) {
er.code = WALLET_RPC_ERROR_CODE_NO_DAEMON_CONNECTION;
er.message = "Each fingerprint must decode to exactly 32 bytes";
return false;
}
ssl_allowed_fingerprints.emplace_back(std::move(decoded));
}
También sugirió medidas de defensa en profundidad:
- Que el constructor
ssl_options_t(std::vector<std::vector<uint8_t>>, std::string)decontrib/epee/src/net_ssl.cpprechace cualquier entrada que no midaSSL_FINGERPRINT_SIZE, para cubrir a futuros llamantes que olviden decodificar. - Que
has_strong_verification()no dé por "fuerte" la verificación solo por el valor del enum, sino que exija además que todas las entradas tengan el tamaño correcto. - Que, si hay huellas configuradas y
has_fingerprint()falla, se registre unMERRORcon los tamaños de las entradas guardadas frente al del resumen calculado, para distinguir un certificado rotado de una lista descartada.
El original no detalla qué cambio concreto se aplicó finalmente.
Qué aprender de este caso
- Cuando una misma opción de seguridad se puede configurar por dos caminos (línea de órdenes y API), compara el código de ambos: aquí la opción de arranque decodificaba el hexadecimal y el RPC copiaba caracteres, con el mismo formato de entrada y resultados opuestos.
- Al recibir huellas, claves o hashes como texto, decodifícalos y comprueba la longitud resultante (32 bytes para SHA-256) en el mismo punto de entrada; una entrada de 64 bytes que nunca puede coincidir es un síntoma claro de que falta la decodificación.
- Desconfía de los modos "autodetect" o "best effort" en TLS: si el usuario ha configurado un anclaje, que no coincida debería cortar la conexión siempre, no rebajarse a un aviso.
- Si una comprobación de seguridad falla por un error de configuración, el log tiene que decirlo de forma distinta a un fallo normal; aquí el anclaje roto era indistinguible de una conexión sin anclar.