Repository navigation
Expand file tree
/
Copy pathscan_debug_header.py
More file actions
342 lines (295 loc) · 14.9 KB
/
Copy pathscan_debug_header.py
File metadata and controls
342 lines (295 loc) · 14.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
#!/usr/bin/env python3
"""
http debug check - improved version with FP reduction
"""
from utils.style import Colors
from utils.utils import configure_logger, requests, random, range_exclusion, traceback, sys, human_time, random_ua
from modules.lists.debug_list import DEBUG_HEADERS
# Response headers that indicate debug mode is actually active
DEBUG_RESPONSE_INDICATORS = [
# Exact header names (case-insensitive match)
"x-debug", "x-debug-info", "x-debug-token", "x-debug-token-link",
"x-symfony-debug", "x-laravel-debug", "x-django-debug",
"x-powered-by", "x-aspnet-version", "x-aspnetmvc-version",
"x-runtime", "x-request-id", "x-trace-id",
"server-timing", "x-cache-debug", "x-varnish-debug",
"x-debug-mode", "x-debug-log", "x-debug-trace",
"x-php-version", "x-generator", "x-environment",
"x-debug-enabled", "x-profiler", "x-clockwork-id",
"x-clockwork-version", "x-blackfire-response",
"x-xdebug-profile-filename", "x-debug-output",
"x-app-env", "x-app-debug", "x-dev-mode",
"x-graphql-event-stream", "x-hasura-role",
"x-kong-proxy-latency", "x-kong-upstream-latency",
"x-envoy-upstream-service-time", "x-envoy-decorator-operation",
"x-amzn-trace-id", "x-cloud-trace-context",
"x-b3-traceid", "x-b3-spanid",
"x-datadog-trace-id", "x-datadog-parent-id",
"x-newrelic-app-data", "x-newrelic-transaction",
"x-instana-t", "x-instana-s",
"x-dynatrace", "x-elastic-product",
"x-litespeed-cache", "x-turbo-charged-by",
]
# Substrings in response header VALUES that indicate debug info leakage
DEBUG_VALUE_INDICATORS = [
"stacktrace", "stack_trace", "traceback", "exception",
"debug=true", "debug=1", "development", "staging",
"phpinfo", "xdebug", "profiler",
"internal server", "error in", "syntax error",
"mysql", "postgresql", "sqlite", "mongodb",
"django.core", "laravel", "symfony",
"node_modules", "vendor/", "gems/",
]
# Headers in the response that are NEW and likely debug-related (partial match)
DEBUG_HEADER_PREFIXES = [
"x-debug", "x-trace", "x-profil", "x-stack",
"x-error", "x-exception", "x-query", "x-sql",
"x-cache-debug", "x-varnish", "x-envoy",
"x-akamai-", "x-fastly-", "x-cloudflare-",
"x-kong-", "x-amzn-", "x-cloud-trace",
"x-datadog-", "x-newrelic-", "x-instana-",
"x-dynatrace", "x-elastic-", "x-zipkin-",
"x-honeycomb-", "x-lightstep-", "x-splunk-",
"x-sentry-", "x-raygun-", "x-blackfire-",
"x-clockwork", "x-xdebug", "x-runtime",
"x-app-env", "x-app-debug", "x-dev-",
"x-dispatcher", "server-timing",
]
def _collect_baselines(url, s, human, num_baselines=3):
"""Collect multiple baseline responses to understand normal server variance."""
baselines = []
for _ in range(num_baselines):
try:
uri = f"{url}?cb={random.randrange(99999)}"
s.headers.update(random_ua())
human_time(human)
r = s.get(uri, allow_redirects=False, verify=False)
baselines.append({
'status': r.status_code,
'size': len(r.content),
'headers': set(h.lower() for h in r.headers.keys()),
'header_count': len(r.headers),
})
except Exception:
continue
return baselines
def _compute_tolerance(baselines, main_len):
"""Compute body size tolerance based on observed variance."""
if not baselines:
return range_exclusion(main_len)
sizes = [b['size'] for b in baselines] + [main_len]
min_size = min(sizes)
max_size = max(sizes)
variance = max_size - min_size
# Tolerance = observed variance + 10% of main_len + fixed margin
margin = max(variance * 2, int(main_len * 0.10), 200)
low = max(0, min_size - margin)
high = max_size + margin
return range(low, high + 1)
def _get_baseline_headers(baselines, main_head_keys):
"""Get union of all header names seen across baselines."""
all_headers = set(h.lower() for h in main_head_keys)
for b in baselines:
all_headers.update(b['headers'])
return all_headers
def _find_new_debug_headers(response_headers, baseline_headers):
"""Find response headers that are new AND look debug-related."""
new_headers = []
for h in response_headers:
h_lower = h.lower()
if h_lower not in baseline_headers:
for prefix in DEBUG_HEADER_PREFIXES:
if h_lower.startswith(prefix) or h_lower in DEBUG_RESPONSE_INDICATORS:
new_headers.append(h)
break
return new_headers
# Headers that commonly contain long/noisy values - skip for debug value analysis
SKIP_VALUE_CHECK_HEADERS = {
"content-security-policy", "content-security-policy-report-only",
"link", "set-cookie", "strict-transport-security",
"permissions-policy", "feature-policy", "referrer-policy",
"access-control-allow-origin", "access-control-allow-headers",
"access-control-allow-methods", "access-control-expose-headers",
"cache-control", "vary", "etag", "date", "expires",
"content-type", "content-length", "content-encoding",
"transfer-encoding", "connection", "keep-alive",
"accept-ranges", "age", "location", "retry-after",
"www-authenticate", "authorization",
"x-frame-options", "x-content-type-options", "x-xss-protection",
"nel", "report-to", "cross-origin-opener-policy",
"cross-origin-embedder-policy", "cross-origin-resource-policy",
"timing-allow-origin",
}
def _check_debug_values(response_headers):
"""Check if any response header values contain debug indicators."""
findings = []
for h, v in response_headers.items():
h_lower = h.lower()
if h_lower in SKIP_VALUE_CHECK_HEADERS:
continue
v_lower = str(v).lower()
for indicator in DEBUG_VALUE_INDICATORS:
if indicator in v_lower:
findings.append(f"{h}: {v[:100]}")
break
return findings
def _confirm_finding(url, s, dh, human):
"""Retry a request to confirm it's not a transient behavior."""
try:
uri = f"{url}?cb={random.randrange(99999)}"
s.headers.update(random_ua())
human_time(human)
r = s.get(uri, headers=dh, allow_redirects=False, verify=False)
return r
except Exception:
return None
def check_http_debug(url, s, main_status_code, main_len, main_head, authent, human):
print(f"{Colors.CYAN} ├ Debug Headers analysis{Colors.RESET}")
# Step 1: Collect multiple baselines to understand normal variance
baselines = _collect_baselines(url, s, human, num_baselines=3)
baseline_statuses = set(b['status'] for b in baselines)
baseline_statuses.add(main_status_code)
# Step 2: Compute adaptive tolerance
size_tolerance = _compute_tolerance(baselines, main_len)
main_head_keys = set(h.lower() for h in main_head) if isinstance(main_head, dict) else set()
baseline_headers = _get_baseline_headers(baselines, main_head_keys if main_head_keys else [])
# Baseline header count range
header_counts = [b['header_count'] for b in baselines]
if isinstance(main_head, dict):
header_counts.append(len(main_head))
elif isinstance(main_head, int):
header_counts.append(main_head)
min_hcount = min(header_counts) if header_counts else 0
max_hcount = max(header_counts) if header_counts else 0
behavior_groups = {}
ignore_statuses = {403, 401, 429, 503, 502}
for dh in DEBUG_HEADERS:
try:
uri = f"{url}?cb={random.randrange(99999)}"
s.headers.update(random_ua())
human_time(human)
req_dh = s.get(uri, headers=dh, allow_redirects=False, verify=False)
resp_status = req_dh.status_code
resp_size = len(req_dh.content)
resp_hcount = len(req_dh.headers)
if resp_status in ignore_statuses:
continue
findings = []
confidence = 0 # 0=noise, 1=low, 2=medium, 3=high
# Check 1: New debug-related response headers (HIGH confidence)
new_debug_h = _find_new_debug_headers(req_dh.headers, baseline_headers)
if new_debug_h:
findings.append(f"NEW DEBUG HEADERS: {', '.join(new_debug_h)}")
confidence = max(confidence, 3)
# Check 2: Debug values in response headers (HIGH confidence)
debug_vals = _check_debug_values(req_dh.headers)
if debug_vals:
findings.append(f"DEBUG VALUES: {'; '.join(debug_vals[:3])}")
confidence = max(confidence, 3)
# Check 3: Status code change (MEDIUM confidence if not in baseline variance)
if resp_status != main_status_code and resp_status not in baseline_statuses:
findings.append(f"STATUS: {main_status_code} → {resp_status}")
confidence = max(confidence, 2)
# Check 4: Body size change (LOW confidence alone, needs confirmation)
if resp_size not in size_tolerance:
# Only flag if size difference is significant (>15% or >500 bytes)
size_diff = abs(resp_size - main_len)
pct_diff = (size_diff / max(main_len, 1)) * 100
if pct_diff > 15 or size_diff > 500:
findings.append(f"BODY: {main_len}b → {resp_size}b ({pct_diff:.0f}%)")
confidence = max(confidence, 1)
# Check 5: Header count (only if way outside baseline range + debug header found)
if resp_hcount > max_hcount + 3 or resp_hcount < min_hcount - 3:
# Only interesting if combined with another signal
if confidence > 0:
findings.append(f"HEADER COUNT: {min_hcount}-{max_hcount} → {resp_hcount}")
confidence = max(confidence, 2)
# Skip low confidence findings that are body-only changes
if confidence < 2 and len(findings) == 1 and "BODY:" in findings[0]:
continue
# Always add body + header count as context if different from baseline
context_parts = []
if resp_size not in size_tolerance:
size_diff = abs(resp_size - main_len)
pct_diff = (size_diff / max(main_len, 1)) * 100
body_info = f"BODY: {main_len}b → {resp_size}b ({pct_diff:.0f}%)"
if body_info not in [f for f in findings if f.startswith("BODY:")]:
context_parts.append(body_info)
if resp_hcount > max_hcount + 1 or resp_hcount < min_hcount - 1:
hcount_info = f"HEADER COUNT: {min_hcount}-{max_hcount} → {resp_hcount}"
if hcount_info not in [f for f in findings if f.startswith("HEADER COUNT:")]:
context_parts.append(hcount_info)
all_findings = findings + context_parts
if all_findings and confidence >= 2:
behavior_msg = " | ".join(all_findings)
# Group by first finding type for dedup
first_finding_type = findings[0].split(":")[0].strip()
behavior_key = f"{first_finding_type}_{resp_status}_{resp_size // 1000}"
if behavior_key not in behavior_groups:
behavior_groups[behavior_key] = {
'msg': behavior_msg,
'url': uri,
'count': 0,
'payloads': [],
'confidence': confidence,
'dh': dh,
}
behavior_groups[behavior_key]['count'] += 1
behavior_groups[behavior_key]['payloads'].append(str(dh))
behavior_groups[behavior_key]['confidence'] = max(
behavior_groups[behavior_key]['confidence'], confidence
)
except Exception as e:
if "got more than 100 headers" in str(e):
print(f"\033[33m └── [WARNING]\033[0m | Server returned >100 headers | \033[34m{uri}\033[0m | PAYLOAD: {dh}")
elif "Connection aborted" in str(e):
print(f"\033[33m └── [WARNING]\033[0m | Connection aborted | \033[34m{uri}\033[0m | PAYLOAD: {dh}")
continue
if len(list(dh.values())[0]) < 50 and len(list(dh.keys())[0]) < 50:
sys.stdout.write(f"{Colors.BLUE}{dh} :: {req_dh.status_code}{Colors.RESET}\r")
sys.stdout.write("\033[K")
# Step 3: Confirm and display results
FP_THRESHOLD = 10 # More than 10 payloads triggering same behavior = FP
for key, data in behavior_groups.items():
confirmed = False
# Too many payloads = FP regardless of confidence
if data['count'] > FP_THRESHOLD:
confirmed = False
# HIGH confidence (debug headers/values found) = always confirmed
elif data['confidence'] == 3:
confirmed = True
# MEDIUM confidence with few payloads: confirm with retry
elif data['count'] <= 5 and data['confidence'] >= 2:
retry = _confirm_finding(url, s, data['dh'], human)
if retry:
retry_size = len(retry.content)
retry_status = retry.status_code
if retry_status not in baseline_statuses and retry_status not in ignore_statuses:
confirmed = True
elif retry_size not in size_tolerance:
size_diff = abs(retry_size - main_len)
pct_diff = (size_diff / max(main_len, 1)) * 100
if pct_diff > 15 or size_diff > 500:
confirmed = True
# MEDIUM confidence with many payloads: likely FP
elif data['count'] > 5 and data['confidence'] < 3:
confirmed = False
if confirmed:
conf_label = "HIGH" if data['confidence'] == 3 else "MEDIUM"
print(
f"\033[32m └── [DEBUG CONFIRMED ({conf_label})]\033[0m | "
f"{data['msg']} | \033[34m{data['url']}\033[0m | "
f"PAYLOAD: {data['payloads'][0]}"
)
if data['count'] > 1:
print(f"\033[90m (+{data['count']-1} similar payloads)\033[0m")
elif data['count'] > FP_THRESHOLD:
print(
f"\033[33m └── [LIKELY FP - {data['count']} payloads trigger same behavior]\033[0m | "
f"{data['msg']} | \033[34m{data['url']}\033[0m"
)
elif data['count'] > 5 and data['confidence'] < 3:
print(
f"\033[33m └── [LIKELY FP - {data['count']} payloads trigger same behavior]\033[0m | "
f"{data['msg']} | \033[34m{data['url']}\033[0m"
)