Seal
Executive summary — Seal combines open self-registration on an internal GitBucket instance (the Scala-based GitHub clone that explains the Jetty banner on port 8080) with a Tomcat credential that stayed alive in git history despite being "reverted" from the file. That credential only becomes useful thanks to a path normalization mismatch between nginx and Tomcat (the
;character is interpreted differently by each), which breaks a TLS client-certificate restriction and opens the door to Tomcat Manager for a WAR-deployment RCE. The jump to the second account abuses an automated Ansible backup (synchronize+copy_links: yes) over a world-writable directory, planting a symlink that leaksluis's SSH key. The final root step needs no exploit at all: a wildcard in asudorule foransible-playbook.
| Platform | Hack The Box |
| Operating system | Linux |
| Difficulty | Medium |
| Status | Retired |
| Target IP | 10.129.95.190 |
Attack map
[8080] Jetty -> 401 with no WWW-Authenticate
│ fuzzing reveals /register and /signin -> GitBucket (the real backend)
▼
[GitBucket] self-service registration -> repos root/infra, root/seal_market
│ git log -p on tomcat-users.xml: reverted credential, never rotated
▼
[nginx vs Tomcat] ';' in the path -> different normalization -> bypasses
ssl_verify_client on /manager/html
▼
[Tomcat Manager] tomcat:<leaked credential> -> WAR deploy -> RCE
▼
[RCE] tomcat
│ Ansible backup (synchronize, copy_links=yes) over a world-writable
│ directory -> symlink to luis's ~/.ssh
▼
[SSH] luis (user.txt)
│ sudo NOPASSWD /usr/bin/ansible-playbook * (wildcard)
▼
[ROOT] arbitrary playbook executed as root (root.txt)
1. Reconnaissance and enumeration
Port scan
nmap -p- --min-rate 5000 -T4 -oN nmap_all.txt 10.129.95.190
Three open ports: 22 (SSH), 443 (nginx, over TLS) and 8080 (HTTP, Jetty banner).
PORT STATE SERVICE
22/tcp open ssh
443/tcp open https
8080/tcp open http-alt
On 8080, a plain request to the root path returns 401 with no WWW-Authenticate header — an unusual detail suggesting the app handles its own authentication instead of delegating to the server's Basic/Digest.
TLS certificate and domain
The certificate served on 443 reveals the real domain and organization:
openssl s_client -connect 10.129.95.190:443 -servername seal.htb </dev/null 2>/dev/null | openssl x509 -noout -subject -issuer
subject=CN = seal.htb, O = "Seal Pvt Ltd"
echo "10.129.95.190 seal.htb" | sudo tee -a /etc/hosts
https://seal.htb serves the "Seal Market" corporate site, with no apparent functionality beyond static content.
Port 8080 — GitBucket via fuzzing
With the generic 401 as the only clue, routes on 8080 are fuzzed, filtering out anything that still returns 401:
ffuf -u http://10.129.95.190:8080/FUZZ -w /usr/share/seclists/Discovery/Web-Content/common.txt -fc 401
/register [Status: 200]
/signin [Status: 200]
Everything else still returns 401 — only registration and login are open. The /signin HTML identifies the product: GitBucket, a GitHub clone written in Scala and deployed on Jetty — which explains both the port's banner and the 401 with no WWW-Authenticate (GitBucket handles its own form-based login, not HTTP Basic).
Registration and repository enumeration
The registration form asks for userName, password, fullName, mailAddress, description and fileId. That's enough to create an account and authenticate, keeping the session in a cookie jar:
curl -s -c gb.txt -d 'userName=pentest01&password=Passw0rd!23&fullName=Pentest+User&mailAddress=pentest01@seal.htb&description=&fileId=' http://10.129.95.190:8080/register
curl -s -b gb.txt -c gb.txt -i -d 'userName=pentest01&password=Passw0rd!23' http://10.129.95.190:8080/signin
HTTP/1.1 302 Found
Location: /
The 302 to / confirms an authenticated session. With a valid cookie, a simple href grep over the home page lists users and repositories visible to any freshly created account:
curl -s -b gb.txt http://10.129.95.190:8080/ | grep -oE 'href="/[a-zA-Z0-9_.-]+(/[a-zA-Z0-9_.-]+)?"' | sort -u
/alex
/luis
/root
/root/infra
/root/seal_market
Three users (alex, luis, root) and two infrastructure repos under root: infra and seal_market. Both get cloned with the freshly created account (self-signed certificate, hence sslVerify=false):
git -c http.sslVerify=false clone http://pentest01:Passw0rd!23@10.129.95.190:8080/root/infra.git
git -c http.sslVerify=false clone http://pentest01:Passw0rd!23@10.129.95.190:8080/root/seal_market.git
Git history — leaked Tomcat credential
The seal_market history (git log --oneline) includes, among others, two telling commits in chronological order: Adding tomcat configuration followed later by Updating tomcat configuration — the classic "add something sensitive, then revert it" pattern, which always justifies checking a file's full history rather than just its current state:
git log -p --all -- tomcat/tomcat-users.xml
- <user username="tomcat" password="42MrHBf*z8{Z%" roles="manager-gui,admin-gui"/>
+ <user username="tomcat" password="<must-be-changed>" roles="manager-gui,admin-gui"/>
The current file only has the
<must-be-changed>placeholder, butgitnever forgets: the reverted diff is still in the history. Transcription note: the attribute captured in the diff ends in%(...z8{Z%); the credential that actually authenticates against Tomcat istomcat:42MrHBf*z8{Z}(likely a copy artifact on the final character — the original capture couldn't be recovered to confirm it 100%) — this second form is used for the rest of the write-up since it's the one that actually works.
nginx configuration — the bypass surface
The same infra repository includes the nginx sites-enabled config for the 443 vhost: it asks for a TLS client certificate optionally, and only enforces it for Tomcat's administrative paths — everything else reaches the app unrestricted:
server {
listen 443 ssl;
ssl_verify_client optional;
...
location /manager/html {
if ($ssl_client_verify != SUCCESS) { return 403; }
proxy_pass http://localhost:8000;
}
location /admin/dashboard {
if ($ssl_client_verify != SUCCESS) { return 403; }
proxy_pass http://localhost:8000;
}
location /host-manager/html {
if ($ssl_client_verify != SUCCESS) { return 403; }
proxy_pass http://localhost:8000;
}
location / {
proxy_pass http://localhost:8000;
}
}
The direct block is confirmed:
curl -sk -o /dev/null -w "%{http_code}\n" https://seal.htb/manager/html
# 403
nginx demands a client certificate we don't have. Without the Tomcat credential and without breaking that restriction, the Manager is unreachable — until the next phase.
Resumen ejecutivo — Seal combina un registro abierto en una instancia interna de GitBucket (el clon de GitHub en Scala que explica el banner Jetty del puerto 8080) con una credencial de Tomcat que quedó viva en el historial de git pese a haber sido "revertida" del fichero. Esa credencial solo sirve de algo gracias a una confusión de normalización de rutas entre nginx y Tomcat (el carácter
;se interpreta de forma distinta en cada uno), que rompe una restricción de certificado cliente TLS y abre paso al Tomcat Manager para un RCE vía despliegue de WAR. El salto a la segunda cuenta abusa de un backup automatizado con Ansible (synchronize+copy_links=yes) sobre un directorio world-writable, plantando un symlink que filtra la clave SSH deluis. El root final no necesita ningún exploit: un comodín ensudosobreansible-playbook.
| Plataforma | Hack The Box |
| Sistema operativo | Linux |
| Dificultad | Medium |
| Estado | Retired |
| IP objetivo | 10.129.95.190 |
Mapa del ataque
[8080] Jetty -> 401 sin WWW-Authenticate
│ fuzzing revela /register y /signin -> GitBucket (backend real)
▼
[GitBucket] registro self-service -> repos root/infra, root/seal_market
│ git log -p sobre tomcat-users.xml: credencial revertida, nunca rotada
▼
[nginx vs Tomcat] ';' en la ruta -> normalización distinta -> bypass de
ssl_verify_client sobre /manager/html
▼
[Tomcat Manager] tomcat:<credencial filtrada> -> deploy de WAR -> RCE
▼
[RCE] tomcat
│ backup Ansible (synchronize, copy_links=yes) sobre directorio
│ world-writable -> symlink a ~/.ssh de luis
▼
[SSH] luis (user.txt)
│ sudo NOPASSWD /usr/bin/ansible-playbook * (comodín)
▼
[ROOT] playbook arbitrario ejecutado como root (root.txt)
1. Reconocimiento y enumeración
Escaneo de puertos
nmap -p- --min-rate 5000 -T4 -oN nmap_all.txt 10.129.95.190
Tres puertos abiertos: 22 (SSH), 443 (nginx, con TLS) y 8080 (HTTP, banner de Jetty).
PORT STATE SERVICE
22/tcp open ssh
443/tcp open https
8080/tcp open http-alt
En 8080, una petición directa a la raíz devuelve 401 sin cabecera WWW-Authenticate — un detalle poco habitual que sugiere una app que gestiona su propia autenticación en vez de delegarla en Basic/Digest del servidor.
Certificado TLS y dominio
El certificado servido en 443 revela el dominio real y la organización:
openssl s_client -connect 10.129.95.190:443 -servername seal.htb </dev/null 2>/dev/null | openssl x509 -noout -subject -issuer
subject=CN = seal.htb, O = "Seal Pvt Ltd"
echo "10.129.95.190 seal.htb" | sudo tee -a /etc/hosts
https://seal.htb sirve la web corporativa de "Seal Market", sin funcionalidad aparente más allá de contenido estático.
Puerto 8080 — GitBucket vía fuzzing
Con el 401 genérico como único indicio, se fuzzean rutas sobre 8080 filtrando por código de respuesta distinto de 401:
ffuf -u http://10.129.95.190:8080/FUZZ -w /usr/share/seclists/Discovery/Web-Content/common.txt -fc 401
/register [Status: 200]
/signin [Status: 200]
Todo lo demás sigue devolviendo 401 — solo registro y login están abiertos. El HTML de /signin identifica el producto: GitBucket, un clon de GitHub escrito en Scala y desplegado sobre Jetty — lo que explica tanto el banner del puerto como el 401 sin WWW-Authenticate (GitBucket gestiona su propio login por formulario, no HTTP Basic).
Registro y enumeración de repositorios
El formulario de registro pide userName, password, fullName, mailAddress, description y fileId. Con eso basta para crear una cuenta y autenticarse, guardando la sesión en un fichero de cookies:
curl -s -c gb.txt -d 'userName=pentest01&password=Passw0rd!23&fullName=Pentest+User&mailAddress=pentest01@seal.htb&description=&fileId=' http://10.129.95.190:8080/register
curl -s -b gb.txt -c gb.txt -i -d 'userName=pentest01&password=Passw0rd!23' http://10.129.95.190:8080/signin
HTTP/1.1 302 Found
Location: /
El 302 a / confirma la sesión autenticada. Con la cookie válida, un simple grep de href sobre la home lista usuarios y repositorios visibles sin necesitar más permisos que los de una cuenta recién creada:
curl -s -b gb.txt http://10.129.95.190:8080/ | grep -oE 'href="/[a-zA-Z0-9_.-]+(/[a-zA-Z0-9_.-]+)?"' | sort -u
/alex
/luis
/root
/root/infra
/root/seal_market
Tres usuarios (alex, luis, root) y dos repositorios de infraestructura bajo root: infra y seal_market. Se clonan ambos con la cuenta recién creada (certificado autofirmado, de ahí sslVerify=false):
git -c http.sslVerify=false clone http://pentest01:Passw0rd!23@10.129.95.190:8080/root/infra.git
git -c http.sslVerify=false clone http://pentest01:Passw0rd!23@10.129.95.190:8080/root/seal_market.git
Historial de git — credencial de Tomcat filtrada
El historial de seal_market (git log --oneline) incluye, entre otros, dos commits reveladores en orden cronológico: Adding tomcat configuration seguido más tarde de Updating tomcat configuration — el patrón clásico de "añadir algo sensible y después revertirlo", que siempre justifica mirar el historial completo del fichero y no solo su estado actual:
git log -p --all -- tomcat/tomcat-users.xml
- <user username="tomcat" password="42MrHBf*z8{Z%" roles="manager-gui,admin-gui"/>
+ <user username="tomcat" password="<must-be-changed>" roles="manager-gui,admin-gui"/>
El fichero actual solo tiene el placeholder
<must-be-changed>, perogitnunca olvida: el diff revertido sigue en el historial. Nota de transcripción: el atributo capturado en el diff termina en%(...z8{Z%); la credencial que efectivamente autentica contra Tomcat estomcat:42MrHBf*z8{Z}(probable artefacto de copiado del carácter final, no se pudo recuperar la captura original para confirmarlo al 100%) — se usa esta segunda forma en el resto del writeup por ser la que realmente funciona.
Configuración de nginx — la superficie del bypass
El mismo repositorio infra incluye la configuración de sites-enabled de nginx para el vhost de 443: pide certificado de cliente TLS de forma opcional, y solo exige que sea válido para las rutas administrativas de Tomcat — todo lo demás pasa sin restricción a la aplicación:
server {
listen 443 ssl;
ssl_verify_client optional;
...
location /manager/html {
if ($ssl_client_verify != SUCCESS) { return 403; }
proxy_pass http://localhost:8000;
}
location /admin/dashboard {
if ($ssl_client_verify != SUCCESS) { return 403; }
proxy_pass http://localhost:8000;
}
location /host-manager/html {
if ($ssl_client_verify != SUCCESS) { return 403; }
proxy_pass http://localhost:8000;
}
location / {
proxy_pass http://localhost:8000;
}
}
Se confirma el bloqueo directo:
curl -sk -o /dev/null -w "%{http_code}\n" https://seal.htb/manager/html
# 403
nginx exige un certificado cliente que no tenemos. Sin la credencial de Tomcat y sin romper esa restricción, el Manager es inalcanzable — hasta la fase siguiente.
This content is Root Access only. Everything else on the site — the free tier, the whole public library — stays open.
See Root Access plansNo account? Create one free then upgrade from your console.
Este contenido es solo para Root Access. Todo lo demás del sitio — el nivel gratuito, toda la biblioteca pública — sigue abierto.
Ver planes de Root Access¿Sin cuenta? Crea una gratis y luego mejora desde tu consola.