iosum ← Índice

Automatización · Funcional (e2e)

transito-co

Lee portales públicos detrás de captcha, WAF y cripto propietaria por API, con consentimiento.

  • Python (stdlib-first)
  • OCR
  • captcha-solving
  • curl_cffi
  • cripto aplicada

Datos que el Estado publica para consulta ciudadana, pero solo de a uno, a mano, y detrás de cuatro capas distintas de anti-automatización. Esto los lee por API, con consentimiento del titular.

Stack: Python (stdlib-first) · OCR · captcha-solving · curl_cffi · cripto aplicada · Estado: Funcional (verificado e2e, manual) · Rol: diseño + desarrollo (solo)

El problema

Hay datos públicos por ley —situación de un trámite, estado de un activo, antecedentes de una persona— que el Estado expone en portales de consulta uno-a-uno. Para automatizarlos con consentimiento del titular (verificación de proveedores, due diligence, un servicio de cartera) hay que consultar decenas o cientos por corrida, y cada portal está protegido contra exactamente eso.

Lo interesante no es el scraping. Es que cada portal usa una defensa distinta, y ninguna se rinde con requests.get():

  • captcha proof-of-work calculado en el cliente,
  • captcha de imagen,
  • reCAPTCHA v2 / v3 / Enterprise,
  • un WAF que te bloquea por el fingerprint TLS antes de ver tu primer byte de HTTP,
  • frameworks server-stateful (ASP.NET WebForms, JSF/PrimeFaces) con tokens que rotan en cada paso,
  • y una API que cifra su propio payload con un esquema propietario.

Resolver uno no enseña a resolver el siguiente. Eso es lo que lo hizo difícil.

Qué construí

~5.500 LOC de Python, organizadas por portal: cada uno es un cliente independiente que sabe negociar su defensa específica y devuelve un dict normalizado. Encima, orquestadores que corren lotes desde CSV, consolidan varias fuentes en un informe por persona/activo, y aplican el gate de consentimiento.

Decisión de base: stdlib primero. La mayoría de clientes corren con urllib + hashlib + subprocess y cero dependencias de Python. Solo bajo a librerías externas (curl_cffi, un navegador headless) cuando la defensa del portal lo obliga, y está documentado por qué en cada caso. Para un binario que la gente va a correr en su máquina y mantener, menos superficie de dependencias es menos cosas que se pudren.

Retos técnicos interesantes

1. Captcha proof-of-work reimplementado desde el worker JS del sitio

Uno de los portales reemplazó el captcha visual por un proof-of-work: el servidor manda un question, y el cliente tiene que encontrar un nonce tal que sha256(JSON.stringify({question, time, nonce})) empiece con un prefijo de ceros. El navegador lo resuelve en un Web Worker y manda el nonce como prueba de trabajo.

No hay API para esto: hay que leer el worker, entender el algoritmo y replicarlo byte a byte. Los detalles que importan y que no son obvios:

  • el hash es sobre el JSON serializado, así que la serialización en Python tiene que producir exactamente la misma cadena que JSON.stringify (orden de claves, sin espacios, comillas) o el hash no coincide nunca;
  • el nonce válido además tiene que ser primo —una verificación extra que el worker hacía y que es fácil pasar por alto;
  • con múltiples niveles de dificultad, el contador de nonce no se reinicia entre niveles: arranca donde quedó. Si lo reinicias, generas tokens que el servidor rechaza sin decirte por qué.

El resultado es un solver de búsqueda por fuerza bruta (incrementar, hashear, comprobar prefijo + primalidad) que produce el token sin tocar un navegador. Réplica exacta del cliente, no aproximación.

2. Cadena de OCR con fallback para captchas de imagen

Otro portal usa captcha de imagen clásico. Lo resolví como una cadena de solvers con degradación de costo:

  1. Tesseract local (vía el binario, sin wrapper de Python) con whitelist de caracteres y page-segmentation mode de línea única. Acierta ~40-50% de estos captchas. Gratis.
  2. Si Tesseract falla —y el portal te lo dice rechazando el captcha—, cae a un modelo de visión (temperatura 0, prompt que exige solo los caracteres respetando mayúsculas). Caro pero ~99%.
  3. Fallback manual para depurar.

El truco de ingeniería es que el bucle no necesita saber de antemano si Tesseract acertó: pide un captcha nuevo, lo intenta, y si el servidor lo rechaza, reintenta o pasa al siguiente solver. El OCR barato resuelve la mitad de los casos por cero, y el modelo caro solo entra cuando hace falta. A escala eso es la diferencia entre centavos y dólares por mil consultas.

3. reCAPTCHA, y un experimento honesto que falló

Para los portales con reCAPTCHA v2/v3/Enterprise, la vía que funciona en producción es un servicio de resolución externo: le pasas el site-key y la URL, te devuelve el token g-recaptcha-response que el portal valida.

Antes de aceptar ese costo recurrente, intenté el bypass gratis por el reto de audio: reCAPTCHA ofrece un MP3 por accesibilidad → descargarlo → transcribirlo con Whisper → escribir la respuesta. Lo construí entero (navegador real conducido por CLI, entrar a los iframes cross-origin del widget, sacar el href del audio, transcribir, rellenar, leer el token).

Resultado real, medido, no inventado: 0 de 5. El código funciona punta a punta —baja el audio, lo transcribe, escribe la respuesta— pero reCAPTCHA rechaza el token y, tras pocos intentos, deja de servir audio: rastrea el navigator.webdriver del navegador automatizado, no solo la IP (el bloqueo persiste tras enfriar la IP). Lo dejé en el repo con el reporte de pruebas y la conclusión escrita: no es viable con esta pila, usar el servicio de pago. Saber cuándo una vía elegante no sirve, y dejarlo documentado en vez de fingir que sí, también es parte del trabajo.

4. Evasión de WAF por fingerprint TLS (JA3/JA4)

Dos portales están detrás de un WAF que bloquea por el handshake TLS antes del HTTP. urllib y requests traen un fingerprint TLS de stack de Python, y el WAF lo conoce: 403 o redirección a una página de error, da igual qué headers o User-Agent mandes —nunca llegas a la capa de aplicación.

La solución es curl_cffi con impersonate="chrome", que reproduce el ClientHello de un Chrome real (orden de cipher suites, extensiones, curvas) para que el JA3/JA4 coincida con un navegador y el WAF deje pasar. Detalle adicional: estos portales no sirven la cadena intermedia de certificados completa, así que la verificación estándar falla aun con certifi —igual que en el navegador, que confía por su almacén local. El WAF se pasa por el fingerprint, no por la validación del cert; son dos cosas que es fácil confundir cuando estás depurando un 403.

5. Frameworks server-stateful: ViewState que rota en cada paso

Los portales sobre ASP.NET WebForms y JSF/PrimeFaces no son stateless. Cada interacción del usuario (elegir un tipo de documento, aceptar términos) es un postback que el servidor responde con tokens nuevos —__VIEWSTATE, __EVENTVALIDATION, __VIEWSTATEGENERATOR, el ViewState delta de JSF— que rotan: el token del paso N solo sirve para el paso N+1.

Replicar eso fuera del navegador es una máquina de estados: GET inicial → extraer tokens → postback con los valores correctos (incluido el del ScriptManager del AjaxControlToolkit, que el servidor exige literal) → re-extraer los tokens rotados de la respuesta (que viene en el formato delta de ASP.NET, separado por |, no en HTML limpio) → siguiente postback. Un token mal copiado y el servidor te manda a una página de error genérica sin pista de qué falló. Hay un portal donde el “captcha” es una pregunta en lenguaje natural (aritmética, o “los N primeros dígitos del documento”, o la capital de un departamento) atada a un token cifrado dentro del ViewState; lo resuelvo con un parser de la pregunta, incluyendo normalizar los typos que el propio portal introduce en los nombres.

Cuando el anti-bot de un portal era además flaky por diseño (un token server-side que falla intermitentemente para tráfico no-humano, más rate-limit por IP), bajé a un navegador headless con un bucle de reintentos que distingue estados terminales (resultado obtenido) de no-terminales (token falló, pregunta no resoluble, rate-limit) y actúa distinto en cada uno. Backoff exponencial con jitter para no martillar.

6. Cripto aplicada: reverse de una API que cifraba su propio payload

El reto más bonito. Una API no acepta JSON plano: el SPA cifra el cuerpo de cada request y manda {"dataBody": "<base64>"}. Para consultarla por API hay que producir ese dataBody.

Leyendo el bundle del front, el cifrado es CryptoJS.AES.encrypt(json, passphrase). La sutileza está en que el segundo argumento es un string, no un WordArray —y ahí CryptoJS cambia de modo: en vez de usar el string como clave, lo trata como passphrase y deriva clave + IV con el viejo EVP_BytesToKey de OpenSSL (MD5, 1 iteración), genera un salt aleatorio de 8 bytes, cifra en AES-256-CBC, y antepone Salted__ + salt al ciphertext. Es el formato openssl enc clásico.

Reconocer ese esquema desde el código JS es el 90% del trabajo. Una vez identificado, la implementación es byte-por-byte compatible con el binario openssl —ni siquiera necesité una librería de cripto en Python: shell-out a openssl enc -aes-256-cbc -md md5 -salt. Replicado el cifrado, el dataBody se acepta y la respuesta vuelve en claro. Esto es leer cripto de un cliente y reconstruir el contrato, que es bastante más interesante que llamar un endpoint documentado.

Arquitectura

CSV (titular + consentimiento)


  ┌── orquestadores ──┐   gate de consentimiento + auditoría (JSONL)
  │  lote / informe   │   throttle + caché con TTL (nombre = hash, no PII en disco)
  └─────────┬─────────┘
            │  fan-out por fuente, cada una aislada (si una cae, el resto sigue)

  ┌─────────────────────────────────────────────────────────┐
  │  clientes por portal — cada uno negocia SU defensa:      │
  │   · PoW SHA-256 (réplica del worker JS)                  │
  │   · OCR chain (Tesseract → visión)                       │
  │   · reCAPTCHA v2/v3/Ent (servicio externo)               │
  │   · WAF/TLS fingerprint (curl_cffi impersonate)          │
  │   · ViewState/JSF rotativo (máquina de estados)          │
  │   · AES-256-CBC CryptoJS (reverse → openssl)             │
  │   · navegador headless (anti-bot flaky, reintentos)      │
  └─────────────────────────┬───────────────────────────────┘

              dict normalizado por fuente → JSON + CSV + PDF de evidencia

Cada cliente expone la misma forma de salida, así que los orquestadores no saben ni les importa qué defensa hubo detrás. Añadir un portal nuevo es escribir un cliente que respete ese contrato. Errores aislados por fuente: una corrida de varias fuentes no se cae porque una esté en rate-limit; lo registra y sigue.

Qué demuestra

  • Reverse engineering de cliente: leer JS minificado de producción (un Web Worker de PoW, un bundle que cifra su payload) y reconstruir el algoritmo en otro lenguaje, exacto, no aproximado.
  • Cripto aplicada de verdad: identificar AES-256-CBC + EVP_BytesToKey/MD5 desde el comportamiento de una librería, y replicarlo de forma byte-compatible.
  • Redes a bajo nivel: entender que un bloqueo puede vivir en la capa TLS (JA3/JA4) y no en HTTP, y resolverlo con impersonation del ClientHello.
  • Protocolos stateful hostiles: modelar ASP.NET WebForms y JSF/PrimeFaces fuera del navegador, con tokens que rotan en cada paso.
  • Criterio de ingeniería: stdlib-first para minimizar dependencias; degradación de costo en el OCR; y la honestidad de medir que el bypass gratis daba 0/5 y documentarlo en vez de venderlo.

Nota sobre uso responsable

Esto consulta datos personales de terceros, así que el consentimiento no es opcional y está codificado, no es una nota en el README. Los modos de lote y de informe se niegan a consultar cualquier titular sin una referencia de autorización registrada, y dejan un log de auditoría (qué se consultó, para quién, bajo qué autorización, cuándo). La caché nombra sus archivos por hash del identificador para no dejar cédulas en claro en disco, y los datos personales viven fuera del control de versiones. Se consulta solo a quien autorizó, y solo para el fin autorizado —el marco local de protección de datos (Habeas Data) tomado como requisito de diseño, no como disclaimer.

Por las mismas razones, el código por-portal no se publica: el valor a mostrar es la ingeniería (PoW, OCR, fingerprint TLS, reverse de cripto), no una guía replicable contra ningún sitio concreto.

Estado y siguiente paso

Funcional, verificado de punta a punta a mano. Cada cliente se probó contra el portal real hasta obtener el dato o llegar exactamente al mismo punto que el navegador humano. Honesto sobre los límites: la verificación es manual, no hay CI —montar suite automática contra portales públicos con captcha y rate-limit es su propio proyecto—, y un par de fuentes son best-effort por anti-bots intencionalmente inestables del lado del servidor, con reintentos en vez de garantía.

Siguiente paso natural: unificar la capa de transporte/sesión/proxy detrás de una interfaz común (rotación de IP para los portales que banean por IP, sesión persistente entre el captcha y la consulta) sin tocar la capa de captcha, que es y seguirá siendo lo específico de cada portal.