From d3b8967aa75a1dee368052fc489df4110658ef60 Mon Sep 17 00:00:00 2001 From: StasonJatham Date: Sun, 4 Oct 2026 06:48:22 +0200 Subject: [PATCH] Improve Gateway fingerprints and header/body coverage --- README.md | 11 ++++++++++ honeypot.py | 48 +++++++++++++++++++++++++++++++++++++++++ test_profile.py | 57 +++++++++++++++++++++++++++++++++++++++++++++++++ ui/login.html | 2 +- 4 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 test_profile.py 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 = ('' 'successmore-info' '' + html.escape(state.sid) + '' + '/nf/auth/doAuthentication.do' + '' + 'loginusername' + '' + '.+' + 'passwdpassword' + '' + 'true.+' + 'nsg-x1-logon-buttonnone' + '' + '' '').encode() mime = 'application/vnd.citrix.authenticateresponse-1+xml; charset=utf-8' elif url.path.startswith('/nitro/'): diff --git a/test_profile.py b/test_profile.py new file mode 100644 index 0000000..6fbddb1 --- /dev/null +++ b/test_profile.py @@ -0,0 +1,57 @@ +"""Run: python3 test_profile.py. Passive fingerprints and inert header/body intake.""" +import gzip +import http.client +import struct +import threading +import xml.etree.ElementTree as ET + +from honeypot import ASSET_MTIME, Engine, Server +from test_private_boundary import PATHS + +events = [] +server = Server(('127.0.0.1', 0), Engine(lambda event, **data: events.append(dict(event=event, **data)))) +server.serve_guest_files = False +thread = threading.Thread(target=server.serve_forever, daemon=True) +thread.start() + +def request(path, method='GET', body=None, headers=None): + c = http.client.HTTPConnection('127.0.0.1', server.server_port, timeout=3) + c.request(method, path, body, headers or {}) + r = c.getresponse() + result = r.status, dict(r.getheaders()), r.read() + c.close() + return result + +try: + status, headers, html = request('/') + assert status == 200 and b'Citrix Gateway' in html + assert headers['Set-Cookie'].startswith('NSC_') # WhatWeb passive matcher + for suffix in ('lang', 'lang-ext'): + path = '/vpn/js/rdx/core/' + suffix + '/rdx_en.json' + status, headers, compressed = request(path + '.gz') + assert status == 200 and headers['Content-Encoding'] == 'gzip' + assert compressed.startswith(b'\x1f\x8b\x08\x08') and b'rdx_en.json\x00' in compressed[:100] + assert struct.unpack(' - NetScaler AAA + Citrix Gateway