Resumen
monerod, el nodo de Monero, mantiene para cada conexión P2P una cola de mensajes salientes (m_send_que, en contrib/epee/include/net/abstract_tcp_server2.inl). Cuando esa cola superaba ABSTRACT_SERVER_SEND_QUE_MAX_COUNT, el código no cortaba la conexión de inmediato: entraba en un bucle que dormía el hilo entre 250 y 300 ms por intento, hasta 20 reintentos (unos 6 segundos en total), y solo entonces cerraba la conexión.
El problema es que ese hilo es uno de los pocos que atienden la red P2P (10 por defecto). Si un atacante conseguía llenar la cola en varias conexiones a la vez, dejaba dormidos todos los hilos y el nodo dejaba de atender a sus pares. Es un caso de consumo de recursos no controlado (CWE-400), con severidad media según HackerOne.
Dos detalles lo agravaban:
- El mensaje
NOTIFY_REQUEST_GET_OBJECTSse atendía antes de completar el handshake, así que se podía saltar cualquier límite de conexiones entrantes. - Cada petición podía pedir hasta 500 bloques (
CURRENCY_PROTOCOL_MAX_OBJECT_REQUEST_COUNT), lo que además consumía mucha memoria, un problema añadido en máquinas con poca RAM.
Según el investigador, afectaba a todas las versiones de Monero, incluida la v0.15.0.5, y está relacionado con el reporte #506595. El reporte se envió el 17 de mayo de 2020.
Pasos de reproducción
El investigador describe el ataque así: abrir muchas conexiones P2P contra el nodo objetivo (sin completar el handshake) y enviar a la vez por todas ellas mensajes NOTIFY_REQUEST_GET_OBJECTS pidiendo 500 bloques cada uno, escogiendo bloques cuya respuesta sea lo bastante grande para llenar la cola. Por cada petición el nodo intenta responder y llena m_send_que; el hilo P2P que atiende esa conexión queda dormido unos 6 segundos. Con el ritmo adecuado, todos los hilos P2P acaban dormidos a la vez.
Para comprobarlo sobre un único hilo, el investigador sincronizó otro monerod v0.15.0.5 contra el objetivo con estos parámetros:
monerod --block-sync-size=500 --limit-rate-down=500 --add-exclusive-node <nodo objetivo>
Cuando la sincronización llegaba a una altura en la que los 500 bloques siguientes ocupaban más que la cola de envío del objetivo, el hilo P2P de este dormía 6 segundos y después cortaba la conexión. Lo probó con una cadena sincronizada hasta la altura 1675605, aunque indica que otras alturas también sirven.
Impacto
- El nodo se ralentizaba o dejaba de atender a sus pares mientras los hilos P2P estaban dormidos, y el efecto podía mantenerse de forma continuada.
- Como las peticiones se aceptaban antes del handshake, el límite de conexiones entrantes no protegía.
- Preparar respuestas de 500 bloques por conexión disparaba el consumo de memoria y podía agotarla en sistemas con poca RAM.
Remediación
El investigador propuso tres cambios, acompañados de un parche:
- Comprobar que el handshake se ha completado antes de enviar bloques.
- Reducir el número máximo de objetos por
NOTIFY_REQUEST_GET_OBJECTS. - Quitar el
sleepdel hilo P2P cuando la cola está llena y cortar la conexión directamente.
Cola llena: cierre inmediato en lugar de dormir y reintentar (abstract_tcp_server2.inl, fragmento):
- long int retry=0;
- const long int retry_limit = 5*4;
- while (m_send_que.size() > ABSTRACT_SERVER_SEND_QUE_MAX_COUNT)
- {
- retry++;
...
- long int ms = 250 + (rng() % 50);
- MDEBUG("Sleeping because QUEUE is FULL, in " << __FUNCTION__ << " for " << ms << " ms before packet_size="<<chunk.size()); // XXX debug sleep
- m_send_que_lock.unlock();
- boost::this_thread::sleep(boost::posix_time::milliseconds( ms ) );
- m_send_que_lock.lock();
...
- if (retry > retry_limit) {
- MWARNING("send que size is more than ABSTRACT_SERVER_SEND_QUE_MAX_COUNT(" << ABSTRACT_SERVER_SEND_QUE_MAX_COUNT << "), shutting down connection");
- shutdown();
- return false;
- }
+ if (m_send_que.size() > ABSTRACT_SERVER_SEND_QUE_MAX_COUNT)
+ {
+ MWARNING("send que size is more than ABSTRACT_SERVER_SEND_QUE_MAX_COUNT(" << ABSTRACT_SERVER_SEND_QUE_MAX_COUNT << "), shutting down connection");
+ shutdown();
+ return false;
}
Menos bloques por petición (cryptonote_protocol_handler.h):
-#define CURRENCY_PROTOCOL_MAX_OBJECT_REQUEST_COUNT 500
+#define CURRENCY_PROTOCOL_MAX_OBJECT_REQUEST_COUNT 20
Rechazar peticiones antes del handshake (cryptonote_protocol_handler.inl, en handle_request_get_objects):
MLOG_P2P_MESSAGE("Received NOTIFY_REQUEST_GET_OBJECTS (" << arg.blocks.size() << " blocks)");
+ if(context.m_state == cryptonote_connection_context::state_before_handshake) {
+ LOG_ERROR_CCONTEXT("Requested objects before handshake, dropping connection");
+ drop_connection(context, false, false);
+ return 1;
+ }
+
if (arg.blocks.size() > CURRENCY_PROTOCOL_MAX_OBJECT_REQUEST_COUNT)
El reporte figura como resuelto, pero no detalla qué versión incluyó la corrección ni si se aplicó tal cual este parche.
Qué aprender de este caso
- En servidores con un número fijo de hilos de red, busca cualquier
sleepo espera activa dentro del camino que atiende a un par remoto: si el par puede provocar esa espera (por ejemplo, llenando una cola de salida), puede bloquear el pool entero con pocas conexiones. - Ante una cola de salida llena, suele ser mejor cortar la conexión del cliente lento o abusivo al momento que "darle tiempo"; la espera castiga al servidor, no al atacante.
- Revisa qué mensajes del protocolo se procesan antes del handshake: cualquiera que genere trabajo o respuestas grandes en ese estado permite saltarse los límites de conexiones entrantes.
- Los límites de "objetos por petición" deben fijarse pensando en el tamaño de la respuesta, no solo en el número: 500 bloques pequeños no pesan lo mismo que 500 bloques grandes.