#!/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 import hashlib import html import http.server import ipaddress import json import os import pathlib import posixpath import re import secrets import shlex import ssl import sys import threading 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 MAX_FILES = 64 MAX_FILE = 8192 MAX_SESSIONS = 128 MAX_PENDING = 16 TTL = 3600 WEBROOT = '/netscaler/ns_gui' UI = pathlib.Path(__file__).parent / 'ui' LOGIN = (UI / 'login.html').read_bytes() # The login page is served at every entry point a Citrix Gateway exposes. LOGIN_PATHS = {'/', '/vpn/', '/vpn/index.html', '/vpn/tmindex.html', '/logon/LogonPoint/tmindex.html', '/logon/LogonPoint/index.html', '/logon/LogonPoint/custom.html', '/menu/neo/'} JS = 'application/javascript; charset=utf-8' STATIC = { '/logon/LogonPoint/login.js': ((UI / 'login.js').read_bytes(), JS), '/logon/LogonPoint/receiver/images/common/ReceiverFullScreenBackground.jpg': ((UI / 'background.jpg').read_bytes(), 'image/jpeg'), '/vpn/media/citrixgateway_logo_white.png': ((UI / 'logo.png').read_bytes(), 'image/png'), '/logon/LogonPoint/receiver/images/common/icon_vpn.ico': ((UI / 'favicon.ico').read_bytes(), 'image/vnd.microsoft.icon'), '/vpn/images/AccessGateway.ico': ((UI / 'favicon.ico').read_bytes(), 'image/vnd.microsoft.icon'), '/favicon.ico': ((UI / 'favicon.ico').read_bytes(), 'image/vnd.microsoft.icon'), # Static NetScaler Gateway scripts scanners look for; symbols match the real rdx/CTXS namespace. '/vpn/js/rdx.js': (b"window.CTXS=window.CTXS||{};window.rdx=window.rdx||{};" b"rdx.base_view=function(){};rdx.utils={get_view:function(){}};\n", JS), '/vpn/js/rdx/core/rdx.js': (b"window.rdx=window.rdx||{};rdx.base_view=rdx.base_view||function(){};" b"rdx.utils={get_view:function(){}};\n", JS), '/vpn/js/gateway_login_view.js': (b"rdx.gateway_login_view=function(){return new rdx.base_view();};\n", JS), '/vpn/js/gateway_login_form_view.js': (b"rdx.gateway_login_form_view=function(){return rdx.utils.get_view();};\n", JS), '/logon/LogonPoint/init.js': (b"var _ctxstxt_CitrixCopyright='Copyright (c) Citrix Systems, Inc.';" b"window.CTXS=window.CTXS||{};CTXS.init=function(){};\n", JS), '/logon/LogonPoint/receiver/js/ctxs.core.min.js': (b"window.CTXS=window.CTXS||{};CTXS.core={version:'1.0'};\n", JS), } class Session: def __init__(self): self.sid = secrets.token_hex(16) self.seen = time.monotonic() self.cwd = '/var/tmp' self.files = {'/etc/passwd': 'root:*:0:0:Charlie &:/root:/bin/sh\n', '/nsconfig/ns.conf': '# synthetic honeypot configuration\n'} self.pending = collections.deque(maxlen=MAX_PENDING) self.auth_started = time.monotonic() self.auth_count = 0 def path(self, name): # All paths stay dictionary keys, including traversal and absolute paths. return posixpath.normpath(posixpath.join(self.cwd, name)) def commands(text): """Small shell-like grammar; unsupported constructs are recorded, never executed.""" lexer = shlex.shlex(text, posix=True, punctuation_chars=';&|<>') lexer.whitespace_split = True lexer.commenters = '#' group = [] for token in lexer: if token in (';', '&&', '||', '|', '&'): if group: yield group group = [] else: group.append(token) if group: yield group def simulate(session, text, emit, download=None): # ponytail: bounded subset, not a POSIX shell; add grammar only from captured evidence. try: groups = list(commands(text)) if len(groups) > 32: emit('limit', resource='simulated_commands', total=len(groups), retained=32) groups = groups[:32] except ValueError: emit('unsupported', reason='shell_parse', command=text) return for tokens in groups: original = tokens[:] dest, append = None, False for operator in ('>>', '>'): if operator in tokens: index = tokens.index(operator) if index + 1 < len(tokens): dest, append = session.path(tokens[index + 1]), operator == '>>' tokens = tokens[:index] break if not tokens: continue name, *args = tokens name = posixpath.basename(name) output = '' supported = True if name == 'id': output = 'uid=0(root) gid=0(wheel) groups=0(wheel)\n' elif name == 'whoami': output = 'root\n' elif name == 'uname': output = 'FreeBSD ns 11.4-RELEASE-p17 FreeBSD 11.4-RELEASE-p17 amd64\n' elif name == 'pwd': output = session.cwd + '\n' elif name == 'cd' and args: session.cwd = session.path(args[0]) emit('virtual_directory_change', path=session.cwd) elif name == 'cat': for arg in args: path = session.path(arg) emit('virtual_file_read', path=path, exists=path in session.files, content=session.files.get(path, '').encode()) output = ''.join(session.files.get(session.path(arg), 'cat: No such file or directory\n') for arg in args) elif name == 'ls': output = '\n'.join(sorted(session.files)) + '\n' elif name == 'echo': output = ' '.join(args) + '\n' elif name == 'printf' and args: if args[0] in ('%s', '%s\\n'): output = ('\n' if args[0].endswith('\\n') else '').join(args[1:]) if args[0].endswith('\\n'): output += '\n' elif len(args) == 1 and '%' not in args[0]: output = args[0].replace('\\n', '\n') else: supported = False elif name == 'touch' and len(args) == 1: dest = session.path(args[0]) output = session.files.get(dest, '') elif name == 'rm': for arg in args: if not arg.startswith('-'): path = session.path(arg) emit('virtual_file_delete', path=path, exists=path in session.files, content=session.files.get(path, '').encode()) session.files.pop(path, None) elif name in ('curl', 'wget', 'nc', 'ncat', 'telnet', 'ssh', 'ftp', 'fetch'): emit('outbound_attempt', argv=original, disposition='recorded_only') if download and name in ('curl', 'wget', 'fetch'): download(' '.join(original)) # GET and store the URL's bytes; never executed supported = False elif name in (':', 'true', 'mkdir'): pass else: supported = False emit('simulated_command' if supported else 'unsupported', argv=original, cwd=session.cwd, output=output[:MAX_FILE], output_truncated=len(output) > MAX_FILE) if dest and supported: if dest in session.files or len(session.files) < MAX_FILES: content = (session.files.get(dest, '') if append else '') + output session.files[dest] = content[:MAX_FILE] emit('virtual_file_write', path=dest, bytes=len(session.files[dest]), content=session.files[dest].encode(), truncated=len(content) > MAX_FILE) else: emit('limit', resource='virtual_files') def extract(value): """Approximate the documented log-to-core-name transform, without running it.""" if not re.search(r'pitboss.*PPE.*(?:missed too many heartbeats|unexpectedly died)', value, re.I): return None if 'NSPPE' not in value: return None tail = 'NSPPE' + value.rsplit('NSPPE', 1)[1] # The published backtick wrapper carries the full command, spaces included, anywhere in the line. substitutions = re.findall(r'`([^`]+)`', tail) if substitutions: return ';'.join(substitutions) fields = tail.replace('(', '').replace(')', '').split() core = '-'.join(fields[:2]) if ';' not in core: return None # Simple semicolon injection without backticks. return core.split(';', 1)[-1] # Conservative, path-based indicators for known Citrix ADC/Gateway exploitation and probes. # Every request is already logged in full; these labels make hunting fast without guessing. SIGNATURES = ( ('CVE-2019-19781', re.compile(r'/vpn/\.\./vpns|/vpns/(?:cfg|portal|scripts)|\.\./vpns', re.I)), ('path-traversal', re.compile(r'\.\.(?:/|%2f)|%2e%2e|%252e', re.I)), ('config-read', re.compile(r'/nsconfig/|/flash/nsconfig|/ns\.conf', re.I)), ('management-probe', re.compile(r'/(?:gwtest|rapi|cgi-bin|menu|nitro)/', re.I)), ) def exploit_signatures(path): raw = urllib.parse.unquote(path) return [name for name, pattern in SIGNATURES if pattern.search(path) or pattern.search(raw)] class Engine: def __init__(self, emit, delay=1, save=None, fetcher=None): self.sink, self.save = emit, save self.delay = delay self.sessions = collections.OrderedDict() self.lock = threading.RLock() self.backend = None self.fetcher = fetcher def emit(self, event, **data): try: if 'content' in data: content = data.pop('content') data['evidence'] = (self.save(content) if self.save else dict(sha256=hashlib.sha256(content).hexdigest(), bytes=len(content))) self.sink(event, **data) except OSError as error: raise EvidenceError(str(error)) from error def session(self, peer): # ponytail: per-source state merges clients behind NAT; use validated sessions if needed. now = time.monotonic() for key in list(self.sessions): if now - self.sessions[key].seen > TTL: self.emit('session_expired', peer=key, session=self.sessions[key].sid, pending_dropped=len(self.sessions[key].pending)) del self.sessions[key] if peer not in self.sessions: if len(self.sessions) >= MAX_SESSIONS: key, old = self.sessions.popitem(last=False) self.emit('session_evicted', peer=key, session=old.sid, pending_dropped=len(old.pending)) self.sessions[peer] = Session() state = self.sessions[peer] state.seen = now self.sessions.move_to_end(peer) return state def auth_attempt(self, state, context, path): # ponytail: bounded fixed 5-minute window; raw events support later cross-window analysis. with self.lock: now = time.monotonic() if now - state.auth_started >= 300: state.auth_started, state.auth_count = now, 0 state.auth_count += 1 data = dict(context, path=path, attempts_in_window=state.auth_count, window_seconds=300, threshold=10, classification='suspected_brute_force' if state.auth_count >= 10 else 'auth_submission', outcome='decoy_no_authentication') self.emit('auth_attempt', **data) if state.auth_count % 10 == 0: self.emit('brute_force_suspected', **data) def ingest(self, state, candidates, context=None): context = dict(context or {}, session=state.sid) for source, value in candidates: command = extract(value) if command is not None: self.emit('injection_candidate', **context, source=source, value=value, command=command, confidence='heuristic') if len(state.pending) < MAX_PENDING: state.pending.append((time.monotonic() + self.delay, command, context)) else: self.emit('limit', **context, resource='pending_commands') def tick(self): pending_vm = [] with self.lock: for state in self.sessions.values(): while state.pending and state.pending[0][0] <= time.monotonic(): _, command, context = state.pending.popleft() if self.backend: pending_vm.append((command, context)) continue self.emit('simulation_start', **context, command=command) download = (lambda text: self.fetcher.submit(text, context)) if self.fetcher else None simulate(state, command, lambda event, **data: self.emit(event, **context, **data), download) self.emit('simulation_end', **context) for command, context in pending_vm: self.backend.execute(command, context) class Server(http.server.ThreadingHTTPServer): daemon_threads = True allow_reuse_address = True def __init__(self, address, engine, tls=None, proxy_from=None, serve_guest_files=True, trusted_forwarded=()): self.engine, self.tls = engine, tls self.proxy_from = proxy_from self.serve_guest_files = serve_guest_files # 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(8) self.local = threading.local() super().__init__(address, Handler) def _trusts_forwarded(self, peer): try: address = ipaddress.ip_address(peer) except ValueError: return False return any(address in network for network in self.trusted_forwarded) def process_request(self, request, address): if not self.slots.acquire(blocking=False): self.engine.emit('connection_rejected', peer=address[0], peer_port=address[1], reason='connection_limit') self.shutdown_request(request) return try: super().process_request(request, address) except BaseException: self.slots.release() raise def process_request_thread(self, request, address): try: request.settimeout(5) if self.proxy_from: original = address address, header = proxy_source(request, original, self.proxy_from) self.engine.emit('trusted_ingress', peer=address[0], peer_port=address[1], transport_peer=original[0], transport_port=original[1], content=header) if self.tls: request = self.tls.wrap_socket(request, server_side=True) # The hop that connected to us is the PROXY-declared source (or socket peer without PROXY). self.local.trusted = self._trusts_forwarded(address[0]) super().process_request_thread(request, address) except (OSError, ssl.SSLError, ValueError, UnicodeError): if isinstance(sys.exc_info()[1], EvidenceError): self.handle_error(request, address) else: try: self.engine.emit('transport_error', peer=address[0], peer_port=address[1], error=type(sys.exc_info()[1]).__name__) except EvidenceError as error: self.stop_capture(error) self.shutdown_request(request) finally: self.slots.release() def handle_error(self, request, address): error = sys.exc_info()[1] if isinstance(error, EvidenceError): self.stop_capture(error) else: try: self.engine.emit('handler_error', peer=address[0], peer_port=address[1], error=type(error).__name__) except EvidenceError as failure: self.stop_capture(failure) def stop_capture(self, error): 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) except OSError: pass print(f'CAPTURE STOPPED: {error}', file=sys.stderr, flush=True) threading.Thread(target=self.shutdown, daemon=True).start() class Handler(http.server.BaseHTTPRequestHandler): server_version = 'Apache' sys_version = '' protocol_version = 'HTTP/1.1' def version_string(self): return self.server_version def log_message(self, format, *args): self.event('http_diagnostic', message=format % args) def event(self, event, **data): self.server.engine.emit(event, **self.context, **data) def handle_one_request(self): self.context = dict(request_id=secrets.token_hex(16), peer=self.client_address[0], peer_port=self.client_address[1], local_ip=self.connection.getsockname()[0], local_port=self.connection.getsockname()[1]) self.status = None self.body_complete = False self.started = time.monotonic_ns() original = self.rfile recorder = RecordingReader(original, MAX_BODY + 131072) self.rfile = recorder try: self.event('connection', tls=isinstance(self.connection, ssl.SSLSocket), tls_cipher=self.connection.cipher() if isinstance(self.connection, ssl.SSLSocket) else None) super().handle_one_request() except EvidenceError: raise except (TimeoutError, ConnectionError, OSError) as error: self.event('request_interrupted', reason=type(error).__name__) finally: self.rfile = original self.event('request_end', status=self.status, duration_ms=(time.monotonic_ns() - self.started) / 1e6, body_complete=self.body_complete, wire_truncated=recorder.truncated, content=bytes(recorder.data), capture_scope='consumed HTTP bytes after TLS; unread/rejected bytes not captured') def send_response(self, code, message=None): self.status = code super().send_response(code, message) def send_error(self, code, message=None, explain=None): self.event('http_rejected', status=code, reason=message or self.responses.get(code, ('',))[0]) super().send_error(code, message, explain) def __getattr__(self, name): if name.startswith('do_'): return self.handle_decoy raise AttributeError(name) def read_bytes(self, size): chunks = bytearray() while len(chunks) < size: if time.monotonic_ns() - self.started > 30 * 10**9: raise ValueError('request duration limit') data = self.rfile.read1(min(65536, size - len(chunks))) if not data: raise ValueError('incomplete request body') chunks.extend(data) return bytes(chunks) def read_body(self): lengths = self.headers.get_all('Content-Length', []) transfers = self.headers.get_all('Transfer-Encoding', []) if len(lengths) > 1 or len(transfers) > 1 or (lengths and transfers): raise ValueError('ambiguous body framing') if transfers: if transfers[0].lower().strip() != 'chunked': raise ValueError('unsupported transfer encoding') body = bytearray() for _ in range(4096): line = self.rfile.readline(1025) if len(line) > 1024 or not line.endswith(b'\r\n'): raise ValueError('invalid chunk header') size_text = line.split(b';', 1)[0].strip() if not re.fullmatch(rb'[0-9a-fA-F]+', size_text): raise ValueError('invalid chunk size') size = int(size_text, 16) if len(body) + size > MAX_BODY: raise OverflowError('body limit') if size == 0: trailers = bytearray() while len(trailers) <= 32768: line = self.rfile.readline(1025) trailers.extend(line) if line == b'\r\n': self.event('request_trailers', content=bytes(trailers)) return bytes(body) if not line or len(line) > 1024: break raise ValueError('invalid trailers') body.extend(self.read_bytes(size)) if self.read_bytes(2) != b'\r\n': raise ValueError('invalid chunk terminator') raise ValueError('chunk count limit') length = int(lengths[0]) if lengths else 0 if length < 0: raise ValueError('negative content length') if length > MAX_BODY: raise OverflowError('body limit') return self.read_bytes(length) def capture_body(self, body): self.event('request_body', content=body, content_type=self.headers.get('Content-Type', '')) kind = self.headers.get_content_type() if kind == 'multipart/form-data': raw_type = self.headers.get('Content-Type', '') # MIME parsing only: never use a supplied filename as a host path. message = email.parser.BytesParser(policy=email.policy.default).parsebytes( ('Content-Type: ' + raw_type + '\r\nMIME-Version: 1.0\r\n\r\n').encode('latin1') + body) if not message.is_multipart(): self.event('multipart_parse_error', reason='not multipart; raw body retained') return [] fields = [] for index, part in enumerate(message.walk()): if index >= 128: self.event('limit', resource='multipart_parts', raw_body_retained=True) break if part.is_multipart(): continue payload = part.get_payload(decode=True) or b'' filename = part.get_filename() field = part.get_param('name', header='content-disposition') self.event('upload' if filename is not None else 'multipart_field', filename=filename, field=field, content_type=part.get_content_type(), defects=[type(d).__name__ for d in part.defects], content=payload) if filename is not None and self.server.engine.backend: self.server.engine.backend.upload(payload, self.context) if filename is None and len(payload) <= 65536: fields.append((field or '', payload.decode('utf-8', 'replace'))) return fields if kind == 'application/x-www-form-urlencoded': if len(body) > 65536: self.event('limit', resource='form_decoding', raw_body_retained=True) return [] try: fields = urllib.parse.parse_qsl(body.decode('utf-8', 'replace'), max_num_fields=128) self.event('form_fields', fields=fields) return fields 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: self.server.engine.backend.upload(body, self.context) return [] def handle_decoy(self): engine = self.server.engine # Trusted Cloudflare/edge headers only when the connecting hop is a configured proxy. if getattr(self.server.local, 'trusted', False) and self.headers is not None: client, source = forwarded_client(self.headers) if client and client != self.client_address[0]: self.context.update(peer=client, client_ip_source=source, proxy_peer=self.client_address[0]) self.client_address = (client, self.client_address[1]) with engine.lock: state = engine.session(self.client_address[0]) self.context['session'] = state.sid if engine.backend: engine.backend.register_peer(self.client_address[0]) self.event('request', method=self.command, uri=self.path, version=self.request_version, headers=list(self.headers.raw_items())) signatures = exploit_signatures(self.path) if signatures: self.event('exploit_attempt', signatures=signatures, uri=self.path) if sum(len(k) + len(v) for k, v in self.headers.items()) > 32768: self.send_error(431) return try: body = self.read_body() except (ValueError, OverflowError) as error: self.send_error(413 if isinstance(error, OverflowError) else 400, str(error)) return self.body_complete = True fields = self.capture_body(body) try: url = urllib.parse.urlsplit(self.path) except ValueError: self.send_error(400, 'invalid URL') return auth_fields = {key.lower() for key, _ in fields} if self.command == 'POST' and (url.path.startswith('/nf/auth/') or url.path in ('/vpn/login', '/vpn/login.html', '/cgi/login') or 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()] candidates += [('form:' + key, value) for key, value in fields] engine.ingest(state, candidates, self.context) code, data, mime = 200, LOGIN, 'text/html; charset=utf-8' if url.path in STATIC: data, mime = STATIC[url.path] elif url.path.startswith('/nf/auth/'): data = ('' 'successmore-info' '' + html.escape(state.sid) + '' '').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: code, data = 404, b'Not Found' elif engine.backend and key.startswith(WEBROOT + '/'): vm_read_path = key code, data = 404, b'Not Found' elif key.startswith(WEBROOT + '/') and key in state.files: data = state.files[key].encode() mime = 'text/plain; charset=utf-8' self.event('virtual_file_http_read', path=key, content=data) else: code, data = 404, b'Not Found' if vm_read_path: result = engine.backend.read(vm_read_path, self.context) if result is not None: code, data, mime = 200, result, 'text/plain; charset=utf-8' # Headers shaped like the real NetScaler Gateway AAA response; HTTP/1.1 keep-alive is default. headers = [('Content-Type', mime), ('Content-Length', str(len(data))), ('Cache-Control', 'no-cache, no-store, must-revalidate'), ('Pragma', 'no-cache'), ('Expires', '0'), ('X-Frame-Options', 'SAMEORIGIN'), ('X-Content-Type-Options', 'nosniff'), ('X-XSS-Protection', '1; mode=block'), ('Set-Cookie', f'NSC_AAAC={state.sid}; Path=/; Secure; HttpOnly'), ('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " "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) for key, value in headers: self.send_header(key, value) self.end_headers() if self.command != 'HEAD': self.wfile.write(data) self.event('response_sent', status=code, body_bytes=0 if self.command == 'HEAD' else len(data)) def parse_trusted(spec): """Split a comma-separated CIDR list; @path reads one CIDR per line from a file.""" items = [] for raw in (spec or '').split(','): raw = raw.strip() if not raw: continue if raw.startswith('@'): items.extend(line.strip() for line in pathlib.Path(raw[1:]).read_text().splitlines()) else: items.append(raw) result = [item for item in items if item and not item.startswith('#')] for item in result: ipaddress.ip_network(item, strict=False) return result 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') parser.add_argument('--allow-real-execution', action='store_true', help='required with --vm-config; real attacker commands then run in the isolated guest') parser.add_argument('--bind', default=os.environ.get('HONEYPOT_BIND', '127.0.0.1')) parser.add_argument('--port', type=int, default=env_int('HONEYPOT_PORT', 8443)) parser.add_argument('--cert') parser.add_argument('--key') parser.add_argument('--proxy-protocol-from', default=os.environ.get('HONEYPOT_PROXY_PROTOCOL_FROM') or None, help='require PROXY v1 only from this exact trusted transport IP') parser.add_argument('--trusted-forwarded-from', default=os.environ.get('HONEYPOT_TRUSTED_FORWARDED_FROM') or None, help='comma-separated CIDRs whose CF-Connecting-IP/X-Forwarded-For headers are trusted') parser.add_argument('--fetch-payloads', action='store_true', help='download payload URLs from attacker commands (HTTP GET only; never executed)') parser.add_argument('--delay', type=float, default=1, help='simulated log processing delay in seconds (real appliance may take 24h)') parser.add_argument('--log-dir', default=os.environ.get('HONEYPOT_LOG_DIR', str(pathlib.Path(__file__).parent / 'logs'))) 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: parser.error('--vm-config requires --allow-real-execution; keep internet-facing listeners in simulation mode') if args.allow_real_execution and not args.vm_config: parser.error('--allow-real-execution requires --vm-config') os.umask(0o077) directory = pathlib.Path(args.log_dir) 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) context.load_cert_chain(args.cert, args.key) engine = Engine(evidence.emit, args.delay, evidence.save) try: server = Server((args.bind, args.port), engine, context, args.proxy_protocol_from, serve_guest_files=not args.hide_guest_files, trusted_forwarded=parse_trusted(args.trusted_forwarded_from)) except (OSError, ValueError) as error: parser.error(f'invalid --trusted-forwarded-from: {error}') if args.fetch_payloads: from payload_fetch import Fetcher engine.fetcher = Fetcher(engine.emit, on_failure=server.stop_capture) if args.vm_config: from vm_backend import VMBackend engine.backend = VMBackend(args.vm_config, engine.emit) engine.backend.on_failure = server.stop_capture stopping = threading.Event() def worker(): while not stopping.wait(0.25): try: engine.tick() except EvidenceError as error: server.stop_capture(error) break threading.Thread(target=worker, daemon=True).start() 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) if engine.fetcher: print('Payload fetch enabled: attacker URLs are downloaded (HTTP GET only, never executed)', file=sys.stderr, flush=True) print(f"Decoy: {'https' if context else 'http'}://{args.bind}:{server.server_port}; logs: {directory}", flush=True) try: server.serve_forever() except KeyboardInterrupt: pass finally: stopping.set() if engine.backend: engine.backend.close() server.server_close() evidence.close() if server.capture_failed: raise SystemExit(78) if __name__ == '__main__': main()