Files
citrixpot/honeypot.py
T

728 lines
34 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Experimental CVE-2026-88771 decoy. Simulation by default; opt-in disposable VM execution."""
import argparse
import collections
import email.parser
import email.policy
import hashlib
import html
import http.server
import ipaddress
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 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(32)
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.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 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
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 = ('<AuthenticateResponse xmlns="http://citrix.com/authentication/response/1">'
'<Status>success</Status><Result>more-info</Result>'
'<StateContext>' + html.escape(state.sid) + '</StateContext>'
'</AuthenticateResponse>').encode()
mime = 'application/vnd.citrix.authenticateresponse-1+xml; charset=utf-8'
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")]
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('--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 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)
evidence = Evidence(directory, args.evidence_mib * 1024 * 1024)
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()
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))
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(1)
if __name__ == '__main__':
main()