Capture bounded SAML envelopes and document proxy limits
This commit is contained in:
1 parent
d3b8967aa7
commit
4da3f54e77
3 files changed
+97
-4
No files matched your search
@@ -84,6 +84,31 @@ configurable evidence quota, a 16MiB request-body limit and eight concurrent req
|
|||||||
may reject or normalize traffic before it reaches the collector; unread/rejected bytes beyond
|
may reject or normalize traffic before it reaches the collector; unread/rejected bytes beyond
|
||||||
these boundaries cannot be promised as captured.
|
these boundaries cannot be promised as captured.
|
||||||
|
|
||||||
|
### Cloudflare / NPM coverage limits
|
||||||
|
|
||||||
|
Cloudflare and NPM can remain in front of this collector. Apply WAF/cache exceptions only
|
||||||
|
to dedicated honeypot hostnames, and preserve trusted client attribution at each hop.
|
||||||
|
The public IP, certificate, TLS handshake and HTTP/2 or HTTP/3 behavior belong to the edge,
|
||||||
|
so IP-only or TLS-specific scanners may not identify the simulated NetScaler behind it.
|
||||||
|
Cloudflare can strip/rewrite headers and reject malformed or DDoS traffic before origin;
|
||||||
|
NPM changes transport framing too. Recorded wire bytes are what the collector consumed,
|
||||||
|
not an exact copy of the original internet connection. Correlate Cloudflare security-event
|
||||||
|
Ray IDs with CF-Ray in captured requests and inspect the dedicated NPM access/error logs
|
||||||
|
for rejected requests. Those edge logs do not recover rejected request bodies.
|
||||||
|
|
||||||
|
DTLS is inactive and not implemented by this HTTP listener. Ordinary Cloudflare HTTP
|
||||||
|
proxying does not carry UDP/DTLS; adding it later requires an appropriate Spectrum
|
||||||
|
entitlement and a separate isolated UDP capture/simulation path. No UDP listener or real
|
||||||
|
NetScaler firmware is enabled by this project. See [Cloudflare proxy limits](https://developers.cloudflare.com/dns/proxy-status/limitations/).
|
||||||
|
|
||||||
|
SAMLRequest/SAMLResponse form and query fields are additionally base64-decoded into inert
|
||||||
|
`saml_payload` artifacts. Redirect-binding DEFLATE output is capped at64KiB; original
|
||||||
|
request bytes and the decoded compressed envelope remain retained if inflation fails.
|
||||||
|
Form/query decoding is limited to64KiB and128 fields. XML is never parsed, entities are
|
||||||
|
never expanded, signatures are never accepted, and no SAML authentication is performed.
|
||||||
|
These are evidence aids, not proof of a SAML CVE attempt. Oversized accepted requests
|
||||||
|
remain in raw capture even when optional decoding is skipped.
|
||||||
|
|
||||||
Checks: `python3 test_nitro.py`, `python3 test_capture.py`, `python3 test_honeypot.py` and
|
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
|
`python3 test_safety.py`. Optional execution tools described below are incompatible with
|
||||||
`--simulation-only` and are not used by the supplied HTTP service.
|
`--simulation-only` and are not used by the supplied HTTP service.
|
||||||
|
|||||||
+45
-3
@@ -24,6 +24,7 @@ import sys
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
import zlib
|
||||||
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 nitro import respond as nitro_response
|
||||||
@@ -574,6 +575,34 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
self.server.engine.backend.upload(body, self.context)
|
self.server.engine.backend.upload(body, self.context)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def capture_saml(self, pairs, source):
|
||||||
|
# Decode protocol envelopes only. Never parse XML, expand entities or authenticate.
|
||||||
|
for field, value in pairs:
|
||||||
|
if field.lower() not in ('samlrequest', 'samlresponse'):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(value, altchars=b'-_', validate=True)
|
||||||
|
except (ValueError, binascii.Error):
|
||||||
|
self.event('saml_decode_error', field=field, source=source, raw_request_retained=True)
|
||||||
|
continue
|
||||||
|
self.event('saml_payload', field=field, source=source, encoding='base64', content=decoded)
|
||||||
|
# HTTP-Redirect binding may carry raw DEFLATE. Bound output before allocation.
|
||||||
|
if source == 'query' and not decoded.lstrip().startswith(b'<'):
|
||||||
|
try:
|
||||||
|
inflater = zlib.decompressobj(-zlib.MAX_WBITS)
|
||||||
|
inflated = inflater.decompress(decoded, 65537)
|
||||||
|
if len(inflated) > 65536 or inflater.unconsumed_tail:
|
||||||
|
self.event('limit', resource='saml_inflation', decoded_payload_retained=True)
|
||||||
|
elif inflater.eof and not inflater.unused_data:
|
||||||
|
self.event('saml_payload', field=field, source=source,
|
||||||
|
encoding='base64+deflate', content=inflated)
|
||||||
|
else:
|
||||||
|
self.event('saml_decode_error', field=field, source=source,
|
||||||
|
reason='incomplete deflate envelope', decoded_payload_retained=True)
|
||||||
|
except zlib.error:
|
||||||
|
self.event('saml_decode_error', field=field, source=source,
|
||||||
|
reason='invalid deflate envelope', decoded_payload_retained=True)
|
||||||
|
|
||||||
def handle_decoy(self):
|
def handle_decoy(self):
|
||||||
engine = self.server.engine
|
engine = self.server.engine
|
||||||
# Trusted Cloudflare/edge headers only when the connecting hop is a configured proxy.
|
# Trusted Cloudflare/edge headers only when the connecting hop is a configured proxy.
|
||||||
@@ -614,14 +643,27 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
indicators.append({'source': source, 'kind': 'encoded-content'})
|
indicators.append({'source': source, 'kind': 'encoded-content'})
|
||||||
if source.startswith('header:') and len(value) > 8192:
|
if source.startswith('header:') and len(value) > 8192:
|
||||||
indicators.append({'source': source, 'kind': 'large-header'})
|
indicators.append({'source': source, 'kind': 'large-header'})
|
||||||
if indicators:
|
|
||||||
self.event('request_indicators', indicators=indicators,
|
|
||||||
body_inspected_bytes=min(len(body), 65536), body_inspection_truncated=len(body) > 65536)
|
|
||||||
try:
|
try:
|
||||||
url = urllib.parse.urlsplit(self.path)
|
url = urllib.parse.urlsplit(self.path)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
self.send_error(400, 'invalid URL')
|
self.send_error(400, 'invalid URL')
|
||||||
return
|
return
|
||||||
|
self.capture_saml(fields, 'form')
|
||||||
|
if len(url.query) <= 65536:
|
||||||
|
try:
|
||||||
|
query_fields = urllib.parse.parse_qsl(url.query, max_num_fields=128)
|
||||||
|
self.capture_saml(query_fields, 'query')
|
||||||
|
if any(k.lower() in ('samlrequest', 'samlresponse') for k, _ in fields + query_fields):
|
||||||
|
indicators.append({'source': 'request', 'kind': 'saml-envelope'})
|
||||||
|
except ValueError:
|
||||||
|
self.event('limit', resource='query_decoding', raw_request_retained=True)
|
||||||
|
else:
|
||||||
|
self.event('limit', resource='query_decoding', raw_request_retained=True)
|
||||||
|
if url.path == '/logon/LogonPoint/Authentication/GetUserName':
|
||||||
|
indicators.append({'source': 'uri', 'kind': 'gateway-username-probe'})
|
||||||
|
if indicators:
|
||||||
|
self.event('request_indicators', indicators=indicators,
|
||||||
|
body_inspected_bytes=min(len(body), 65536), body_inspection_truncated=len(body) > 65536)
|
||||||
auth_fields = {key.lower() for key, _ in fields}
|
auth_fields = {key.lower() for key, _ in fields}
|
||||||
if self.command == 'POST' and (url.path.startswith('/nf/auth/') or
|
if self.command == 'POST' and (url.path.startswith('/nf/auth/') or
|
||||||
url.path in ('/vpn/login', '/vpn/login.html', '/cgi/login') or
|
url.path in ('/vpn/login', '/vpn/login.html', '/cgi/login') or
|
||||||
|
|||||||
+27
-1
@@ -1,9 +1,13 @@
|
|||||||
"""Run: python3 test_profile.py. Passive fingerprints and inert header/body intake."""
|
"""Run: python3 test_profile.py. Passive fingerprints and inert header/body intake."""
|
||||||
import gzip
|
import gzip
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
import http.client
|
import http.client
|
||||||
import struct
|
import struct
|
||||||
import threading
|
import threading
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
import urllib.parse
|
||||||
|
import zlib
|
||||||
|
|
||||||
from honeypot import ASSET_MTIME, Engine, Server
|
from honeypot import ASSET_MTIME, Engine, Server
|
||||||
from test_private_boundary import PATHS
|
from test_private_boundary import PATHS
|
||||||
@@ -47,10 +51,32 @@ try:
|
|||||||
assert {'source': 'header:X_Test', 'kind': 'netscaler-log-marker'} in indicator['indicators']
|
assert {'source': 'header:X_Test', 'kind': 'netscaler-log-marker'} in indicator['indicators']
|
||||||
assert {'source': 'body', 'kind': 'encoded-content'} in indicator['indicators']
|
assert {'source': 'body', 'kind': 'encoded-content'} in indicator['indicators']
|
||||||
assert any(e['event'] == 'request_body' and e['request_id'] == indicator['request_id'] for e in events)
|
assert any(e['event'] == 'request_body' and e['request_id'] == indicator['request_id'] for e in events)
|
||||||
|
xml = b'<Response xmlns="urn:oasis:names:tc:SAML:2.0:protocol">inert-marker</Response>'
|
||||||
|
form = urllib.parse.urlencode({'SAMLResponse': base64.b64encode(xml).decode()}).encode()
|
||||||
|
assert request('/cgi/samlauth', 'POST', form, {'Content-Type': 'application/x-www-form-urlencoded'})[0] == 404
|
||||||
|
saml = next(e for e in reversed(events) if e['event'] == 'saml_payload')
|
||||||
|
assert saml['source'] == 'form' and saml['evidence']['sha256'] == hashlib.sha256(xml).hexdigest()
|
||||||
|
raw_body = next(e for e in events if e['event'] == 'request_body' and e['request_id'] == saml['request_id'])
|
||||||
|
assert raw_body['evidence']['sha256'] == hashlib.sha256(form).hexdigest()
|
||||||
|
def redirect(value):
|
||||||
|
compressor = zlib.compressobj(wbits=-zlib.MAX_WBITS)
|
||||||
|
compressed = compressor.compress(value) + compressor.flush()
|
||||||
|
return '/cgi/samlauth?' + urllib.parse.urlencode({'SAMLRequest': base64.b64encode(compressed).decode()})
|
||||||
|
assert request(redirect(xml))[0] == 404
|
||||||
|
assert any(e['event'] == 'saml_payload' and e['encoding'] == 'base64+deflate'
|
||||||
|
and e['evidence']['sha256'] == hashlib.sha256(xml).hexdigest() for e in events)
|
||||||
|
before = len(events)
|
||||||
|
assert request(redirect(b'A' * 65537))[0] == 404
|
||||||
|
assert any(e['event'] == 'limit' and e.get('resource') == 'saml_inflation' for e in events[before:])
|
||||||
|
assert not any(e['event'] == 'saml_payload' and e['encoding'] == 'base64+deflate' for e in events[before:])
|
||||||
|
assert request('/cgi/samlauth?SAMLResponse=not-base64')[0] == 404
|
||||||
|
assert any(e['event'] == 'saml_decode_error' for e in events)
|
||||||
|
assert request('/logon/LogonPoint/Authentication/GetUserName')[0] == 404
|
||||||
|
assert any({'source': 'uri', 'kind': 'gateway-username-probe'} in e.get('indicators', []) for e in events)
|
||||||
for path in PATHS + ('/.env', '/nsconfig/ns.conf', '/api/events'):
|
for path in PATHS + ('/.env', '/nsconfig/ns.conf', '/api/events'):
|
||||||
status, _, body = request(path)
|
status, _, body = request(path)
|
||||||
assert status == 404 and body == b'Not Found', path
|
assert status == 404 and body == b'Not Found', path
|
||||||
print('PASS: Gateway fingerprint, gzip firmware metadata, auth XML, correlated indicators, private boundary')
|
print('PASS: Gateway fingerprint, gzip metadata, auth XML, SAML bytes/inflation bounds, indicators, private boundary')
|
||||||
finally:
|
finally:
|
||||||
server.shutdown()
|
server.shutdown()
|
||||||
server.server_close()
|
server.server_close()
|
||||||
|
|||||||
Reference in new issue
Block a user