Improve Gateway fingerprints and header/body coverage
This commit is contained in:
1 parent
c65f049223
commit
d3b8967aa7
4 files changed
+117
-1
No files matched your search
@@ -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
@@ -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/'):
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user