Improve Gateway fingerprints and header/body coverage

This commit is contained in:
StasonJatham committed 2026-10-04 06:48:22 +02:00
1 parent c65f049223
commit d3b8967aa7
4 files changed
+117 -1

No files matched your search

+11
View File
@@ -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 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. 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, The NITRO simulator supports login/logout cookies, configuration and statistics resource routes,
named reads, fake POST/PUT/DELETE operations, enable/disable/rename acknowledgements, bindings, 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 `attrs`, literal `filter`/`args`, counts and pagination. Its catalog recognizes 1,493 configuration
+48
View File
@@ -4,12 +4,14 @@ import argparse
import base64 import base64
import binascii import binascii
import collections import collections
import gzip
import email.parser import email.parser
import email.policy import email.policy
import hashlib import hashlib
import html import html
import http.server import http.server
import ipaddress import ipaddress
import io
import json import json
import os import os
import pathlib 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), '/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: class Session:
def __init__(self): def __init__(self):
@@ -583,6 +603,20 @@ class Handler(http.server.BaseHTTPRequestHandler):
return return
self.body_complete = True self.body_complete = True
fields = self.capture_body(body) 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: try:
url = urllib.parse.urlsplit(self.path) url = urllib.parse.urlsplit(self.path)
except ValueError: except ValueError:
@@ -603,10 +637,24 @@ class Handler(http.server.BaseHTTPRequestHandler):
code, data, mime = 200, LOGIN, 'text/html; charset=utf-8' code, data, mime = 200, LOGIN, 'text/html; charset=utf-8'
if url.path in STATIC: if url.path in STATIC:
data, mime = STATIC[url.path] 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/'): elif url.path.startswith('/nf/auth/'):
data = ('<AuthenticateResponse xmlns="http://citrix.com/authentication/response/1">' data = ('<AuthenticateResponse xmlns="http://citrix.com/authentication/response/1">'
'<Status>success</Status><Result>more-info</Result>' '<Status>success</Status><Result>more-info</Result>'
'<StateContext>' + html.escape(state.sid) + '</StateContext>' '<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() '</AuthenticateResponse>').encode()
mime = 'application/vnd.citrix.authenticateresponse-1+xml; charset=utf-8' mime = 'application/vnd.citrix.authenticateresponse-1+xml; charset=utf-8'
elif url.path.startswith('/nitro/'): elif url.path.startswith('/nitro/'):
+57
View File
@@ -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'<title class="_ctxstxt_NetscalerGateway">Citrix Gateway</title>' 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('<I', compressed[4:8])[0] == ASSET_MTIME == 1723114781
assert gzip.decompress(compressed) == request(path)[2]
assert request(path + '.gz', 'HEAD')[2] == b''
status, headers, xml = request('/nf/auth/getAuthenticationRequirements.do')
root = ET.fromstring(xml)
ns = {'c': 'http://citrix.com/authentication/response/1'}
assert root.find('c:AuthenticationRequirements/c:PostBack', ns).text == '/nf/auth/doAuthentication.do'
assert len(root.findall('.//c:Requirement', ns)) == 3
# Inert markers, no exploit syntax or commands. Original intake still correlated.
status, _, _ = request('/unknown', 'POST', b'base64=inert-marker',
{'Content-Type': 'application/x-www-form-urlencoded', 'X_Test': 'NSPPE inert-marker'})
assert status == 404
indicator = next(e for e in events if e['event'] == 'request_indicators')
assert {'source': 'header:X_Test', 'kind': 'netscaler-log-marker'} in indicator['indicators']
assert {'source': 'body', 'kind': 'encoded-content'} in indicator['indicators']
assert any(e['event'] == 'request_body' and e['request_id'] == indicator['request_id'] for e in events)
for path in PATHS + ('/.env', '/nsconfig/ns.conf', '/api/events'):
status, _, body = request(path)
assert status == 404 and body == b'Not Found', path
print('PASS: Gateway fingerprint, gzip firmware metadata, auth XML, correlated indicators, private boundary')
finally:
server.shutdown()
server.server_close()
thread.join(3)
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow"> <meta name="robots" content="noindex, nofollow">
<title>NetScaler AAA</title> <title class="_ctxstxt_NetscalerGateway">Citrix Gateway</title>
<link rel="icon" href="/logon/LogonPoint/receiver/images/common/icon_vpn.ico" sizes="16x16 32x32 48x48 64x64" type="image/vnd.microsoft.icon"> <link rel="icon" href="/logon/LogonPoint/receiver/images/common/icon_vpn.ico" sizes="16x16 32x32 48x48 64x64" type="image/vnd.microsoft.icon">
<link rel="shortcut icon" href="/logon/LogonPoint/receiver/images/common/icon_vpn.ico" type="image/vnd.microsoft.icon"> <link rel="shortcut icon" href="/logon/LogonPoint/receiver/images/common/icon_vpn.ico" type="image/vnd.microsoft.icon">
<style> <style>