Expand capture-only NetScaler NITRO simulation and crash recovery
This commit is contained in:
1 parent
7b99db9e25
commit
c65f049223
10 files changed
+2097
-19
No files matched your search
+54
-7
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Experimental CVE-2026-88771 decoy. Simulation by default; opt-in disposable VM execution."""
|
||||
import argparse
|
||||
import base64
|
||||
import binascii
|
||||
import collections
|
||||
import email.parser
|
||||
import email.policy
|
||||
@@ -8,6 +10,7 @@ import hashlib
|
||||
import html
|
||||
import http.server
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import posixpath
|
||||
@@ -21,6 +24,7 @@ import time
|
||||
import urllib.parse
|
||||
from trusted_proxy import source as proxy_source, forwarded_client
|
||||
from evidence import Evidence, EvidenceError, RecordingReader
|
||||
from nitro import respond as nitro_response
|
||||
from env_file import load, env_int
|
||||
|
||||
MAX_BODY = 16 * 1024 * 1024
|
||||
@@ -305,7 +309,7 @@ class Server(http.server.ThreadingHTTPServer):
|
||||
# Only a hop (the PROXY-declared source) inside one of these networks may set client headers.
|
||||
self.trusted_forwarded = [ipaddress.ip_network(value, strict=False) for value in trusted_forwarded]
|
||||
self.capture_failed = False
|
||||
self.slots = threading.BoundedSemaphore(32)
|
||||
self.slots = threading.BoundedSemaphore(8)
|
||||
self.local = threading.local()
|
||||
super().__init__(address, Handler)
|
||||
|
||||
@@ -367,6 +371,15 @@ class Server(http.server.ThreadingHTTPServer):
|
||||
if self.capture_failed:
|
||||
return
|
||||
self.capture_failed = True
|
||||
if self.engine.save:
|
||||
store = getattr(self.engine.save, '__self__', None)
|
||||
if isinstance(store, Evidence):
|
||||
try:
|
||||
marker = store.directory / 'STOP'
|
||||
marker.write_text('Capture failed; preserve evidence and review storage before clearing.\n')
|
||||
marker.chmod(0o600)
|
||||
except OSError:
|
||||
pass
|
||||
if self.engine.backend:
|
||||
try:
|
||||
self.engine.backend.close(trip=True)
|
||||
@@ -522,6 +535,19 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
except ValueError:
|
||||
self.event('form_parse_error', raw_body_retained=True)
|
||||
return []
|
||||
if kind == 'application/json':
|
||||
try:
|
||||
document = json.loads(body)
|
||||
files = document.get('systemfile', []) if isinstance(document, dict) else []
|
||||
files = [files] if isinstance(files, dict) else files
|
||||
if isinstance(files, list):
|
||||
for item in files[:128]:
|
||||
if isinstance(item, dict) and isinstance(item.get('filecontent'), str):
|
||||
content = base64.b64decode(item['filecontent'], validate=True)
|
||||
self.event('upload', filename=item.get('filename'), field='systemfile.filecontent',
|
||||
content_type='application/octet-stream', content=content)
|
||||
except (ValueError, RecursionError, binascii.Error):
|
||||
self.event('json_upload_parse_error', raw_body_retained=True)
|
||||
if body:
|
||||
self.event('upload_or_raw_payload', filename=None, content=body)
|
||||
if self.server.engine.backend:
|
||||
@@ -568,6 +594,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
auth_fields.intersection({'passwd', 'password', 'pwd', 'passcode'})):
|
||||
engine.auth_attempt(state, self.context, url.path)
|
||||
vm_read_path = None
|
||||
extra_headers = []
|
||||
with engine.lock:
|
||||
candidates = [('uri', urllib.parse.unquote(self.path))]
|
||||
candidates += [('header:' + key, value) for key, value in self.headers.items()]
|
||||
@@ -582,6 +609,9 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
'<StateContext>' + html.escape(state.sid) + '</StateContext>'
|
||||
'</AuthenticateResponse>').encode()
|
||||
mime = 'application/vnd.citrix.authenticateresponse-1+xml; charset=utf-8'
|
||||
elif url.path.startswith('/nitro/'):
|
||||
code, result, extra_headers = nitro_response(state, self.command, url, body, self.headers)
|
||||
data, mime = json.dumps(result).encode(), 'application/json'
|
||||
elif url.path not in LOGIN_PATHS:
|
||||
key = posixpath.normpath(WEBROOT + '/' + urllib.parse.unquote(url.path).lstrip('/'))
|
||||
if not self.server.serve_guest_files:
|
||||
@@ -611,6 +641,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
"img-src 'self' data:; font-src 'self'; frame-ancestors 'self'; "
|
||||
"connect-src 'self' citrixng:// com.citrix.nsgclient:// nsgcepa://; "
|
||||
"report-uri /nscsp_violation/report_uri")]
|
||||
headers.extend(extra_headers)
|
||||
self.event('response', status=code, headers=headers, content=data,
|
||||
body_suppressed=self.command == 'HEAD')
|
||||
self.send_response(code)
|
||||
@@ -642,6 +673,7 @@ def parse_trusted(spec):
|
||||
def main():
|
||||
load()
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--simulation-only', action='store_true', help='refuse real execution and URL fetching')
|
||||
parser.add_argument('--hide-guest-files', action='store_true', help='serve only fixed NetScaler UI/auth routes over HTTP')
|
||||
parser.add_argument('--vm-config', default=os.environ.get('DECOY_VM_CONFIG') or None,
|
||||
help='opt-in dedicated disposable guest SSH configuration')
|
||||
@@ -663,6 +695,8 @@ def main():
|
||||
parser.add_argument('--evidence-mib', type=int, default=env_int('HONEYPOT_EVIDENCE_MIB', 1024),
|
||||
help='total evidence quota; stop rather than overwrite at this limit')
|
||||
args = parser.parse_args()
|
||||
if args.simulation_only and (args.vm_config or args.allow_real_execution or args.fetch_payloads):
|
||||
parser.error('--simulation-only cannot use execution or payload fetching')
|
||||
if bool(args.cert) != bool(args.key) or not 0 <= args.delay <= 86400 or args.evidence_mib < 1:
|
||||
parser.error('provide both cert/key; delay must be 0..86400; evidence-mib must be positive')
|
||||
if args.vm_config and not args.allow_real_execution:
|
||||
@@ -671,7 +705,14 @@ def main():
|
||||
parser.error('--allow-real-execution requires --vm-config')
|
||||
os.umask(0o077)
|
||||
directory = pathlib.Path(args.log_dir)
|
||||
evidence = Evidence(directory, args.evidence_mib * 1024 * 1024)
|
||||
if (directory / 'STOP').exists():
|
||||
print('Capture STOP latch exists; review storage before clearing.', file=sys.stderr)
|
||||
raise SystemExit(78)
|
||||
try:
|
||||
evidence = Evidence(directory, args.evidence_mib * 1024 * 1024)
|
||||
except OSError as error:
|
||||
print(f'Evidence storage unavailable: {error}', file=sys.stderr)
|
||||
raise SystemExit(78)
|
||||
context = None
|
||||
if args.cert:
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
@@ -699,10 +740,16 @@ def main():
|
||||
server.stop_capture(error)
|
||||
break
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
engine.emit('start', mode='disposable_vm' if engine.backend else 'simulation', bind=args.bind, port=server.server_port,
|
||||
delay=args.delay, evidence_quota_bytes=evidence.quota, max_body_bytes=MAX_BODY,
|
||||
serve_guest_files=server.serve_guest_files, real_execution=bool(engine.backend),
|
||||
payload_fetch=bool(engine.fetcher))
|
||||
try:
|
||||
engine.emit('start', mode='disposable_vm' if engine.backend else 'simulation', bind=args.bind, port=server.server_port,
|
||||
delay=args.delay, evidence_quota_bytes=evidence.quota, max_body_bytes=MAX_BODY,
|
||||
serve_guest_files=server.serve_guest_files, real_execution=bool(engine.backend),
|
||||
payload_fetch=bool(engine.fetcher))
|
||||
except EvidenceError as error:
|
||||
server.stop_capture(error)
|
||||
server.server_close()
|
||||
evidence.close()
|
||||
raise SystemExit(78)
|
||||
if engine.backend:
|
||||
print('WARNING: real execution enabled; attacker commands run only in the isolated disposable guest',
|
||||
file=sys.stderr, flush=True)
|
||||
@@ -721,7 +768,7 @@ def main():
|
||||
server.server_close()
|
||||
evidence.close()
|
||||
if server.capture_failed:
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(78)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Reference in new issue
Block a user