11°
Portada del artículo: PhantomRelay: por qué tu automatización se delata antes del primer byte HTTP
SistemasRedes

PhantomRelay: por qué tu automatización se delata antes del primer byte HTTP

Un cliente HTTP de Node es distinguible de Chrome real en el handshake TLS, antes de enviar una sola cabecera. Cómo construí un relay de navegador con un addon Rust sobre BoringSSL, escalación por costo y casi 900 tests.

Efrain Garay 18 de agosto de 2026

Reproduciendo el resumen

Tienes una suite de pruebas end-to-end que corre contra tu propio staging. Un día pones un WAF delante y la suite empieza a fallar: no por un bug tuyo, sino porque el WAF decidió que tu automatización no parece un navegador. Y tiene razón, aunque no por lo que uno esperaría. No la detectó por el User-Agent ni por navigator.webdriver. La detectó antes: en el handshake TLS, antes de que se enviara la primera cabecera HTTP.

PhantomRelay nació de ese problema.

PhantomRelay en 24 segundos: el saludo TLS que te delata, por qué OpenSSL y BoringSSL no producen los mismos bytes, y por qué la solución fue heredar en vez de imitar. Sin sonido por defecto: actívalo en los controles.Verlo en el visualizador de reels →

El ClientHello te delata

Cuando abres una conexión TLS, lo primero que envías es un ClientHello: un mensaje en texto plano (aún no hay cifrado) que anuncia qué versiones soportas, qué cipher suites prefieres y en qué orden, qué extensiones traes, qué curvas elípticas aceptas y qué protocolos anuncias por ALPN.

Ese mensaje no es igual en todos los clientes, y ahí está el problema: su estructura depende de la librería TLS que lo genera. Node.js usa OpenSSL. Chrome usa BoringSSL, un fork propio de Google. Aunque configures ambos para “lo mismo”, el byte stream sale distinto.

Un observador toma ese ClientHello, lo resume en un hash, y ya sabe con qué está hablando. Eso es JA3, que hashea la lista tal como viaja, en su orden original.

La misma pregunta, hecha por dos clientesCapturado el 18 de agosto de 2026 contra tls.browserleaks.com/json, desde la misma máquina, con minutos de diferencia.

JA4

t13d1516h2_8daaf6152771_806a8c22fdea

  • tTCPsobre TCP, no QUIC
  • 13TLS 1.3la versión negociada más alta
  • dcon SNIanuncia el dominio en claro
  • 15cipherscuántos cifrados ofrece
  • 16extensionescuántas extensiones trae
  • h2ALPNprimera y última letra del ALPN

Quince cipher suites y dieciséis extensiones. La parte b, 8daaf6152771, es exactamente la que FoxIO publica como huella de Chrome en su propio repositorio.

JA3
2bcce27ad3a76dede43f4e8775366cb0
HTTP/2 SETTINGS
1:65536;2:0;4:6291456;6:262144

JA4

t13d4907h2_0d8feac7bc37_7395dae3b2f3

  • tTCPsobre TCP, no QUIC
  • 13TLS 1.3la versión negociada más alta
  • dcon SNIanuncia el dominio en claro
  • 49cipherscuántos cifrados ofrece
  • 07extensionescuántas extensiones trae
  • h2ALPNprimera y última letra del ALPN

Cuarenta y nueve cipher suites y siete extensiones. Ofrece tres veces más cifrados y menos de la mitad de extensiones: no hace falta comparar hashes, los conteos ya lo separan.

JA3
2bab0327a296230f9f6427341e716ea0
HTTP/2 SETTINGS
sin HTTP/2 negociado en esta captura

JA4

t13d1516h2_acb858a92679_28ef7e58d5d7

  • tTCPsobre TCP, no QUIC
  • 13TLS 1.3la versión negociada más alta
  • dcon SNIanuncia el dominio en claro
  • 15cipherscuántos cifrados ofrece
  • 16extensionescuántas extensiones trae
  • h2ALPNprimera y última letra del ALPN

Mismo cliente y mismo handshake que la primera ficha. La parte a no se mueve, pero b y c son otras: si no se ordena, el barajado de Chrome cambia la huella en cada conexión. Ordenar es lo que la vuelve estable.

JA3
mismo cliente, mismo handshake
HTTP/2 SETTINGS
1:65536;2:0;4:6291456;6:262144

JA4, su sucesor, hace justo lo contrario de lo que uno esperaría: ordena las cipher suites y las extensiones en hexadecimal antes de hashearlas. Suena a perder información, y es deliberado. En 2023 Chrome empezó a barajar el orden de sus extensiones en cada conexión, precisamente para que nadie lo rastreara por JA3, y de paso para que los servidores no construyeran lógica asumiendo una huella fija. Ordenar neutraliza esa defensa. La entropía que JA4 pierde al ordenar la recupera por otro lado: suma los algoritmos de firma, el ALPN y los conteos de ciphers y extensiones.

Las diferencias que importan son cuatro:

  1. Qué cipher suites ofreces. Para JA3, además, en qué orden las prefieres; JA4 solo mira el conjunto. Cada stack tiene el suyo.
  2. Qué extensiones traes, y cuántas. Aquí está el muro real, y no es el orden: es que Node no te deja elegir el juego de extensiones que OpenSSL emite ni los algoritmos de firma que anuncia. El conteo entra en el hash tal cual, así que una extensión de más o de menos ya te separa de Chrome por mucho que aciertes en todo lo demás.
  3. ALPN. Chrome anuncia HTTP/2 antes de HTTP/1.1. Muchos clientes anuncian uno solo, o al revés, y eso entra en el hash.
  4. GREASE (RFC 8701). Chrome inserta valores reservados pseudoaleatorios entre sus ciphers y extensiones, a propósito, para que los middleboxes no se osifiquen asumiendo que la lista nunca cambia. Un stack que no los emite tiene una silueta distinta.

La decisión: heredar en vez de imitar

Se puede intentar parchear OpenSSL para que su salida se parezca a la de BoringSSL. Es una carrera perdida: cada versión de Chrome cambia algo, y el propio autor de JA4 advierte que las huellas cambian más o menos una vez al año, cuando las librerías se actualizan. Perseguir eso desde fuera es firmar un mantenimiento eterno.

La decisión de arquitectura fue la contraria: no imitar el ClientHello de Chrome, sino heredarlo. Un addon nativo de Node escrito en Rust con NAPI-RS, apoyado en boring (el binding de BoringSSL que mantiene Cloudflare) y hyper-boring para HTTP/2.

Son 377 líneas de Rust en cinco archivos. Configuran el conector con el perfil de Chrome (cipher suites, curvas, algoritmos de firma, ALPN, SCT y OCSP stapling, con verificación de certificado siempre activa) y devuelven el builder sin finalizar, a propósito, para que la capa HTTP instale sus propios callbacks antes de cerrarlo.

Lo elegante es lo que no hay que escribir: el orden de extensiones y los valores GREASE no se fabrican. Salen solos, porque los produce la misma librería que usa Chrome.

La huella no termina en TLS. Hay dos capas más:

  • HTTP/2: los SETTINGS de la conexión también identifican al cliente. Chrome usa un HEADER_TABLE_SIZE de 64 KB donde Node usa 4 KB, y ventanas iniciales mucho mayores. Se replican los valores del perfil.
  • Orden de cabeceras: HTTP/2 exige que los pseudo-headers vayan primero, pero el orden del resto es libre, y cada navegador tiene el suyo. Se fija la secuencia capturada de un Chrome real con mitmproxy.

El addon nativo es opcional, siempre

Una regla dura del repo: el paquete nativo nunca puede ser obligatorio. Se carga con try/catch y hay degradación elegante al camino TLS de Node si el binario no compiló, si la plataforma no calza o si Rust no está disponible. En los tests se declara externo, así que la suite corre sin compilar nada.

El monorepo (Turborepo + pnpm, 13 paquetes, TypeScript strict) tiene un DAG explícito de dependencias donde ese paquete cuelga como hoja opcional. Instalar el proyecto en una máquina sin toolchain de Rust funciona; simplemente pierdes una capa.

Escalar por costo, no por defecto

La segunda idea del diseño: usar el nivel más barato que funcione.

Hay cuatro modos, del más barato al más caro: una petición HTTP pura con el perfil de red correcto (decenas de milisegundos), un Chrome headless controlado por CDP, ese Chrome con comportamiento humano simulado, y por último un perfil de navegador persistente y envejecido.

En modo automático el orquestador entra por el más barato y solo escala cuando un detector determina que la respuesta no sirve. La mayoría de las veces no hace falta ni levantar un navegador. Es la diferencia entre 30 milisegundos y 15 segundos por petición, multiplicada por cada request de una suite de QA.

Todo esto está descompuesto con inyección de dependencias: el orquestador recibe la flota de navegadores, el detector, el controlador de proxies, el ejecutor de acciones y el gestor de sesiones como interfaces. La lógica de escalación se testea entera sin levantar Chrome.

Tres piezas de las que estoy contento

Rate limiting distribuido que se degrada solo

Ventana deslizante sobre un sorted set de Redis, resuelta en un MULTI de cuatro comandos: agregar el timestamp, purgar lo que quedó fuera de la ventana, contar y renovar el TTL. Si el conteo excede el límite, se quita la entrada recién insertada y se calcula cuándo se libera el cupo.

Lo bueno no es eso, es el fallback: el limitador de Redis compone internamente uno en memoria. Si Redis no responde, marca la conexión como caída y delega, sesgado a permitir. La conexión inicial nunca bloquea el arranque del servicio. Un Redis caído degrada la precisión del límite; no tumba el relay.

Histéresis en la salud de los proxies

Cada proxy lleva un promedio móvil exponencial de sus éxitos. El detalle está en cómo se emiten los eventos: degraded cuando el puntaje cruza hacia abajo un umbral, recovered cuando cruza hacia arriba otro más alto.

Dos umbrales distintos, no uno. Con un solo umbral, un proxy que oscila alrededor del límite emite un evento por request y satura el bus. Con histéresis, tiene que recuperarse de verdad para volver a considerarse sano. Y la comparación es entre el puntaje previo y el nuevo: se emite en la transición, no en el estado.

Un relay CDP que no entiende CDP

El puente WebSocket que expone el protocolo de Chrome hacia afuera reenvía los frames tal cual, sin interpretarlos. Es una decisión defensiva: si el relay generara mensajes propios, esos mensajes serían una señal. No interpretar nada es la garantía más simple de que no introduce artefactos.

Tuvo su costo de implementación: hubo que manejar el upgrade de HTTP a WebSocket a mano, porque la librería aborta el handshake en rutas que no son la suya y corrompía sockets ya promovidos por otra parte del servidor.

El caso de uso, hecho producto

La parte que más me gusta del proyecto es la que menos tiene que ver con el fingerprinting: un corredor de QA de unas 1.400 líneas que usa todo lo anterior para probar aplicaciones propias. Corre las suites, aísla el perfil del navegador por suite para que no se filtren cookies entre pruebas, y graba un MP4 por test ensamblado desde los frames del screencast de Chrome.

Es la mejor demostración de la tesis: la infraestructura existe para que la automatización legítima no se rompa.

Los números

  • 5 servicios en Docker: el relay, un Chrome dedicado, Redis, Prometheus y Grafana. Cada uno con healthcheck, y el relay esperando a que sus dependencias estén sanas.
  • ~900 tests en 56 archivos (el README todavía dice 732: quedó atrás).
  • ~20.600 líneas de TypeScript sin contar tests, más 377 de Rust.
  • El contenedor de Chrome corre con un perfil seccomp propio, porque Chromium necesita llamadas al sistema que el perfil por defecto de Docker no permite. La alternativa fácil habría sido --privileged o desactivar el sandbox; abrir lo mínimo cuesta más y es lo correcto.

Y las que faltan, dichas sin maquillaje: no hay benchmark público reproducible de tasa de éxito, y con proxy en el modo más barato se cae al camino TLS de Node, que no controla el orden de extensiones. Están en el roadmap, no en los logros.

Lo que me llevo

El fingerprinting TLS se discute casi siempre como tema de evasión. Me parece más interesante como problema de compatibilidad de stacks: dos librerías que implementan el mismo RFC producen bytes distinguibles, y ese detalle, invisible para el 99% del software, decide si tu suite de pruebas corre o falla.

La solución tampoco fue astucia: fue usar la misma pieza que usa el navegador y aceptar el costo de meter Rust en un monorepo de TypeScript, con el cuidado de que siga funcionando cuando ese Rust no está.

Fuentes

  • Especificación de JA4, FoxIO. El algoritmo completo: qué entra en cada una de las tres partes del hash y por qué los ciphers y las extensiones van ordenados en hexadecimal.
  • FAQ del repositorio de JA4. La respuesta del autor a “¿por qué ordenan las extensiones?”: Chrome pasó a barajarlas en 2023, y sumar los algoritmos de firma es lo que recupera la unicidad.
  • Randomize TLS extension order, en Chrome Platform Status. El cambio de Chromium que provocó todo lo anterior.
  • JA3, de Salesforce. La implementación original, que hashea la lista en su orden de llegada.
  • RFC 8701. GREASE: por qué un navegador anuncia a propósito valores reservados que nadie va a negociar.
  • browserleaks.com/tls. Contra este endpoint verifiqué que la huella del relay coincide con la de un Chrome real.

Comentarios

Todavía no hay comentarios. El primero es tuyo.

Se revisa antes de publicarse. El correo no se guarda ni aparece en ninguna parte.