Expand capture-only NetScaler NITRO simulation and crash recovery

This commit is contained in:
StasonJatham committed 2026-10-04 06:16:31 +02:00
1 parent 7b99db9e25
commit c65f049223
10 files changed
+2097 -19

No files matched your search

+54 -7
View File
@@ -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__':