diff --git a/README.md b/README.md index 1fea2d3..6057a3e 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ English/German language JSON and gzip endpoints carry the default 14.1-29.63 per 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. +Authentication XML includes credential requirements, state context, a postback route and error labels after credential submissions; it grants no real access. The browser requests the initial challenge and consumes the returned XML. Gateway CSS and JavaScript use local appliance-style paths; assets support ETag/Last-Modified revalidation and HEAD, without issuing session cookies. CSP reports are captured before a204 response; logout clears the cookie and redirects locally. `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. @@ -139,7 +139,7 @@ vulnerability exists, a payload executes, or a CVE is conclusively identified. | Gateway login pages | `/`, `/vpn/`, `/vpn/index.html`, `/vpn/tmindex.html`, `/logon/LogonPoint/index.html`, `/logon/LogonPoint/tmindex.html`, `/logon/LogonPoint/custom.html`, `/menu/neo/`. German login UI, canonical Citrix Gateway title, NSC_ cookie and Gateway-shaped response headers. | | Gateway static fingerprints | Explicit rdx/CTXS JavaScript routes, login.js/init.js, original favicon/logo/background assets and AccessGateway.ico alias. Only routes in STATIC are supported; arbitrary files under these directories return404. | | Language/version fingerprints | English/German JSON and `.json.gz` under `/vpn/js/rdx/core/lang/` and `/vpn/js/rdx/core/lang-ext/`; gzip filename, build timestamp, Content-Encoding and Last-Modified. Synthetic translation subset with the default14.1-29.63 metadata. | -| Authentication XML | `/nf/auth/*` returns credential requirements, state context and local postback. This is a repeated login challenge, not LDAP, SAML, MFA or successful real authentication. | +| Authentication XML | `/nf/auth/getAuthenticationRequirements.do` and `/nf/auth/doAuthentication.do` return credential requirements, state context and local postback; credential submissions add an error label. Unknown authentication endpoints return404. This is a repeated login challenge, not LDAP, SAML, MFA or successful real authentication. | | NITRO authentication | `/nitro/v1/config/login` and logout with synthetic session tokens. Resource access accepts a matching fake token, nonempty X-NITRO-USER/X-NITRO-PASS, or a Basic authorization prefix. These are decoy rules, not real user/account validation. | | NITRO resources | `/nitro/v1/config/[/]` and `/nitro/v1/stat/[/]`;1,493 config and176 statistics names in [nitro-resources.json](nitro-resources.json). Common system/network/Gateway/AAA/SSL/LB/binding records are populated; other recognized resources return empty collections or generic counters. | | NITRO operations | Config reads, fake POST/PUT/DELETE, enable/disable/rename, inert administrative-action acknowledgements, attrs, literal filter/args, counts, pagesize and pageno. Unknown resources, invalid input, unsupported methods and unauthenticated access return simulator errors. | diff --git a/honeypot.py b/honeypot.py index f09acfe..8132042 100644 --- a/honeypot.py +++ b/honeypot.py @@ -7,6 +7,7 @@ import collections import gzip import email.parser import email.policy +import email.utils import hashlib import html import http.server @@ -51,21 +52,19 @@ STATIC = { '/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'), - # Static NetScaler Gateway scripts scanners look for; symbols match the real rdx/CTXS namespace. - '/vpn/js/rdx.js': (b"window.CTXS=window.CTXS||{};window.rdx=window.rdx||{};" - b"rdx.base_view=function(){};rdx.utils={get_view:function(){}};\n", JS), - '/vpn/js/rdx/core/rdx.js': (b"window.rdx=window.rdx||{};rdx.base_view=rdx.base_view||function(){};" - b"rdx.utils={get_view:function(){}};\n", JS), - '/vpn/js/gateway_login_view.js': (b"rdx.gateway_login_view=function(){return new rdx.base_view();};\n", JS), - '/vpn/js/gateway_login_form_view.js': (b"rdx.gateway_login_form_view=function(){return rdx.utils.get_view();};\n", JS), - '/logon/LogonPoint/init.js': (b"var _ctxstxt_CitrixCopyright='Copyright (c) Citrix Systems, Inc.';" - b"window.CTXS=window.CTXS||{};CTXS.init=function(){};\n", JS), - '/logon/LogonPoint/receiver/js/ctxs.core.min.js': (b"window.CTXS=window.CTXS||{};CTXS.core={version:'1.0'};\n", JS), + '/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), } # 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) +UI_MTIME = int(max(p.stat().st_mtime for p in UI.iterdir() if p.is_file())) for language in ('en', 'de'): translation = {'LogOn': 'Log On' if language == 'en' else 'Anmelden', 'UserName': 'User name' if language == 'en' else 'Benutzername', @@ -681,13 +680,18 @@ class Handler(http.server.BaseHTTPRequestHandler): 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/'): + 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'): data = ('' 'successmore-info' '' + html.escape(state.sid) + '' '/nf/auth/doAuthentication.do' - '' + '' + ( + 'none' + '' + '' if url.path == '/nf/auth/doAuthentication.do' + and self.command == 'POST' and auth_fields.intersection({'login', 'passwd'}) else '') + 'loginusername' '' '.+' @@ -699,6 +703,12 @@ class Handler(http.server.BaseHTTPRequestHandler): '' '').encode() mime = 'application/vnd.citrix.authenticateresponse-1+xml; charset=utf-8' + 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')] 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' @@ -715,22 +725,53 @@ class Handler(http.server.BaseHTTPRequestHandler): self.event('virtual_file_http_read', path=key, content=data) else: code, data = 404, b'Not Found' + # 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'' 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. - headers = [('Content-Type', mime), ('Content-Length', str(len(data))), - ('Cache-Control', 'no-cache, no-store, must-revalidate'), + 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'), ('Pragma', 'no-cache'), ('Expires', '0'), ('X-Frame-Options', 'SAMEORIGIN'), ('X-Content-Type-Options', 'nosniff'), ('X-XSS-Protection', '1; mode=block'), - ('Set-Cookie', f'NSC_AAAC={state.sid}; Path=/; Secure; HttpOnly'), ('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")] + 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')) headers.extend(extra_headers) self.event('response', status=code, headers=headers, content=data, body_suppressed=self.command == 'HEAD') diff --git a/test_honeypot.py b/test_honeypot.py index 915ee9b..4499ee8 100644 --- a/test_honeypot.py +++ b/test_honeypot.py @@ -101,7 +101,7 @@ def main(): wire_digest = hashlib.sha256(raw).hexdigest() # Wait for finally-recorded evidence after the response has closed. assert (pathlib.Path(workspace.name) / 'artifacts' / (wire_digest + '.bin')).read_bytes() == raw - assert b'200' in raw_request(b'CUSTOM / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n').split(b'\r\n')[0] + assert b'405' in raw_request(b'CUSTOM / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n').split(b'\r\n')[0] assert b'400' in raw_request(b'not-http\r\n') partial = b'PUT / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 10\r\n\r\nabc' assert b'400' in raw_request(partial) diff --git a/test_profile.py b/test_profile.py index c0b2be7..a2d8ef2 100644 --- a/test_profile.py +++ b/test_profile.py @@ -43,6 +43,40 @@ try: 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 + status, headers, xml = request('/nf/auth/doAuthentication.do', 'POST', + b'login=profile-check&passwd=synthetic', + {'Content-Type': 'application/x-www-form-urlencoded'}) + root = ET.fromstring(xml) + labels = root.findall('.//c:Label', ns) + assert any(label.find('c:Type', ns).text == 'error' for label in labels) + assert root.find('c:Result', ns).text == 'more-info' # Never grant authentication. + assert request('/nf/auth/not-a-real-endpoint.do')[0] == 404 + assert request('/nf/auth/doAuthentication.do')[0] == 405 + assert b' -
@@ -53,6 +21,7 @@

Melden Sie sich an

+

diff --git a/ui/login.js b/ui/login.js index 23f5d3d..c54b94d 100644 --- a/ui/login.js +++ b/ui/login.js @@ -2,19 +2,31 @@ const form = document.getElementById('login-form'); const button = document.getElementById('nsg-x1-logon-button'); const message = document.getElementById('message'); +async function authenticate(path, body) { + const response = await fetch(path, { + method: 'POST', body, credentials: 'same-origin', redirect: 'error', + headers: { Accept: 'application/vnd.citrix.authenticateresponse-1+xml' }, + signal: AbortSignal.timeout(10000) + }); + if (!response.ok) throw new Error('Request failed'); + const result = rdx.utils.authentication(await response.text()); + form.elements.StateContext.value = result.state; + // Only the local authentication endpoint can receive form credentials. + if (result.postback === '/nf/auth/doAuthentication.do') form.action = result.postback; + return result; +} +const ready = authenticate('/nf/auth/getAuthenticationRequirements.do', new URLSearchParams()) + .catch(() => null); form.addEventListener('submit', async event => { event.preventDefault(); button.disabled = true; message.textContent = ''; try { + await ready; const body = new URLSearchParams(new FormData(form)); body.set('nsg-x1-logon-button', 'Log On'); - const response = await fetch('/nf/auth/doAuthentication.do', { - method: 'POST', body, credentials: 'same-origin', redirect: 'error', - signal: AbortSignal.timeout(10000) - }); - if (!response.ok) throw new Error('Request failed'); - message.textContent = 'Anmeldung fehlgeschlagen. Überprüfen Sie Ihre Zugangsdaten.'; + const result = await authenticate('/nf/auth/doAuthentication.do', body); + message.textContent = result.errors.join(' ') || 'Anmeldung fehlgeschlagen. Überprüfen Sie Ihre Zugangsdaten.'; } catch { message.textContent = 'Die Anmeldung ist derzeit nicht möglich. Versuchen Sie es erneut.'; } finally { diff --git a/ui/rdx.css b/ui/rdx.css new file mode 100644 index 0000000..7a920fb --- /dev/null +++ b/ui/rdx.css @@ -0,0 +1,30 @@ +* { box-sizing: border-box; } + html, body { min-height: 100%; margin: 0; } + body { background: #302c41 url('/logon/LogonPoint/receiver/images/common/ReceiverFullScreenBackground.jpg') center / cover fixed; + font-family: "Helvetica Neue", "Segoe UI", Helvetica, Arial, sans-serif; color: #fff; } + main { min-height: 100vh; display: flex; align-items: center; } + .band { width: 100%; min-height: 350px; background: rgba(58, 54, 65, .86); display: flex; align-items: center; } + .layout { width: min(1086px, calc(100% - 64px)); margin: auto; display: grid; grid-template-columns: 258px 570px; align-items: center; } + .logo { width: 160px; height: auto; } + h1 { font-size: 18px; font-weight: 400; margin: 0 0 18px; } + .row { display: grid; grid-template-columns: 185px 1fr; align-items: center; margin-bottom: 6px; } + label { color: #bdbbc2; font-size: 16px; } + input { width: 100%; height: 44px; border: 1px solid #ddd; background: white; color: #111; border-radius: 0; padding: 8px 15px; font: inherit; } + input:focus-visible { outline: 2px solid #79cce2; outline-offset: 2px; } + button { display: block; width: calc(100% - 185px); margin: 26px 0 0 185px; height: 45px; border: 0; border-radius: 0; + background: #49a0bb; color: white; font: 600 16px "Helvetica Neue", "Segoe UI", Helvetica, Arial, sans-serif; cursor: pointer; } + button:hover { background: #398aa4; } + button:focus-visible { outline: 2px solid white; outline-offset: 3px; } + button:disabled { cursor: wait; opacity: .75; } + #message { margin: 12px 0 0 185px; font-size: 14px; color: #fff; } + #message:empty { display: none; } + @media (max-width: 950px) { + .layout { width: min(570px, calc(100% - 48px)); display: block; padding: 40px 0; } + .logo { margin-bottom: 28px; } + } + @media (max-width: 540px) { + .row { display: block; margin-bottom: 16px; } + label { display: block; margin-bottom: 7px; } + button { width: 100%; margin-left: 0; } + #message { margin-left: 0; } + } diff --git a/ui/rdx.js b/ui/rdx.js new file mode 100644 index 0000000..370f9a1 --- /dev/null +++ b/ui/rdx.js @@ -0,0 +1,23 @@ +'use strict'; +var _ctxstxt_CitrixCopyright = 'Copyright (c) Citrix Systems, Inc.'; +window.CTXS = window.CTXS || {}; +window.rdx = window.rdx || {}; +rdx.base_view = function (element) { this.element = element; }; +rdx.base_view.prototype.show = function () { if (this.element) this.element.hidden = false; }; +rdx.base_view.prototype.hide = function () { if (this.element) this.element.hidden = true; }; +rdx.utils = { + get_view: function (id) { return new rdx.base_view(document.getElementById(id || 'login-form')); }, + authentication: function (xml) { + const document = new DOMParser().parseFromString(xml, 'application/xml'); + const ns = 'http://citrix.com/authentication/response/1'; + if (document.getElementsByTagName('parsererror').length || + document.documentElement.localName !== 'AuthenticateResponse' || + document.documentElement.namespaceURI !== ns) throw new Error('Invalid authentication response'); + const text = name => document.getElementsByTagNameNS(ns, name)[0]?.textContent || ''; + const errors = Array.from(document.getElementsByTagNameNS(ns, 'Label')) + .filter(label => label.getElementsByTagNameNS(ns, 'Type')[0]?.textContent === 'error') + .map(label => label.getElementsByTagNameNS(ns, 'Text')[0]?.textContent || ''); + return { state: text('StateContext'), postback: text('PostBack'), errors }; + } +}; +CTXS.core = { getView: rdx.utils.get_view };