2026-09-28 19:44:37 +02:00
#!/usr/bin/env python3
"""Experimental CVE-2026-88771 decoy. Simulation by default; opt-in disposable VM execution."""
import argparse
2026-10-04 06:16:31 +02:00
import base64
import binascii
2026-09-28 19:44:37 +02:00
import collections
2026-10-04 06:48:22 +02:00
import gzip
2026-09-28 19:44:37 +02:00
import email.parser
import email.policy
2026-10-07 07:32:34 +02:00
import email.utils
2026-09-28 19:44:37 +02:00
import hashlib
import html
import http.server
import ipaddress
2026-10-04 06:48:22 +02:00
import io
2026-10-04 06:16:31 +02:00
import json
2026-09-28 19:44:37 +02:00
import os
import pathlib
import posixpath
import re
import secrets
import shlex
import ssl
import sys
import threading
import time
import urllib.parse
2026-10-04 06:57:59 +02:00
import zlib
2026-09-28 19:44:37 +02:00
from trusted_proxy import source as proxy_source , forwarded_client
from evidence import Evidence , EvidenceError , RecordingReader
2026-10-04 06:16:31 +02:00
from nitro import respond as nitro_response
2026-09-28 19:44:37 +02:00
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' ),
2026-10-07 07:32:34 +02:00
'/vpn/js/rdx/core/css/rdx.css' : (( UI / 'rdx.css' ) . read_bytes (), 'text/css; charset=utf-8' ),
'/vpn/js/rdx.js' : (( UI / 'rdx.js' ) . read_bytes (), JS ),
'/vpn/js/rdx/core/rdx.js' : (( UI / 'rdx.js' ) . read_bytes (), JS ),
'/vpn/js/gateway_login_view.js' : ( b "rdx.gateway_login_view=function(){return new rdx.base_view(document.querySelector('main'));}; \n " , JS ),
'/vpn/js/gateway_login_form_view.js' : ( b "rdx.gateway_login_form_view=function(){return new rdx.base_view(document.getElementById('login-form'));}; \n " , JS ),
'/logon/LogonPoint/init.js' : ( b "window.CTXS=window.CTXS|| {} ;CTXS.init=function(){return rdx.utils.get_view('login-form');}; \n " , JS ),
'/logon/LogonPoint/receiver/js/ctxs.core.min.js' : (( UI / 'rdx.js' ) . read_bytes (), JS ),
2026-09-28 19:44:37 +02:00
}
2026-10-04 06:48:22 +02:00
# 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 )
2026-10-07 07:32:34 +02:00
UI_MTIME = int ( max ( p . stat () . st_mtime for p in UI . iterdir () if p . is_file ()))
2026-10-04 06:48:22 +02:00
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' )
2026-09-28 19:44:37 +02:00
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
2026-10-04 06:16:31 +02:00
self . slots = threading . BoundedSemaphore ( 8 )
2026-09-28 19:44:37 +02:00
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
2026-10-04 06:16:31 +02:00
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
2026-09-28 19:44:37 +02:00
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\n MIME-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 []
2026-10-04 06:16:31 +02:00
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 )
2026-09-28 19:44:37 +02:00
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 []
2026-10-04 06:57:59 +02:00
def capture_saml ( self , pairs , source ):
# Decode protocol envelopes only. Never parse XML, expand entities or authenticate.
for field , value in pairs :
if field . lower () not in ( 'samlrequest' , 'samlresponse' ):
continue
try :
decoded = base64 . b64decode ( value , altchars = b '-_' , validate = True )
except ( ValueError , binascii . Error ):
self . event ( 'saml_decode_error' , field = field , source = source , raw_request_retained = True )
continue
self . event ( 'saml_payload' , field = field , source = source , encoding = 'base64' , content = decoded )
# HTTP-Redirect binding may carry raw DEFLATE. Bound output before allocation.
if source == 'query' and not decoded . lstrip () . startswith ( b '<' ):
try :
inflater = zlib . decompressobj ( - zlib . MAX_WBITS )
inflated = inflater . decompress ( decoded , 65537 )
if len ( inflated ) > 65536 or inflater . unconsumed_tail :
self . event ( 'limit' , resource = 'saml_inflation' , decoded_payload_retained = True )
elif inflater . eof and not inflater . unused_data :
self . event ( 'saml_payload' , field = field , source = source ,
encoding = 'base64+deflate' , content = inflated )
else :
self . event ( 'saml_decode_error' , field = field , source = source ,
reason = 'incomplete deflate envelope' , decoded_payload_retained = True )
except zlib . error :
self . event ( 'saml_decode_error' , field = field , source = source ,
reason = 'invalid deflate envelope' , decoded_payload_retained = True )
2026-09-28 19:44:37 +02:00
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 )
2026-10-04 06:48:22 +02:00
# 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' })
2026-09-28 19:44:37 +02:00
try :
url = urllib . parse . urlsplit ( self . path )
except ValueError :
self . send_error ( 400 , 'invalid URL' )
return
2026-10-04 06:57:59 +02:00
self . capture_saml ( fields , 'form' )
if len ( url . query ) <= 65536 :
try :
query_fields = urllib . parse . parse_qsl ( url . query , max_num_fields = 128 )
self . capture_saml ( query_fields , 'query' )
if any ( k . lower () in ( 'samlrequest' , 'samlresponse' ) for k , _ in fields + query_fields ):
indicators . append ({ 'source' : 'request' , 'kind' : 'saml-envelope' })
except ValueError :
self . event ( 'limit' , resource = 'query_decoding' , raw_request_retained = True )
else :
self . event ( 'limit' , resource = 'query_decoding' , raw_request_retained = True )
if url . path == '/logon/LogonPoint/Authentication/GetUserName' :
indicators . append ({ 'source' : 'uri' , 'kind' : 'gateway-username-probe' })
if indicators :
self . event ( 'request_indicators' , indicators = indicators ,
body_inspected_bytes = min ( len ( body ), 65536 ), body_inspection_truncated = len ( body ) > 65536 )
2026-09-28 19:44:37 +02:00
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
2026-10-04 06:16:31 +02:00
extra_headers = []
2026-09-28 19:44:37 +02:00
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 ]
2026-10-04 06:48:22 +02:00
if url . path . endswith ( '.gz' ):
extra_headers . append (( 'Content-Encoding' , 'gzip' ))
2026-10-07 07:32:34 +02:00
asset_mtime = ASSET_MTIME if '/lang/' in url . path or '/lang-ext/' in url . path else UI_MTIME
extra_headers . append (( 'Last-Modified' , self . date_time_string ( asset_mtime )))
elif url . path in ( '/nf/auth/getAuthenticationRequirements.do' , '/nf/auth/doAuthentication.do' ):
2026-09-28 19:44:37 +02:00
data = ( '<AuthenticateResponse xmlns="http://citrix.com/authentication/response/1">'
'<Status>success</Status><Result>more-info</Result>'
'<StateContext>' + html . escape ( state . sid ) + '</StateContext>'
2026-10-04 06:48:22 +02:00
'<AuthenticationRequirements><PostBack>/nf/auth/doAuthentication.do</PostBack>'
2026-10-07 07:32:34 +02:00
'<Requirements>' + (
'<Requirement><Credential><Type>none</Type></Credential>'
'<Label><Text>Incorrect user name or password.</Text><Type>error</Type></Label>'
'<Input/></Requirement>' if url . path == '/nf/auth/doAuthentication.do'
and self . command == 'POST' and auth_fields . intersection ({ 'login' , 'passwd' }) else '' ) +
2026-10-04 06:48:22 +02:00
'<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>'
2026-09-28 19:44:37 +02:00
'</AuthenticateResponse>' ) . encode ()
mime = 'application/vnd.citrix.authenticateresponse-1+xml; charset=utf-8'
2026-10-07 07:32:34 +02:00
elif url . path == '/nscsp_violation/report_uri' :
code , data , mime = 204 , b '' , 'text/plain; charset=utf-8'
elif url . path in ( '/cgi/logout' , '/vpn/logout.html' ):
code , data = 302 , b ''
extra_headers = [( 'Location' , '/vpn/index.html' ),
( 'Set-Cookie' , 'NSC_AAAC=; Path=/; Max-Age=0; Secure; HttpOnly' )]
2026-10-04 06:16:31 +02:00
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'
2026-09-28 19:44:37 +02:00
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'
2026-10-07 07:32:34 +02:00
# Capture and ingest precede protocol responses, including rejected methods.
if url . path in STATIC or url . path in LOGIN_PATHS :
allowed = ( 'GET' , 'HEAD' )
elif url . path == '/nf/auth/doAuthentication.do' or url . path == '/nscsp_violation/report_uri' :
allowed = ( 'POST' ,)
elif url . path == '/nf/auth/getAuthenticationRequirements.do' :
allowed = ( 'GET' , 'HEAD' , 'POST' )
else :
allowed = None
if allowed and self . command not in allowed :
code , data , mime = 405 , b 'Method Not Allowed' , 'text/plain; charset=utf-8'
extra_headers = [( 'Allow' , ', ' . join ( allowed ))]
if url . path in STATIC and code == 200 :
etag = '"' + hashlib . sha256 ( data ) . hexdigest () + '"'
extra_headers . append (( 'ETag' , etag ))
matches = self . headers . get ( 'If-None-Match' )
not_modified = matches is not None and any (
tag . strip () . removeprefix ( 'W/' ) in ( '*' , etag ) for tag in matches . split ( ',' ))
if matches is None and self . headers . get ( 'If-Modified-Since' ):
try :
not_modified = email . utils . parsedate_to_datetime (
self . headers [ 'If-Modified-Since' ]) . timestamp () >= asset_mtime
except ( ValueError , TypeError , OverflowError ):
pass
if not_modified :
code , data = 304 , b ''
2026-09-28 19:44:37 +02:00
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.
2026-10-07 07:32:34 +02:00
headers = [( 'Content-Type' , mime ),
( 'Cache-Control' , 'public, max-age=0, must-revalidate' if url . path in STATIC
and code in ( 200 , 304 ) else 'no-cache, no-store, must-revalidate' ),
2026-09-28 19:44:37 +02:00
( 'Pragma' , 'no-cache' ), ( 'Expires' , '0' ),
( 'X-Frame-Options' , 'SAMEORIGIN' ), ( 'X-Content-Type-Options' , 'nosniff' ),
( 'X-XSS-Protection' , '1; mode=block' ),
( '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" )]
2026-10-07 07:32:34 +02:00
if code not in ( 204 , 304 ):
headers . append (( 'Content-Length' , str ( len ( data ))))
if url . path not in STATIC and url . path != '/nscsp_violation/report_uri' and not any (
key == 'Set-Cookie' for key , _ in extra_headers ):
headers . append (( 'Set-Cookie' , f 'NSC_AAAC= { state . sid } ; Path=/; Secure; HttpOnly' ))
2026-10-04 06:16:31 +02:00
headers . extend ( extra_headers )
2026-09-28 19:44:37 +02:00
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__ )
2026-10-04 06:16:31 +02:00
parser . add_argument ( '--simulation-only' , action = 'store_true' , help = 'refuse real execution and URL fetching' )
2026-09-28 19:44:37 +02:00
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 ()
2026-10-04 06:16:31 +02:00
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' )
2026-09-28 19:44:37 +02:00
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 )
2026-10-04 06:16:31 +02:00
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 )
2026-09-28 19:44:37 +02:00
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 ()
2026-10-04 06:16:31 +02:00
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 )
2026-09-28 19:44:37 +02:00
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 :
2026-10-04 06:16:31 +02:00
raise SystemExit ( 78 )
2026-09-28 19:44:37 +02:00
if __name__ == '__main__' :
main ()