agent-bypass 2.16.0 → 2.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT_ADDITIVE_CHANGELOG.md +21 -1
- package/README.md +7 -2
- package/SKILL.md +43 -118
- package/bin/install.js +28 -1
- package/dsh/persona.md +2 -2
- package/package.json +1 -1
- package/references/agent-routing.md +1 -1
- package/references/bruteforce-playbook.md +87 -0
- package/references/fake-ip-tun.md +114 -0
- package/references/tool-selection.md +9 -1
- package/scripts/doh_resolve.sh +110 -5
- package/scripts/fastrecon.py +55 -6
- package/scripts/netdns.py +192 -0
- package/scripts/origin_trace.py +535 -0
- package/scripts/portscan.py +119 -13
- package/tests/test_regressions.py +144 -0
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""origin_trace — 源站溯源(绕 CDN/WAF):DoH 真 IP + 证书透明度 + 漏质子域 + 直连指纹比对
|
|
4
|
+
|
|
5
|
+
为什么需要:开了 TUN / fake-IP(iSH 常见 198.18.0.0/15)时,系统 DNS 拿不到真实 IP,
|
|
6
|
+
历史解析、子域、CT 查询都会被污染;本工具统一走 DoH 拿真 IP,再逐个直连比对指纹。
|
|
7
|
+
|
|
8
|
+
只读:仅做 DNS / HTTPS GET / CT 查询,不修改目标任何状态。授权范围内使用。
|
|
9
|
+
|
|
10
|
+
用法:
|
|
11
|
+
python3 origin_trace.py example.com
|
|
12
|
+
python3 origin_trace.py example.com --json
|
|
13
|
+
python3 origin_trace.py example.com --max-candidates 60 --timeout 8
|
|
14
|
+
python3 origin_trace.py example.com --no-ct --max-candidates 8 --budget 60 # iSH/fake-IP:跳过 CT、限时防卡
|
|
15
|
+
python3 origin_trace.py example.com --ct-sources crt.sh,certspotter,otx # 指定/兜底 CT 源(all=全部)
|
|
16
|
+
python3 origin_trace.py example.com --max-ct 120 --ct-budget 20 # CT 主机上限/多源总预算
|
|
17
|
+
DOH_PROVIDERS="https://1.1.1.1/dns-query" python3 origin_trace.py example.com
|
|
18
|
+
|
|
19
|
+
输出: 候选源站表(ip / 分数 / 来源 / 标题 / Server / CDN 头),--json 输出机器可读结构。
|
|
20
|
+
退出码: 0 正常(有候选)/ 1 无候选或全部失败 / 2 用法错误。
|
|
21
|
+
"""
|
|
22
|
+
import concurrent.futures
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import socket
|
|
28
|
+
import ssl
|
|
29
|
+
import sys
|
|
30
|
+
import time
|
|
31
|
+
import urllib.parse
|
|
32
|
+
import urllib.request
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
import netdns
|
|
36
|
+
except Exception: # 单文件拷贝场景
|
|
37
|
+
netdns = None
|
|
38
|
+
|
|
39
|
+
UA = "Mozilla/5.0 (compatible; origin-trace/1.0; +agent-bypass)"
|
|
40
|
+
SUBS = [
|
|
41
|
+
"direct", "origin", "origin-www", "mail", "mx", "smtp", "pop", "imap",
|
|
42
|
+
"webmail", "autodiscover", "autoconfig", "cpanel", "whm", "ftp",
|
|
43
|
+
"dev", "test", "staging", "stage", "pre", "prod", "beta", "old",
|
|
44
|
+
"legacy", "backup", "admin", "api", "gateway", "vpn", "oa", "portal",
|
|
45
|
+
"m", "wap", "static", "img", "cdn", "assets", "uploads",
|
|
46
|
+
]
|
|
47
|
+
ISH = os.path.isdir("/var/minis") or bool(os.environ.get("MINIS_ROOT"))
|
|
48
|
+
|
|
49
|
+
# 多源证书透明度 / 被动 DNS(无 Key 优先;单个源 502/超时不影响其他源)
|
|
50
|
+
CT_SOURCES = ("crt.sh", "certspotter", "otx", "hackertarget", "anubis", "rapiddns")
|
|
51
|
+
|
|
52
|
+
CDN_HEADERS = (
|
|
53
|
+
"cf-ray", "cf-cache-status", "x-cache", "via", "x-amz-cf-id", "x-amz-cf-pop",
|
|
54
|
+
"x-fastly-request-id", "x-akamai-transformed", "x-cdn", "x-served-by",
|
|
55
|
+
"x-cache-hits", "x-timer", "server-timing",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _err(msg):
|
|
60
|
+
print(msg, file=sys.stderr)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_ipv4(s):
|
|
64
|
+
if netdns is not None:
|
|
65
|
+
return netdns.is_ipv4(s)
|
|
66
|
+
return bool(re.match(r"^\d{1,3}(?:\.\d{1,3}){3}$", str(s)))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def doh_resolve(name, timeout=6.0):
|
|
70
|
+
"""DoH 真 IP;无 netdns 时退回系统解析。返回 [(ip, 'doh'|'system')]"""
|
|
71
|
+
if netdns is not None:
|
|
72
|
+
ips = netdns.doh_query(name, "A", timeout)
|
|
73
|
+
if ips:
|
|
74
|
+
return [(ip, "doh") for ip in ips]
|
|
75
|
+
ips = netdns.system_resolve(name, timeout)
|
|
76
|
+
return [(ip, "system") for ip in ips if not netdns.is_fake_ip(ip)]
|
|
77
|
+
try:
|
|
78
|
+
return [(socket.gethostbyname(name), "system")]
|
|
79
|
+
except OSError:
|
|
80
|
+
return []
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _host_in_domain(name, domain):
|
|
84
|
+
"""规范化主机名并校验属于目标域;不合法返回 None。"""
|
|
85
|
+
name = str(name or "").strip().lower().lstrip("*.").rstrip(".")
|
|
86
|
+
if name and (name == domain or name.endswith("." + domain)):
|
|
87
|
+
return name
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _dedup_hosts(cands, domain, seen, out):
|
|
92
|
+
for raw in cands:
|
|
93
|
+
n = _host_in_domain(raw, domain)
|
|
94
|
+
if n and n not in seen:
|
|
95
|
+
seen.add(n)
|
|
96
|
+
out.append(n)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def http_get_text(url, timeout=8.0, retry=1):
|
|
100
|
+
"""GET 文本(带 UA + 重试);失败抛异常由调用方兜底。"""
|
|
101
|
+
last = None
|
|
102
|
+
for attempt in range(retry + 1):
|
|
103
|
+
try:
|
|
104
|
+
req = urllib.request.Request(url, headers={"user-agent": UA})
|
|
105
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
106
|
+
return r.read(8 << 20).decode("utf-8", "ignore")
|
|
107
|
+
except Exception as e:
|
|
108
|
+
last = e
|
|
109
|
+
if attempt < retry:
|
|
110
|
+
time.sleep(0.8)
|
|
111
|
+
raise last
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def parse_crtsh(text, domain):
|
|
115
|
+
"""解析 crt.sh JSON → 该域名下的主机名(去通配符、去重、保序)。"""
|
|
116
|
+
try:
|
|
117
|
+
rows = json.loads(text)
|
|
118
|
+
except (ValueError, TypeError):
|
|
119
|
+
return []
|
|
120
|
+
if not isinstance(rows, list):
|
|
121
|
+
return []
|
|
122
|
+
out, seen = [], set()
|
|
123
|
+
for row in rows:
|
|
124
|
+
if isinstance(row, dict):
|
|
125
|
+
_dedup_hosts(str(row.get("name_value") or "").split("\n"), domain, seen, out)
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def parse_certspotter(text, domain):
|
|
130
|
+
"""CertSpotter issuances JSON → dns_names 里的主机名。"""
|
|
131
|
+
try:
|
|
132
|
+
rows = json.loads(text)
|
|
133
|
+
except (ValueError, TypeError):
|
|
134
|
+
return []
|
|
135
|
+
out, seen = [], set()
|
|
136
|
+
for row in rows if isinstance(rows, list) else []:
|
|
137
|
+
if isinstance(row, dict):
|
|
138
|
+
_dedup_hosts(row.get("dns_names") or [], domain, seen, out)
|
|
139
|
+
return out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def parse_otx(text, domain):
|
|
143
|
+
"""OTX 被动 DNS → (主机名列表, IP 列表)。"""
|
|
144
|
+
try:
|
|
145
|
+
d = json.loads(text)
|
|
146
|
+
except (ValueError, TypeError):
|
|
147
|
+
return [], []
|
|
148
|
+
rows = d.get("passive_dns") if isinstance(d, dict) else None
|
|
149
|
+
out, seen, ips = [], set(), []
|
|
150
|
+
for row in rows if isinstance(rows, list) else []:
|
|
151
|
+
if not isinstance(row, dict):
|
|
152
|
+
continue
|
|
153
|
+
host = _host_in_domain(row.get("hostname") or "", domain)
|
|
154
|
+
if not host:
|
|
155
|
+
continue # 只收目标域相关记录,避免无关 IP 污染候选
|
|
156
|
+
_dedup_hosts([host], domain, seen, out)
|
|
157
|
+
ip = str(row.get("address") or "").strip()
|
|
158
|
+
if netdns is not None and netdns.is_ipv4(ip) and not netdns.is_fake_ip(ip) and ip not in ips:
|
|
159
|
+
ips.append(ip)
|
|
160
|
+
return out, ips
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def parse_hackertarget(text, domain):
|
|
164
|
+
"""HackerTarget hostsearch CSV(host,ip)→ (主机名, IP)。"""
|
|
165
|
+
out, seen, ips = [], set(), []
|
|
166
|
+
for line in str(text or "").splitlines():
|
|
167
|
+
parts = [x.strip() for x in line.split(",")]
|
|
168
|
+
if len(parts) != 2:
|
|
169
|
+
continue
|
|
170
|
+
host = _host_in_domain(parts[0], domain)
|
|
171
|
+
if not host:
|
|
172
|
+
continue # 只收目标域相关主机/IP
|
|
173
|
+
_dedup_hosts([host], domain, seen, out)
|
|
174
|
+
if netdns is not None and netdns.is_ipv4(parts[1]) and not netdns.is_fake_ip(parts[1]) and parts[1] not in ips:
|
|
175
|
+
ips.append(parts[1])
|
|
176
|
+
return out, ips
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def parse_anubis(text, domain):
|
|
180
|
+
"""Anubis(jldc.me) 子域 JSON 数组 → 主机名。"""
|
|
181
|
+
try:
|
|
182
|
+
rows = json.loads(text)
|
|
183
|
+
except (ValueError, TypeError):
|
|
184
|
+
return []
|
|
185
|
+
out, seen = [], set()
|
|
186
|
+
_dedup_hosts(rows if isinstance(rows, list) else [], domain, seen, out)
|
|
187
|
+
return out
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def parse_rapiddns(text, domain):
|
|
191
|
+
"""RapidDNS HTML → 页面中的子域文本(宽松匹配)。"""
|
|
192
|
+
out, seen = [], set()
|
|
193
|
+
for m in re.finditer(r"([a-zA-Z0-9][a-zA-Z0-9._-]*\." + re.escape(domain) + r")", str(text or "")):
|
|
194
|
+
_dedup_hosts([m.group(1)], domain, seen, out)
|
|
195
|
+
return out
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _ct_fetch(name, domain, timeout):
|
|
199
|
+
"""单个 CT/被动 DNS 源 → (hosts, ips)。"""
|
|
200
|
+
q = urllib.parse.quote(domain, safe="")
|
|
201
|
+
if name == "crt.sh":
|
|
202
|
+
text = http_get_text("https://crt.sh/?q=" + urllib.parse.quote("%." + domain, safe="") + "&output=json",
|
|
203
|
+
timeout, retry=1)
|
|
204
|
+
return parse_crtsh(text, domain), []
|
|
205
|
+
if name == "certspotter":
|
|
206
|
+
text = http_get_text("https://api.certspotter.com/v1/issuances?domain=%s&include_subdomains=true&expand=dns_names" % q,
|
|
207
|
+
timeout)
|
|
208
|
+
return parse_certspotter(text, domain), []
|
|
209
|
+
if name == "otx":
|
|
210
|
+
text = http_get_text("https://otx.alienvault.com/api/v1/indicators/domain/%s/passive_dns" % q, timeout)
|
|
211
|
+
return parse_otx(text, domain)
|
|
212
|
+
if name == "hackertarget":
|
|
213
|
+
text = http_get_text("https://api.hackertarget.com/hostsearch/?q=%s" % q, timeout)
|
|
214
|
+
return parse_hackertarget(text, domain)
|
|
215
|
+
if name == "anubis":
|
|
216
|
+
text = http_get_text("https://jldc.me/anubis/subdomains/%s" % q, timeout)
|
|
217
|
+
return parse_anubis(text, domain), []
|
|
218
|
+
if name == "rapiddns":
|
|
219
|
+
text = http_get_text("https://rapiddns.io/subdomain/%s?full=1" % q, timeout)
|
|
220
|
+
return parse_rapiddns(text, domain), []
|
|
221
|
+
return [], []
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def fetch_passive_names(domain, timeout=8.0, sources=None, max_hosts=80, budget=None):
|
|
225
|
+
"""多源并发查询 CT/被动 DNS:单源失败不影响其他;总预算到期返回部分结果。
|
|
226
|
+
返回 (hosts, passive_ips, per_source_status)。"""
|
|
227
|
+
sources = list(sources or CT_SOURCES)
|
|
228
|
+
budget = budget if budget and budget > 0 else (10.0 if ISH else 15.0)
|
|
229
|
+
workers = min(4 if ISH else 6, max(1, len(sources)))
|
|
230
|
+
hosts, seen, ips, status = [], set(), [], {}
|
|
231
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
|
|
232
|
+
futs = {ex.submit(_ct_fetch, name, domain, timeout): name for name in sources}
|
|
233
|
+
try:
|
|
234
|
+
for fut in concurrent.futures.as_completed(futs, timeout=budget):
|
|
235
|
+
src = futs[fut]
|
|
236
|
+
try:
|
|
237
|
+
h, ip = fut.result()
|
|
238
|
+
except Exception as e:
|
|
239
|
+
status[src] = "失败: %s" % str(e)[:60]
|
|
240
|
+
_err("[CT] %-12s 失败,跳过: %s" % (src, str(e)[:80]))
|
|
241
|
+
continue
|
|
242
|
+
status[src] = len(h) + (len(ip) and len(ip) or 0) * 0 # 仅主机数用于展示
|
|
243
|
+
for n in h:
|
|
244
|
+
if n not in seen and len(hosts) < max_hosts:
|
|
245
|
+
seen.add(n)
|
|
246
|
+
hosts.append(n)
|
|
247
|
+
for ipv in ip:
|
|
248
|
+
if ipv not in ips:
|
|
249
|
+
ips.append(ipv)
|
|
250
|
+
_err("[CT] %-12s %d 主机%s" % (src, len(h), (" / %d IP" % len(ip)) if ip else ""))
|
|
251
|
+
except concurrent.futures.TimeoutError:
|
|
252
|
+
status["_budget"] = "总预算 %.0fs 超时(返回部分)" % budget
|
|
253
|
+
_err("[CT] 总预算 %.0fs 到期,使用已返回的 %d 个主机" % (budget, len(hosts)))
|
|
254
|
+
finally:
|
|
255
|
+
ex.shutdown(wait=False, cancel_futures=True)
|
|
256
|
+
return hosts, ips, status
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def cdn_markers(headers):
|
|
260
|
+
return [h for h in CDN_HEADERS if h in headers]
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def extract_title(body):
|
|
264
|
+
m = re.search(r"<title[^>]*>(.*?)</title>", body, re.I | re.S)
|
|
265
|
+
if not m:
|
|
266
|
+
return ""
|
|
267
|
+
t = re.sub(r"\s+", " ", m.group(1)).strip()
|
|
268
|
+
return t[:120]
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def parse_response(data):
|
|
272
|
+
"""原始 HTTP 响应 → dict(status/title/server/headers/cdn/location)。"""
|
|
273
|
+
head, _, body = data.partition(b"\r\n\r\n")
|
|
274
|
+
lines = head.decode("iso-8859-1", "ignore").split("\r\n")
|
|
275
|
+
status = lines[0].strip() if lines else ""
|
|
276
|
+
headers = {}
|
|
277
|
+
for line in lines[1:]:
|
|
278
|
+
if ":" in line:
|
|
279
|
+
k, v = line.split(":", 1)
|
|
280
|
+
headers[k.strip().lower()] = v.strip()
|
|
281
|
+
return {
|
|
282
|
+
"status": status,
|
|
283
|
+
"title": extract_title(body.decode("utf-8", "ignore")),
|
|
284
|
+
"server": headers.get("server", ""),
|
|
285
|
+
"location": headers.get("location", ""),
|
|
286
|
+
"headers": headers,
|
|
287
|
+
"cdn": cdn_markers(headers),
|
|
288
|
+
"body_sha1": hashlib.sha1(body).hexdigest()[:12] if body else "",
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def http_probe(ip, domain, timeout=8.0, method="GET"):
|
|
293
|
+
"""直连 IP + 正确 SNI/Host 的 HTTPS 探测(不校验证书,仅采集指纹)。"""
|
|
294
|
+
ctx = ssl.create_default_context()
|
|
295
|
+
ctx.check_hostname = False
|
|
296
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
297
|
+
raw = socket.create_connection((ip, 443), timeout=timeout)
|
|
298
|
+
try:
|
|
299
|
+
sock = ctx.wrap_socket(raw, server_hostname=domain) # SSLContext.wrap_socket 不接受 timeout 参数
|
|
300
|
+
sock.settimeout(timeout)
|
|
301
|
+
try:
|
|
302
|
+
cert = sock.getpeercert() or {}
|
|
303
|
+
sans = [v for k, v in cert.get("subjectAltName", ()) if k == "DNS"]
|
|
304
|
+
req = ("%s / HTTP/1.1\r\nHost: %s\r\nUser-Agent: %s\r\n"
|
|
305
|
+
"Accept: */*\r\nConnection: close\r\n\r\n") % (method, domain, UA)
|
|
306
|
+
sock.sendall(req.encode("utf-8", "ignore"))
|
|
307
|
+
data = b""
|
|
308
|
+
end = time.monotonic() + max(timeout, 2.0) # 读取总预算,防慢连接挂住
|
|
309
|
+
while len(data) < 131072 and time.monotonic() < end:
|
|
310
|
+
try:
|
|
311
|
+
chunk = sock.recv(16384)
|
|
312
|
+
except (socket.timeout, OSError):
|
|
313
|
+
break
|
|
314
|
+
if not chunk:
|
|
315
|
+
break
|
|
316
|
+
data += chunk
|
|
317
|
+
finally:
|
|
318
|
+
sock.close()
|
|
319
|
+
finally:
|
|
320
|
+
try:
|
|
321
|
+
raw.close()
|
|
322
|
+
except Exception:
|
|
323
|
+
pass
|
|
324
|
+
info = parse_response(data)
|
|
325
|
+
info["cert_sans"] = sans
|
|
326
|
+
info["cert_domain_ok"] = any(
|
|
327
|
+
domain == x or domain.endswith("." + x.lstrip("*.")) for x in sans
|
|
328
|
+
)
|
|
329
|
+
return info
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def score_candidate(base, cand):
|
|
333
|
+
"""候选 vs 基线指纹打分:同标题/同 Server/无 CDN 头/证书匹配加分。"""
|
|
334
|
+
score, reasons = 0, []
|
|
335
|
+
if base.get("title") and cand.get("title") and cand["title"] == base["title"]:
|
|
336
|
+
score += 3
|
|
337
|
+
reasons.append("title")
|
|
338
|
+
if base.get("status") and cand.get("status") == base.get("status"):
|
|
339
|
+
score += 1
|
|
340
|
+
if base.get("server") and cand.get("server") == base.get("server"):
|
|
341
|
+
score += 1
|
|
342
|
+
reasons.append("server")
|
|
343
|
+
if base.get("cdn") and not cand.get("cdn"):
|
|
344
|
+
score += 2
|
|
345
|
+
reasons.append("no-cdn-headers")
|
|
346
|
+
if cand.get("cert_domain_ok"):
|
|
347
|
+
score += 1
|
|
348
|
+
reasons.append("cert-san")
|
|
349
|
+
if cand.get("cdn"):
|
|
350
|
+
if base.get("cdn") and set(cand["cdn"]) & set(base["cdn"]):
|
|
351
|
+
score -= 2 # 与基线同一 CDN = 还是 CDN 边缘,不是源站
|
|
352
|
+
reasons.append("same-cdn")
|
|
353
|
+
else:
|
|
354
|
+
score -= 1
|
|
355
|
+
reasons.append("cdn")
|
|
356
|
+
return score, reasons
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def collect_candidates(domain, max_candidates=40, timeout=8.0, use_ct=True,
|
|
360
|
+
ct_sources=None, max_ct=80, ct_budget=0.0):
|
|
361
|
+
"""收集候选 (name, ip, sources):漏质子域 + 多源 CT/被动 DNS。"""
|
|
362
|
+
names = ["www." + domain, domain] + ["%s.%s" % (s, domain) for s in SUBS]
|
|
363
|
+
passive_ips = []
|
|
364
|
+
if use_ct:
|
|
365
|
+
ct, passive_ips, _status = fetch_passive_names(
|
|
366
|
+
domain, timeout, sources=ct_sources, max_hosts=max_ct, budget=ct_budget)
|
|
367
|
+
if ct:
|
|
368
|
+
_err("[CT] 合计 %d 个主机名" % len(ct))
|
|
369
|
+
names.extend(ct)
|
|
370
|
+
if passive_ips:
|
|
371
|
+
_err("[被动DNS] 合计 %d 个 IP 并入候选" % len(passive_ips))
|
|
372
|
+
uniq, seen = [], set()
|
|
373
|
+
for n in names:
|
|
374
|
+
n = n.strip().lower().rstrip(".")
|
|
375
|
+
if n and n not in seen and (n == domain or n.endswith("." + domain)):
|
|
376
|
+
seen.add(n)
|
|
377
|
+
uniq.append(n)
|
|
378
|
+
uniq = uniq[:max_candidates]
|
|
379
|
+
_err("[候选] 解析 %d 个主机(DoH)" % len(uniq))
|
|
380
|
+
|
|
381
|
+
def one(name):
|
|
382
|
+
return name, doh_resolve(name, timeout)
|
|
383
|
+
|
|
384
|
+
by_ip = {}
|
|
385
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=(4 if ISH else 8)) as ex:
|
|
386
|
+
for name, pairs in ex.map(one, uniq):
|
|
387
|
+
for ip, src in pairs:
|
|
388
|
+
srcs = by_ip.setdefault(ip, {"ips": set(), "sources": set()})
|
|
389
|
+
srcs["sources"].add(name)
|
|
390
|
+
for ip in passive_ips:
|
|
391
|
+
by_ip.setdefault(ip, {"ips": set(), "sources": set()})["sources"].add("passive-dns")
|
|
392
|
+
return by_ip
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def main(argv=None):
|
|
396
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
397
|
+
domains, as_json, no_verify, no_ct = [], False, False, False
|
|
398
|
+
max_c, timeout, budget = 40, 8.0, 120.0
|
|
399
|
+
ct_sources, max_ct, ct_budget = None, 80, 0.0
|
|
400
|
+
i = 0
|
|
401
|
+
while i < len(args):
|
|
402
|
+
a = args[i]
|
|
403
|
+
if a == "--json":
|
|
404
|
+
as_json = True
|
|
405
|
+
elif a == "--no-verify":
|
|
406
|
+
no_verify = True
|
|
407
|
+
elif a == "--no-ct":
|
|
408
|
+
no_ct = True
|
|
409
|
+
elif a in ("--max-candidates", "--timeout", "--budget", "--max-ct", "--ct-budget", "--ct-sources"):
|
|
410
|
+
if i + 1 >= len(args):
|
|
411
|
+
_err("%s 缺少取值" % a)
|
|
412
|
+
return 2
|
|
413
|
+
try:
|
|
414
|
+
if a == "--max-candidates":
|
|
415
|
+
max_c = max(1, int(args[i + 1]))
|
|
416
|
+
elif a == "--budget":
|
|
417
|
+
budget = max(10.0, float(args[i + 1]))
|
|
418
|
+
elif a == "--max-ct":
|
|
419
|
+
max_ct = max(1, int(args[i + 1]))
|
|
420
|
+
elif a == "--ct-budget":
|
|
421
|
+
ct_budget = max(3.0, float(args[i + 1]))
|
|
422
|
+
elif a == "--ct-sources":
|
|
423
|
+
val = args[i + 1].strip()
|
|
424
|
+
if val.lower() == "all":
|
|
425
|
+
ct_sources = None
|
|
426
|
+
else:
|
|
427
|
+
picked = [x.strip() for x in val.split(",") if x.strip()]
|
|
428
|
+
if not picked or any(x not in CT_SOURCES for x in picked):
|
|
429
|
+
_err("--ct-sources 取值非法(可选: %s 或 all)" % ",".join(CT_SOURCES))
|
|
430
|
+
return 2
|
|
431
|
+
ct_sources = picked
|
|
432
|
+
else:
|
|
433
|
+
timeout = max(1.0, min(float(args[i + 1]), 30.0))
|
|
434
|
+
except (TypeError, ValueError):
|
|
435
|
+
_err("%s 取值非法: %s" % (a, args[i + 1]))
|
|
436
|
+
return 2
|
|
437
|
+
i += 1
|
|
438
|
+
elif a.startswith("-"):
|
|
439
|
+
_err("未知参数: %s" % a)
|
|
440
|
+
return 2
|
|
441
|
+
else:
|
|
442
|
+
domains.append(a)
|
|
443
|
+
i += 1
|
|
444
|
+
if not domains:
|
|
445
|
+
_err(__doc__)
|
|
446
|
+
return 2
|
|
447
|
+
|
|
448
|
+
rc = 0
|
|
449
|
+
report = {}
|
|
450
|
+
for domain in domains:
|
|
451
|
+
_err("\n[*] %s" % domain)
|
|
452
|
+
root_ips = doh_resolve(domain, timeout)
|
|
453
|
+
real_root = [ip for ip, _s in root_ips]
|
|
454
|
+
if not real_root:
|
|
455
|
+
_err("[!] %s DoH 解析失败(检查出网/DoH 端点)" % domain)
|
|
456
|
+
rc = 1
|
|
457
|
+
continue
|
|
458
|
+
_err("[根] %s → %s" % (domain, ",".join(real_root)))
|
|
459
|
+
|
|
460
|
+
base = None
|
|
461
|
+
if not no_verify:
|
|
462
|
+
try:
|
|
463
|
+
base = http_probe(real_root[0], domain, timeout)
|
|
464
|
+
_err("[基线] %s | server=%s | cdn=%s | title=%r" % (
|
|
465
|
+
base["status"], base["server"], ",".join(base["cdn"]) or "-", base["title"][:40]))
|
|
466
|
+
except Exception as e:
|
|
467
|
+
_err("[基线] 探测失败: %s(继续)" % e)
|
|
468
|
+
|
|
469
|
+
by_ip = collect_candidates(domain, max_c, timeout, use_ct=not no_ct,
|
|
470
|
+
ct_sources=ct_sources, max_ct=max_ct, ct_budget=ct_budget)
|
|
471
|
+
for ip in real_root: # 根 IP 纳入候选
|
|
472
|
+
by_ip.setdefault(ip, {"sources": set()})["sources"].add(domain)
|
|
473
|
+
|
|
474
|
+
rows = []
|
|
475
|
+
end = time.monotonic() + budget
|
|
476
|
+
pending = [(ip, meta) for ip, meta in by_ip.items()]
|
|
477
|
+
|
|
478
|
+
def verify(item):
|
|
479
|
+
ip, meta = item
|
|
480
|
+
row = {"ip": ip, "sources": sorted(meta["sources"]),
|
|
481
|
+
"score": 0, "reasons": [], "cdn": [], "title": "", "server": "",
|
|
482
|
+
"status": "", "cert_sans": []}
|
|
483
|
+
if not no_verify and time.monotonic() < end:
|
|
484
|
+
try:
|
|
485
|
+
cand = http_probe(ip, domain, timeout)
|
|
486
|
+
row.update({k: cand.get(k) for k in
|
|
487
|
+
("status", "title", "server", "cdn", "cert_sans")})
|
|
488
|
+
row["score"], row["reasons"] = score_candidate(base or {}, cand)
|
|
489
|
+
except Exception as e:
|
|
490
|
+
row["error"] = str(e)[:80]
|
|
491
|
+
return row
|
|
492
|
+
|
|
493
|
+
if no_verify or not pending:
|
|
494
|
+
rows = [verify(it) for it in pending]
|
|
495
|
+
else:
|
|
496
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as ex:
|
|
497
|
+
futs = {ex.submit(verify, it): it for it in pending}
|
|
498
|
+
try:
|
|
499
|
+
for f in concurrent.futures.as_completed(futs, timeout=max(5.0, budget)):
|
|
500
|
+
try:
|
|
501
|
+
rows.append(f.result())
|
|
502
|
+
except Exception:
|
|
503
|
+
pass
|
|
504
|
+
except concurrent.futures.TimeoutError:
|
|
505
|
+
_err("[预算] 验证超过 %.0fs,先返回已完成的候选" % budget)
|
|
506
|
+
finally:
|
|
507
|
+
ex.shutdown(wait=False, cancel_futures=True)
|
|
508
|
+
rows.sort(key=lambda r: (-r["score"], r["ip"]))
|
|
509
|
+
report[domain] = rows
|
|
510
|
+
|
|
511
|
+
if as_json:
|
|
512
|
+
continue
|
|
513
|
+
print("\n%s 候选源站(分数高=更像源站)" % domain)
|
|
514
|
+
print("%-16s %5s %-28s %-10s %s" % ("ip", "score", "sources", "server", "title"))
|
|
515
|
+
for r in rows[:max_c]:
|
|
516
|
+
print("%-16s %5d %-28s %-10s %s%s" % (
|
|
517
|
+
r["ip"], r["score"], ",".join(r["sources"])[:27] or "-",
|
|
518
|
+
(r["server"] or "-")[:10], (r["title"] or "")[:40],
|
|
519
|
+
(" [cdn:%s]" % ",".join(r["cdn"])) if r["cdn"] else ""))
|
|
520
|
+
strong = [r for r in rows if r["score"] >= 4 and not r["cdn"]]
|
|
521
|
+
if strong:
|
|
522
|
+
print("[结论] 高置信候选(无 CDN 头): %s" % ", ".join(r["ip"] for r in strong[:5]))
|
|
523
|
+
elif rows:
|
|
524
|
+
print("[结论] 无高置信候选:可能源站未暴露/全站 CDN/TUN 干扰(用 --json 看细节)")
|
|
525
|
+
rc = rc or 1
|
|
526
|
+
else:
|
|
527
|
+
rc = 1
|
|
528
|
+
|
|
529
|
+
if as_json:
|
|
530
|
+
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
531
|
+
return rc
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
if __name__ == "__main__":
|
|
535
|
+
sys.exit(main())
|