diff --git a/README.md b/README.md
index d3def83..b5c6aec 100644
--- a/README.md
+++ b/README.md
@@ -49,6 +49,17 @@ Use `--simulation-only` to refuse execution and URL fetching. The supplied HTTP
this mode and does not depend on an execution guest or relay. The loopback viewer service
disables external public-IP discovery. These services create no backups.
+Gateway fingerprints include the canonical Citrix Gateway title, NSC_ cookie and original image assets.
+English/German language JSON and gzip endpoints carry the default 14.1-29.63 persona's build timestamp
+1723114781, as catalogued by [Fox-IT](https://github.com/fox-it/citrix-netscaler-triage).
+Translations are synthetic subsets, not original firmware files. If changing HONEYPOT_VERSION, also
+set HONEYPOT_ASSET_MTIME to that firmware's documented timestamp; metadata matching does not prove
+full firmware emulation. Gzip assets are returned with Content-Encoding and Last-Modified.
+Authentication XML includes credential requirements and a postback route; it grants no real access.
+`request_indicators` hunts header/body log markers and encoding hints without claiming a confirmed CVE.
+Bodies are inspected up to64KiB for these hints; complete accepted bodies remain captured independently.
+Run `python3 test_profile.py` for passive identification, gzip decoding/metadata, XML and capture checks.
+
The NITRO simulator supports login/logout cookies, configuration and statistics resource routes,
named reads, fake POST/PUT/DELETE operations, enable/disable/rename acknowledgements, bindings,
`attrs`, literal `filter`/`args`, counts and pagination. Its catalog recognizes 1,493 configuration
diff --git a/honeypot.py b/honeypot.py
index 8898eb3..26a982e 100644
--- a/honeypot.py
+++ b/honeypot.py
@@ -4,12 +4,14 @@ 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
@@ -60,6 +62,24 @@ STATIC = {
'/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):
@@ -583,6 +603,20 @@ class Handler(http.server.BaseHTTPRequestHandler):
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:
@@ -603,10 +637,24 @@ class Handler(http.server.BaseHTTPRequestHandler):
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 = ('