824 lines
40 KiB
Python
824 lines
40 KiB
Python
#!/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 gzip
|
|
import email.parser
|
|
import email.policy
|
|
import hashlib
|
|
import html
|
|
import http.server
|
|
import ipaddress
|
|
import io
|
|
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),
|
|
}
|
|
|
|
# Synthetic translations; metadata matches the default 14.1-29.63 persona.
|
|
# Source: Fox-IT citrix-netscaler-triage version timestamp catalog.
|
|
ASSET_MTIME = env_int('HONEYPOT_ASSET_MTIME', 1723114781)
|
|
for language in ('en', 'de'):
|
|
translation = {'LogOn': 'Log On' if language == 'en' else 'Anmelden',
|
|
'UserName': 'User name' if language == 'en' else 'Benutzername',
|
|
'Password': 'Password' if language == 'en' else 'Kennwort',
|
|
'CitrixCopyright': 'Copyright (c) Citrix Systems, Inc.'}
|
|
for suffix in ('lang', 'lang-ext'):
|
|
path = f'/vpn/js/rdx/core/{suffix}/rdx_{language}.json'
|
|
encoded = json.dumps(translation, ensure_ascii=False).encode()
|
|
STATIC[path] = (encoded, 'application/json; charset=utf-8')
|
|
compressed = io.BytesIO()
|
|
with gzip.GzipFile(filename=f'rdx_{language}.json', mode='wb', fileobj=compressed,
|
|
mtime=ASSET_MTIME) as stream:
|
|
stream.write(encoded)
|
|
STATIC[path + '.gz'] = (compressed.getvalue(), 'application/json; charset=utf-8')
|
|
|
|
|
|
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)
|
|
# Hunt across all intake surfaces; labels are indicators, never CVE confirmation.
|
|
indicators = []
|
|
for source, value in [('header:' + k, v) for k, v in self.headers.raw_items()] + [
|
|
('body', body[:65536].decode('utf-8', 'replace'))]:
|
|
decoded = urllib.parse.unquote_plus(value)
|
|
if re.search(r'pitboss|NSPPE', decoded, re.I):
|
|
indicators.append({'source': source, 'kind': 'netscaler-log-marker'})
|
|
if re.search(r'base64|b64decode|atob\s*\(', decoded, re.I):
|
|
indicators.append({'source': source, 'kind': 'encoded-content'})
|
|
if source.startswith('header:') and len(value) > 8192:
|
|
indicators.append({'source': source, 'kind': 'large-header'})
|
|
if indicators:
|
|
self.event('request_indicators', indicators=indicators,
|
|
body_inspected_bytes=min(len(body), 65536), body_inspection_truncated=len(body) > 65536)
|
|
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]
|
|
if url.path.endswith('.gz'):
|
|
extra_headers.append(('Content-Encoding', 'gzip'))
|
|
extra_headers.append(('Last-Modified', self.date_time_string(ASSET_MTIME)))
|
|
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>'
|
|
'<AuthenticationRequirements><PostBack>/nf/auth/doAuthentication.do</PostBack>'
|
|
'<Requirements>'
|
|
'<Requirement><Credential><ID>login</ID><Type>username</Type></Credential>'
|
|
'<Label><Text>User name:</Text><Type>plain</Type></Label>'
|
|
'<Input><Text><InitialValue/><Constraint>.+</Constraint></Text></Input></Requirement>'
|
|
'<Requirement><Credential><ID>passwd</ID><Type>password</Type></Credential>'
|
|
'<Label><Text>Password:</Text><Type>plain</Type></Label>'
|
|
'<Input><Text><Secret>true</Secret><InitialValue/><Constraint>.+</Constraint></Text></Input></Requirement>'
|
|
'<Requirement><Credential><ID>nsg-x1-logon-button</ID><Type>none</Type></Credential>'
|
|
'<Label><Type>none</Type></Label><Input><Button>Log On</Button></Input></Requirement>'
|
|
'</Requirements></AuthenticationRequirements>'
|
|
'</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:
|
|
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()
|