AntiDetect
Funciones Tecnología Precios Para quién
Docs Blog FAQ
Idioma
EnglishItalianoDeutschEspañolРусский
Descargar

Diez formas en que el DevTools Protocol te delata

Todos los stacks de automatización conocidos controlan Chrome a través del DevTools Protocol. CDP se construyó para depurar, no para esconderse — y deja rastro en una docena de sitios. Aquí está dónde, y por qué un parche en JavaScript suele empeorarlo.

CDP nunca se pensó para ser silencioso

Puppeteer, Playwright, Selenium 4 y todo envoltorio «stealth» construido sobre ellos comparten un cimiento: el Chrome DevTools Protocol. Es el mismo canal que abre tu navegador cuando pulsas F12. Chrome lo expone para que un depurador pueda inspeccionar y conducir la página, y se diseñó con exactamente un no-objetivo: ocultar que hay un depurador conectado.

Ahí está la raíz del problema. Las señales de abajo no son fallos de Puppeteer. Casi todas son CDP comportándose correctamente, de formas en que un navegador conducido por una persona nunca se comporta. Un proveedor de detección no necesita romper nada para encontrarlas; le basta con leer valores que la plataforma regala.

Lo que sigue es la lista tal como está en 2026, ordenada más o menos por lo barato que resulta comprobar cada punto.

navigator.webdriver, y por qué parchearlo en JS es peor

La señal más antigua. navigator.webdriver devuelve true siempre que Chrome arranca con la automatización activada. Todos los tutoriales te dicen que lo sobrescribas:

Object.defineProperty(navigator, 'webdriver', { get: () => false })

Eso no es un arreglo. Es una segunda señal, más ruidosa. En un navegador de serie, webdriver es un accessor sobre Navigator.prototype, no una propiedad propia de la instancia. Después del fragmento anterior hay tres cosas ciertas que no lo son en ningún otro sitio:

Los scripts de detección llevan años recorriendo la cadena toString. Un navegador que devuelve el honesto true es un navegador con automatización. Un navegador que devuelve un false falsificado es un navegador con automatización y además intentando esconderlo, que para casi todos los motores de riesgo es lo peor de los dos.

La única versión de esto que aguanta una inspección es aquella en la que el propio getter en C++ devuelve false, de modo que no hay propiedad propia, ni función flecha, ni nada fuera de lugar en la cadena de prototipos.

Runtime.enable: la fuga que reescribió el stack de todos

Para evaluar JavaScript en una página, la mayoría de librerías llaman a Runtime.enable. Ese único comando tiene efectos secundarios que llegan a la página: empieza a emitir notificaciones Runtime.executionContextCreated y cambia cómo se serializan los argumentos de la consola.

El exploit clásico cabe en un tuit. Crea un objeto con un getter, pásalo a console.debug y mira si el getter se dispara. Con un depurador conectado y Runtime.enable llamado, el protocolo serializa el objeto para el cliente y el getter se ejecuta. Sin depurador, nadie lo toca.

let leaked = false;
const probe = { get id() { leaked = true; return 1; } };
console.debug(probe);
// leaked === true  →  something is listening on CDP

Eso es lo que empujó al ecosistema hacia forks parcheados — rebrowser-puppeteer y afines — que evitan Runtime.enable y usan mundos aislados o addBinding en su lugar. Esos parches funcionan y merece la pena usarlos. También tapan exactamente un agujero de diez.

Tres fallos en la entrada sintética

Cuando la automatización mueve un ratón, no mueve un ratón. Llama a Input.dispatchMouseEvent, y Chrome construye un objeto de evento que es casi, pero no del todo, lo que produce la verdadera cadena de entrada. Tres diferencias se observan trivialmente desde una página.

screenX y screenY están mal

Es el bug 40280325 de Chromium. CDP pone screenX = clientX y screenY = clientY — coordenadas del viewport donde corresponden coordenadas de pantalla. En un clic real, las dos difieren en la posición de la ventana más la cromo del navegador: barra de pestañas, barra de direcciones, marcadores. En una ventana maximizada eso son unos 90 a 130 píxeles de desplazamiento vertical.

Así que un clic genuino cerca de la parte alta de una página tiene screenY en torno a 150. Un clic de CDP en el mismo sitio informa screenY = 20. Cloudflare Turnstile trata un screenY bajo como clic de bot, y hace bien: ninguna persona puede hacer clic por encima de la cromo de su propio navegador.

UIEvent.detail es cero

En un clic real, event.detail lleva el número de clics — 1 para uno simple, 2 para uno doble. Los eventos de clic despachados por CDP llegan con click_count = 0, así que detail es 0. No hay gesto de usuario que produzca un clic con detail cero. Turnstile lo comprueba dentro de su iframe de desafío.

getCoalescedEvents() viene vacío

Los navegadores modernos agrupan el movimiento de puntero de alta frecuencia y exponen las muestras originales mediante PointerEvent.getCoalescedEvents(). Un pointermove real lleva siempre al menos un evento agrupado: él mismo. Un pointermove despachado por CDP lleva un array vacío, porque nunca hubo una muestra de hardware que agrupar.

Nada de esto se puede reparar desde JavaScript. El objeto de evento se construye en C++ antes de que ningún script de la página lo vea, y las propiedades son de solo lectura.

Ventana del navegador en sección mostrando el desfase entre clientY y screenY
Bug 40280325 de Chromium. Un clic real lleva la cromo del navegador en su coordenada de pantalla; un clic de CDP informa la del viewport. Turnstile lee la diferencia.

Las cadenas que la automatización deja tiradas

Dos familias de cadenas literales acaban donde una página puede leerlas.

Marcadores de ChromeDriver. ChromeDriver sigue el rastro de los resultados de sus propias llamadas definiendo propiedades del documento con prefijo cdc_, $cdc_ o $chrome_. No están escondidas. Un bucle de tres líneas sobre Object.getOwnPropertyNames(document) las encuentra, y los scripts de detección buscan exactamente eso desde 2018.

URLs de origen de Puppeteer. Puppeteer añade //# sourceURL=pptr:... a los scripts que evalúa y crea un mundo aislado llamado __puppeteer_utility_world__. Ambos afloran en Error.stack si una excepción cruza la frontera:

try { null.f() } catch (e) { /* e.stack may contain "pptr:" */ }

Son las señales más fáciles de quitar y las que más a menudo se quedan, porque solo aparecen en condiciones que un desarrollador rara vez prueba: un error no capturado dentro de una función evaluada.

Workers: el contexto que nadie cubre

Esta es la brecha que pilla a las configuraciones por lo demás más cuidadosas.

Los Web Workers, Service Workers y Shared Workers tienen cada uno su propio ámbito global con su propio navigator. Ese objeto lo crea el motor, en el hilo del worker, a partir de los valores reales del navegador. Los overrides de CDP a nivel de página — Emulation.setUserAgentOverride, scripts inyectados, Page.addScriptToEvaluateOnNewDocument — no llegan hasta ahí.

El resultado es un navegador que afirma una cosa en el hilo principal y otra dentro de un worker. A un script de detección le basta con preguntar dos veces:

// main thread says 8; worker says 24
new Worker(URL.createObjectURL(new Blob([
  'postMessage([navigator.hardwareConcurrency, navigator.deviceMemory, navigator.platform])'
], { type: 'text/javascript' })));

Cualquier desacuerdo entre ambos es concluyente. Los navegadores reales no pueden producirlo, porque los dos ámbitos leen los mismos valores de base. Entre las señales que se escapan por ahí están hardwareConcurrency, deviceMemory, platform, la lista completa de marcas de userAgentData y — vía OffscreenCanvas — el fabricante y el renderizador de WebGL.

Arreglar esto desde fuera no es posible de forma completa. Los valores tienen que venir del motor, lo que significa que el navigator del worker debe construirse a partir del mismo perfil que el de la ventana.

Las cosas pequeñas que aun así suman

Tres más, cada una barata de comprobar y cada una capaz de terminar una sesión por sí sola.

Dimensiones exteriores con las DevTools acopladas

La diferencia entre outerWidth y innerWidth es la cromo del navegador — unos 16 píxeles en horizontal y de 85 a 90 en vertical en Chrome de escritorio. Acopla las DevTools y ese hueco salta cientos de píxeles. Una ventana que informa outerHeight - innerHeight = 400 es una ventana con un depurador abierto.

Cuota de almacenamiento

navigator.storage.estimate() devuelve una cuota derivada del espacio libre en disco. Ya de por sí es una señal fuerte de hardware, y además delata contenedores y contextos de incógnito, que informan números redondos característicos muy por debajo de cualquier disco real.

APIs que faltan para la plataforma que dices ser

Si tu user agent dice Chrome 147 en Windows, la página puede comprobar qué APIs trae de verdad Chrome 147 en Windows. Históricamente, las builds headless no tenían chrome.runtime, ni el flujo completo de permisos de Notification, ni el soporte de códecs de las builds headful. Reclamar una plataforma que no puedes respaldar es peor que no reclamar ninguna.

Por qué el arreglo tiene que estar en el código fuente

Mira la lista y aparece un patrón. Casi todas las señales nacen por debajo de JavaScript:

Un script de contenido se ejecuta después de todo eso. Solo puede sobrescribir lo que ya está decidido, y cada sobrescritura deja las huellas descritas arriba: propiedades propias donde corresponden prototipos, funciones flecha donde corresponde código nativo, desacuerdo entre contextos.

Este es el argumento honesto a favor de una build modificada de Chromium, y conviene decir también sus límites. Parchear el motor resuelve la capa de debajo de JavaScript. No resuelve los fingerprints de TLS y HTTP/2, que están del todo por debajo del navegador, y no hace nada por el comportamiento: una sesión que hace clic con regularidad inhumana se marcará por muy limpias que estén sus propiedades.

Probar tu propia configuración

No te fíes de la palabra de ningún proveedor, la nuestra incluida. Las pruebas son públicas y puedes correrlas en una tarde:

Pásalas por lo que uses hoy antes de cambiar nada. Una herramienta que supera nueve de estas y falla la décima no está a nueve décimos del camino: una sola discrepancia concluyente basta.

Arreglado en el motor, no en un script de contenido

P8 entrega una build de Chromium en la que todo esto se comporta como en un navegador corriente — porque se cambió el C++, no se envolvió.

Empieza gratis
Todos los artículos Siguiente: fingerprinting del navegador explicado