agent-bypass 2.15.1 → 2.17.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 +30 -5
- package/SKILL.md +48 -120
- package/bin/install.js +114 -5
- package/dsh/README.md +1 -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 +113 -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 +369 -0
- package/scripts/portscan.py +119 -13
- package/tests/test_regressions.py +129 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""共享 DNS 工具:fake-IP 判定 + DoH 真 IP 解析 + 系统解析兜底(纯标准库)。
|
|
4
|
+
|
|
5
|
+
为什么需要:iSH / iOS 或桌面开了 TUN(Clash / mihomo / sing-box 等)时,
|
|
6
|
+
DNS 常被劫持成 fake-IP(默认 198.18.0.0/15)。此时:
|
|
7
|
+
· 端口扫描(nmap/masscan/本脚本 connect 扫)拿到的是不可路由假地址 → 全部超时/误判;
|
|
8
|
+
· 源站溯源 / 爆破 / 证书探测同样拿不到真 IP。
|
|
9
|
+
解法:用 DoH(DNS over HTTPS,走 443,不经过被劫持的 UDP:53)拿真实 A/AAAA。
|
|
10
|
+
|
|
11
|
+
用法(供 portscan / fastrecon / origin_trace 复用):
|
|
12
|
+
import netdns
|
|
13
|
+
ips, source = netdns.resolve_real('example.com') # auto: 系统 → DoH
|
|
14
|
+
ips, source = netdns.resolve_real('example.com', prefer='doh')
|
|
15
|
+
netdns.is_fake_ip('198.18.0.5') # True
|
|
16
|
+
|
|
17
|
+
CLI 自检:
|
|
18
|
+
python3 netdns.py example.com [--doh] [--json]
|
|
19
|
+
"""
|
|
20
|
+
import ipaddress
|
|
21
|
+
import json
|
|
22
|
+
import socket
|
|
23
|
+
import sys
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
import urllib.parse
|
|
27
|
+
import urllib.request
|
|
28
|
+
|
|
29
|
+
# fake-IP 常见伪造段(Clash/mihomo/sing-box 默认 198.18.0.0/15;其它代理软件另有实现)
|
|
30
|
+
FAKE_NETS = tuple(ipaddress.ip_network(n) for n in (
|
|
31
|
+
"198.18.0.0/15", # RFC 2544 基准测试段:Clash / mihomo / sing-box 默认 fake-ip
|
|
32
|
+
"198.51.100.0/24", # TEST-NET-2:部分代理软件伪造段
|
|
33
|
+
"203.0.113.0/24", # TEST-NET-3
|
|
34
|
+
"240.0.0.0/4", # 保留段:部分 fake-ip 实现
|
|
35
|
+
))
|
|
36
|
+
|
|
37
|
+
# DoH JSON 端点(走 HTTPS/443,TUN 劫持不到 UDP:53)
|
|
38
|
+
DOH_PROVIDERS = (
|
|
39
|
+
"https://1.1.1.1/dns-query", # Cloudflare(需 accept: application/dns-json)
|
|
40
|
+
"https://dns.google/resolve", # Google
|
|
41
|
+
"https://223.5.5.5/resolve", # AliDNS
|
|
42
|
+
)
|
|
43
|
+
UA = "agent-bypass-netdns/1.0"
|
|
44
|
+
DEFAULT_TIMEOUT = 6.0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_ipv4(s):
|
|
48
|
+
try:
|
|
49
|
+
ipaddress.IPv4Address(str(s).strip())
|
|
50
|
+
return True
|
|
51
|
+
except (ValueError, TypeError):
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_fake_ip(ip):
|
|
56
|
+
"""fake-IP / 不可路由伪造段判定。"""
|
|
57
|
+
if not is_ipv4(ip):
|
|
58
|
+
return False
|
|
59
|
+
a = ipaddress.IPv4Address(str(ip).strip())
|
|
60
|
+
return any(a in n for n in FAKE_NETS)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _system_resolve_impl(name):
|
|
64
|
+
"""实际解析;可能被 TUN 劫持成 fake-IP。"""
|
|
65
|
+
infos = socket.getaddrinfo(name, None, socket.AF_INET, socket.SOCK_STREAM)
|
|
66
|
+
out = []
|
|
67
|
+
for info in infos:
|
|
68
|
+
ip = info[4][0]
|
|
69
|
+
if is_ipv4(ip) and ip not in out:
|
|
70
|
+
out.append(ip)
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def system_resolve(name, timeout=5.0):
|
|
75
|
+
"""系统解析 + 硬超时:getaddrinfo 在部分环境(iSH/TUN)会长时间卡住,
|
|
76
|
+
用守护线程 + join(timeout) 兜底,确保调用方最多等 timeout 秒。"""
|
|
77
|
+
box = {"ips": []}
|
|
78
|
+
|
|
79
|
+
def work():
|
|
80
|
+
try:
|
|
81
|
+
box["ips"] = _system_resolve_impl(name)
|
|
82
|
+
except Exception:
|
|
83
|
+
box["ips"] = []
|
|
84
|
+
|
|
85
|
+
th = threading.Thread(target=work, daemon=True)
|
|
86
|
+
th.start()
|
|
87
|
+
th.join(timeout)
|
|
88
|
+
return list(box["ips"])
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _parse_dns_json(text, qtype):
|
|
92
|
+
try:
|
|
93
|
+
data = json.loads(text)
|
|
94
|
+
except (ValueError, TypeError):
|
|
95
|
+
return []
|
|
96
|
+
want = 1 if qtype == "A" else 28
|
|
97
|
+
ips = []
|
|
98
|
+
for ans in (data.get("Answer") or []):
|
|
99
|
+
if ans.get("type") != want:
|
|
100
|
+
continue
|
|
101
|
+
d = str(ans.get("data") or "").strip()
|
|
102
|
+
if qtype == "A" and is_ipv4(d) and d not in ips:
|
|
103
|
+
ips.append(d)
|
|
104
|
+
elif qtype == "AAAA" and ":" in d and d not in ips:
|
|
105
|
+
ips.append(d)
|
|
106
|
+
return ips
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def doh_query(name, qtype="A", timeout=DEFAULT_TIMEOUT, providers=None, fake_ok=False,
|
|
110
|
+
deadline=None):
|
|
111
|
+
"""DoH 查询:按顺序尝试多个端点;默认过滤 fake-IP 结果。
|
|
112
|
+
deadline(time.monotonic 秒)用于总时限,避免多端点叠加导致长时间卡住。"""
|
|
113
|
+
deadline = deadline if deadline is not None else time.monotonic() + max(timeout, 1.0) * 3
|
|
114
|
+
for base in (providers or DOH_PROVIDERS):
|
|
115
|
+
remain = deadline - time.monotonic()
|
|
116
|
+
if remain <= 0.2:
|
|
117
|
+
break
|
|
118
|
+
sep = "&" if "?" in base else "?"
|
|
119
|
+
url = "%s%sname=%s&type=%s" % (
|
|
120
|
+
base, sep, urllib.parse.quote(str(name), safe=""), qtype)
|
|
121
|
+
try:
|
|
122
|
+
req = urllib.request.Request(url, headers={
|
|
123
|
+
"accept": "application/dns-json",
|
|
124
|
+
"user-agent": UA,
|
|
125
|
+
})
|
|
126
|
+
with urllib.request.urlopen(req, timeout=min(timeout, remain)) as r:
|
|
127
|
+
ips = _parse_dns_json(r.read(1 << 20).decode("utf-8", "ignore"), qtype)
|
|
128
|
+
except Exception:
|
|
129
|
+
continue
|
|
130
|
+
if not fake_ok:
|
|
131
|
+
ips = [x for x in ips if not is_fake_ip(x)]
|
|
132
|
+
if ips:
|
|
133
|
+
return ips
|
|
134
|
+
return []
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def resolve_real(name, prefer="auto", timeout=DEFAULT_TIMEOUT):
|
|
138
|
+
"""解析真实 IP。prefer: auto(系统→DoH)/ system / doh。
|
|
139
|
+
返回 (ips, source):source ∈ ip / system / doh / system-fake / fail。
|
|
140
|
+
"""
|
|
141
|
+
name = str(name or "").strip().rstrip(".")
|
|
142
|
+
if is_ipv4(name):
|
|
143
|
+
return [name], "ip"
|
|
144
|
+
if prefer == "system":
|
|
145
|
+
ips = system_resolve(name, timeout)
|
|
146
|
+
return ips, ("system" if ips else "fail")
|
|
147
|
+
t0 = time.monotonic()
|
|
148
|
+
sys_ips = system_resolve(name, min(timeout, 5.0)) if prefer == "auto" else []
|
|
149
|
+
real = [x for x in sys_ips if not is_fake_ip(x)]
|
|
150
|
+
if real:
|
|
151
|
+
return real, "system"
|
|
152
|
+
budget = max(2.0, timeout - (time.monotonic() - t0))
|
|
153
|
+
doh_ips = doh_query(name, "A", timeout=min(4.0, budget),
|
|
154
|
+
deadline=time.monotonic() + budget + 1.0)
|
|
155
|
+
if doh_ips:
|
|
156
|
+
return doh_ips, "doh"
|
|
157
|
+
if sys_ips:
|
|
158
|
+
return sys_ips, "system-fake"
|
|
159
|
+
return [], "fail"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def fmt_ips(ips):
|
|
163
|
+
return ",".join(ips) if ips else "-"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _main(argv):
|
|
167
|
+
args = [a for a in argv[1:] if not a.startswith("-")]
|
|
168
|
+
flags = [a for a in argv[1:] if a.startswith("-")]
|
|
169
|
+
if not args:
|
|
170
|
+
print("用法: python3 netdns.py <domain>... [--doh] [--json]", file=sys.stderr)
|
|
171
|
+
return 2
|
|
172
|
+
prefer = "doh" if "--doh" in flags else "auto"
|
|
173
|
+
rc = 0
|
|
174
|
+
out = {}
|
|
175
|
+
for name in args:
|
|
176
|
+
ips, source = resolve_real(name, prefer=prefer)
|
|
177
|
+
out[name] = {"ips": ips, "source": source}
|
|
178
|
+
if not ips:
|
|
179
|
+
rc = 1
|
|
180
|
+
print("%s\t-\t[解析失败:TUN/fake-IP?换 DoH 或检查出网]" % name, file=sys.stderr)
|
|
181
|
+
elif source == "system-fake":
|
|
182
|
+
rc = 1
|
|
183
|
+
print("%s\t%s\t[fake-IP:不可信,换 DoH]" % (name, fmt_ips(ips)), file=sys.stderr)
|
|
184
|
+
else:
|
|
185
|
+
print("%s\t%s\t[%s]" % (name, fmt_ips(ips), source))
|
|
186
|
+
if "--json" in flags:
|
|
187
|
+
print(json.dumps(out, ensure_ascii=False))
|
|
188
|
+
return rc
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
if __name__ == "__main__":
|
|
192
|
+
sys.exit(_main(sys.argv))
|
|
@@ -0,0 +1,369 @@
|
|
|
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
|
+
DOH_PROVIDERS="https://1.1.1.1/dns-query" python3 origin_trace.py example.com
|
|
16
|
+
|
|
17
|
+
输出: 候选源站表(ip / 分数 / 来源 / 标题 / Server / CDN 头),--json 输出机器可读结构。
|
|
18
|
+
退出码: 0 正常(有候选)/ 1 无候选或全部失败 / 2 用法错误。
|
|
19
|
+
"""
|
|
20
|
+
import concurrent.futures
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
import socket
|
|
25
|
+
import ssl
|
|
26
|
+
import sys
|
|
27
|
+
import time
|
|
28
|
+
import urllib.parse
|
|
29
|
+
import urllib.request
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
import netdns
|
|
33
|
+
except Exception: # 单文件拷贝场景
|
|
34
|
+
netdns = None
|
|
35
|
+
|
|
36
|
+
UA = "Mozilla/5.0 (compatible; origin-trace/1.0; +agent-bypass)"
|
|
37
|
+
SUBS = [
|
|
38
|
+
"direct", "origin", "origin-www", "mail", "mx", "smtp", "pop", "imap",
|
|
39
|
+
"webmail", "autodiscover", "autoconfig", "cpanel", "whm", "ftp",
|
|
40
|
+
"dev", "test", "staging", "stage", "pre", "prod", "beta", "old",
|
|
41
|
+
"legacy", "backup", "admin", "api", "gateway", "vpn", "oa", "portal",
|
|
42
|
+
"m", "wap", "static", "img", "cdn", "assets", "uploads",
|
|
43
|
+
]
|
|
44
|
+
CDN_HEADERS = (
|
|
45
|
+
"cf-ray", "cf-cache-status", "x-cache", "via", "x-amz-cf-id", "x-amz-cf-pop",
|
|
46
|
+
"x-fastly-request-id", "x-akamai-transformed", "x-cdn", "x-served-by",
|
|
47
|
+
"x-cache-hits", "x-timer", "server-timing",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _err(msg):
|
|
52
|
+
print(msg, file=sys.stderr)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_ipv4(s):
|
|
56
|
+
if netdns is not None:
|
|
57
|
+
return netdns.is_ipv4(s)
|
|
58
|
+
return bool(re.match(r"^\d{1,3}(?:\.\d{1,3}){3}$", str(s)))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def doh_resolve(name, timeout=6.0):
|
|
62
|
+
"""DoH 真 IP;无 netdns 时退回系统解析。返回 [(ip, 'doh'|'system')]"""
|
|
63
|
+
if netdns is not None:
|
|
64
|
+
ips = netdns.doh_query(name, "A", timeout)
|
|
65
|
+
if ips:
|
|
66
|
+
return [(ip, "doh") for ip in ips]
|
|
67
|
+
ips = netdns.system_resolve(name, timeout)
|
|
68
|
+
return [(ip, "system") for ip in ips if not netdns.is_fake_ip(ip)]
|
|
69
|
+
try:
|
|
70
|
+
return [(socket.gethostbyname(name), "system")]
|
|
71
|
+
except OSError:
|
|
72
|
+
return []
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def fetch_ct_names(domain, timeout=8.0):
|
|
76
|
+
"""证书透明度(crt.sh)里的 DNS 名;返回去重主机名列表。
|
|
77
|
+
crt.sh 偶发 502/超时:重试一次,仍失败则跳过(不阻塞主流程)。"""
|
|
78
|
+
url = "https://crt.sh/?q=" + urllib.parse.quote("%." + domain, safe="") + "&output=json"
|
|
79
|
+
last = None
|
|
80
|
+
for attempt in (1, 2):
|
|
81
|
+
try:
|
|
82
|
+
req = urllib.request.Request(url, headers={"user-agent": UA})
|
|
83
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
84
|
+
text = r.read(8 << 20).decode("utf-8", "ignore")
|
|
85
|
+
return parse_crtsh(text, domain)
|
|
86
|
+
except Exception as e:
|
|
87
|
+
last = e
|
|
88
|
+
if attempt == 1:
|
|
89
|
+
time.sleep(1.0)
|
|
90
|
+
_err("[crt.sh] 查询失败(已重试): %s —— 跳过 CT,继续漏质子域" % last)
|
|
91
|
+
return []
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def parse_crtsh(text, domain):
|
|
95
|
+
"""解析 crt.sh JSON → 该域名下的主机名(去通配符、去重、保序)。"""
|
|
96
|
+
try:
|
|
97
|
+
rows = json.loads(text)
|
|
98
|
+
except (ValueError, TypeError):
|
|
99
|
+
return []
|
|
100
|
+
if not isinstance(rows, list):
|
|
101
|
+
return []
|
|
102
|
+
out, seen = [], set()
|
|
103
|
+
for row in rows:
|
|
104
|
+
if not isinstance(row, dict):
|
|
105
|
+
continue
|
|
106
|
+
for raw in str(row.get("name_value") or "").split("\n"):
|
|
107
|
+
name = raw.strip().lower().lstrip("*.").rstrip(".")
|
|
108
|
+
if not name or name in seen:
|
|
109
|
+
continue
|
|
110
|
+
if name == domain or name.endswith("." + domain):
|
|
111
|
+
seen.add(name)
|
|
112
|
+
out.append(name)
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def cdn_markers(headers):
|
|
117
|
+
return [h for h in CDN_HEADERS if h in headers]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def extract_title(body):
|
|
121
|
+
m = re.search(r"<title[^>]*>(.*?)</title>", body, re.I | re.S)
|
|
122
|
+
if not m:
|
|
123
|
+
return ""
|
|
124
|
+
t = re.sub(r"\s+", " ", m.group(1)).strip()
|
|
125
|
+
return t[:120]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def parse_response(data):
|
|
129
|
+
"""原始 HTTP 响应 → dict(status/title/server/headers/cdn/location)。"""
|
|
130
|
+
head, _, body = data.partition(b"\r\n\r\n")
|
|
131
|
+
lines = head.decode("iso-8859-1", "ignore").split("\r\n")
|
|
132
|
+
status = lines[0].strip() if lines else ""
|
|
133
|
+
headers = {}
|
|
134
|
+
for line in lines[1:]:
|
|
135
|
+
if ":" in line:
|
|
136
|
+
k, v = line.split(":", 1)
|
|
137
|
+
headers[k.strip().lower()] = v.strip()
|
|
138
|
+
return {
|
|
139
|
+
"status": status,
|
|
140
|
+
"title": extract_title(body.decode("utf-8", "ignore")),
|
|
141
|
+
"server": headers.get("server", ""),
|
|
142
|
+
"location": headers.get("location", ""),
|
|
143
|
+
"headers": headers,
|
|
144
|
+
"cdn": cdn_markers(headers),
|
|
145
|
+
"body_sha1": hashlib.sha1(body).hexdigest()[:12] if body else "",
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def http_probe(ip, domain, timeout=8.0, method="GET"):
|
|
150
|
+
"""直连 IP + 正确 SNI/Host 的 HTTPS 探测(不校验证书,仅采集指纹)。"""
|
|
151
|
+
ctx = ssl.create_default_context()
|
|
152
|
+
ctx.check_hostname = False
|
|
153
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
154
|
+
raw = socket.create_connection((ip, 443), timeout=timeout)
|
|
155
|
+
try:
|
|
156
|
+
sock = ctx.wrap_socket(raw, server_hostname=domain) # SSLContext.wrap_socket 不接受 timeout 参数
|
|
157
|
+
sock.settimeout(timeout)
|
|
158
|
+
try:
|
|
159
|
+
cert = sock.getpeercert() or {}
|
|
160
|
+
sans = [v for k, v in cert.get("subjectAltName", ()) if k == "DNS"]
|
|
161
|
+
req = ("%s / HTTP/1.1\r\nHost: %s\r\nUser-Agent: %s\r\n"
|
|
162
|
+
"Accept: */*\r\nConnection: close\r\n\r\n") % (method, domain, UA)
|
|
163
|
+
sock.sendall(req.encode("utf-8", "ignore"))
|
|
164
|
+
data = b""
|
|
165
|
+
end = time.monotonic() + max(timeout, 2.0) # 读取总预算,防慢连接挂住
|
|
166
|
+
while len(data) < 131072 and time.monotonic() < end:
|
|
167
|
+
try:
|
|
168
|
+
chunk = sock.recv(16384)
|
|
169
|
+
except (socket.timeout, OSError):
|
|
170
|
+
break
|
|
171
|
+
if not chunk:
|
|
172
|
+
break
|
|
173
|
+
data += chunk
|
|
174
|
+
finally:
|
|
175
|
+
sock.close()
|
|
176
|
+
finally:
|
|
177
|
+
try:
|
|
178
|
+
raw.close()
|
|
179
|
+
except Exception:
|
|
180
|
+
pass
|
|
181
|
+
info = parse_response(data)
|
|
182
|
+
info["cert_sans"] = sans
|
|
183
|
+
info["cert_domain_ok"] = any(
|
|
184
|
+
domain == x or domain.endswith("." + x.lstrip("*.")) for x in sans
|
|
185
|
+
)
|
|
186
|
+
return info
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def score_candidate(base, cand):
|
|
190
|
+
"""候选 vs 基线指纹打分:同标题/同 Server/无 CDN 头/证书匹配加分。"""
|
|
191
|
+
score, reasons = 0, []
|
|
192
|
+
if base.get("title") and cand.get("title") and cand["title"] == base["title"]:
|
|
193
|
+
score += 3
|
|
194
|
+
reasons.append("title")
|
|
195
|
+
if base.get("status") and cand.get("status") == base.get("status"):
|
|
196
|
+
score += 1
|
|
197
|
+
if base.get("server") and cand.get("server") == base.get("server"):
|
|
198
|
+
score += 1
|
|
199
|
+
reasons.append("server")
|
|
200
|
+
if base.get("cdn") and not cand.get("cdn"):
|
|
201
|
+
score += 2
|
|
202
|
+
reasons.append("no-cdn-headers")
|
|
203
|
+
if cand.get("cert_domain_ok"):
|
|
204
|
+
score += 1
|
|
205
|
+
reasons.append("cert-san")
|
|
206
|
+
if cand.get("cdn"):
|
|
207
|
+
if base.get("cdn") and set(cand["cdn"]) & set(base["cdn"]):
|
|
208
|
+
score -= 2 # 与基线同一 CDN = 还是 CDN 边缘,不是源站
|
|
209
|
+
reasons.append("same-cdn")
|
|
210
|
+
else:
|
|
211
|
+
score -= 1
|
|
212
|
+
reasons.append("cdn")
|
|
213
|
+
return score, reasons
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def collect_candidates(domain, max_candidates=40, timeout=8.0, use_ct=True):
|
|
217
|
+
"""收集候选 (name, ip, sources)。"""
|
|
218
|
+
names = ["www." + domain, domain] + ["%s.%s" % (s, domain) for s in SUBS]
|
|
219
|
+
if use_ct:
|
|
220
|
+
ct = fetch_ct_names(domain, timeout)
|
|
221
|
+
if ct:
|
|
222
|
+
_err("[CT] crt.sh 返回 %d 个主机名" % len(ct))
|
|
223
|
+
names.extend(ct)
|
|
224
|
+
uniq, seen = [], set()
|
|
225
|
+
for n in names:
|
|
226
|
+
n = n.strip().lower().rstrip(".")
|
|
227
|
+
if n and n not in seen and (n == domain or n.endswith("." + domain)):
|
|
228
|
+
seen.add(n)
|
|
229
|
+
uniq.append(n)
|
|
230
|
+
uniq = uniq[:max_candidates]
|
|
231
|
+
_err("[候选] 解析 %d 个主机(DoH)" % len(uniq))
|
|
232
|
+
|
|
233
|
+
def one(name):
|
|
234
|
+
return name, doh_resolve(name, timeout)
|
|
235
|
+
|
|
236
|
+
by_ip = {}
|
|
237
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as ex:
|
|
238
|
+
for name, pairs in ex.map(one, uniq):
|
|
239
|
+
for ip, src in pairs:
|
|
240
|
+
srcs = by_ip.setdefault(ip, {"ips": set(), "sources": set()})
|
|
241
|
+
srcs["sources"].add(name)
|
|
242
|
+
return by_ip
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def main(argv=None):
|
|
246
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
247
|
+
domains, as_json, no_verify, no_ct = [], False, False, False
|
|
248
|
+
max_c, timeout, budget = 40, 8.0, 120.0
|
|
249
|
+
i = 0
|
|
250
|
+
while i < len(args):
|
|
251
|
+
a = args[i]
|
|
252
|
+
if a == "--json":
|
|
253
|
+
as_json = True
|
|
254
|
+
elif a == "--no-verify":
|
|
255
|
+
no_verify = True
|
|
256
|
+
elif a == "--no-ct":
|
|
257
|
+
no_ct = True
|
|
258
|
+
elif a in ("--max-candidates", "--timeout", "--budget"):
|
|
259
|
+
if i + 1 >= len(args):
|
|
260
|
+
_err("%s 缺少取值" % a)
|
|
261
|
+
return 2
|
|
262
|
+
try:
|
|
263
|
+
if a == "--max-candidates":
|
|
264
|
+
max_c = max(1, int(args[i + 1]))
|
|
265
|
+
elif a == "--budget":
|
|
266
|
+
budget = max(10.0, float(args[i + 1]))
|
|
267
|
+
else:
|
|
268
|
+
timeout = max(1.0, min(float(args[i + 1]), 30.0))
|
|
269
|
+
except (TypeError, ValueError):
|
|
270
|
+
_err("%s 取值非法: %s" % (a, args[i + 1]))
|
|
271
|
+
return 2
|
|
272
|
+
i += 1
|
|
273
|
+
elif a.startswith("-"):
|
|
274
|
+
_err("未知参数: %s" % a)
|
|
275
|
+
return 2
|
|
276
|
+
else:
|
|
277
|
+
domains.append(a)
|
|
278
|
+
i += 1
|
|
279
|
+
if not domains:
|
|
280
|
+
_err(__doc__)
|
|
281
|
+
return 2
|
|
282
|
+
|
|
283
|
+
rc = 0
|
|
284
|
+
report = {}
|
|
285
|
+
for domain in domains:
|
|
286
|
+
_err("\n[*] %s" % domain)
|
|
287
|
+
root_ips = doh_resolve(domain, timeout)
|
|
288
|
+
real_root = [ip for ip, _s in root_ips]
|
|
289
|
+
if not real_root:
|
|
290
|
+
_err("[!] %s DoH 解析失败(检查出网/DoH 端点)" % domain)
|
|
291
|
+
rc = 1
|
|
292
|
+
continue
|
|
293
|
+
_err("[根] %s → %s" % (domain, ",".join(real_root)))
|
|
294
|
+
|
|
295
|
+
base = None
|
|
296
|
+
if not no_verify:
|
|
297
|
+
try:
|
|
298
|
+
base = http_probe(real_root[0], domain, timeout)
|
|
299
|
+
_err("[基线] %s | server=%s | cdn=%s | title=%r" % (
|
|
300
|
+
base["status"], base["server"], ",".join(base["cdn"]) or "-", base["title"][:40]))
|
|
301
|
+
except Exception as e:
|
|
302
|
+
_err("[基线] 探测失败: %s(继续)" % e)
|
|
303
|
+
|
|
304
|
+
by_ip = collect_candidates(domain, max_c, timeout, use_ct=not no_ct)
|
|
305
|
+
for ip in real_root: # 根 IP 纳入候选
|
|
306
|
+
by_ip.setdefault(ip, {"sources": set()})["sources"].add(domain)
|
|
307
|
+
|
|
308
|
+
rows = []
|
|
309
|
+
end = time.monotonic() + budget
|
|
310
|
+
pending = [(ip, meta) for ip, meta in by_ip.items()]
|
|
311
|
+
|
|
312
|
+
def verify(item):
|
|
313
|
+
ip, meta = item
|
|
314
|
+
row = {"ip": ip, "sources": sorted(meta["sources"]),
|
|
315
|
+
"score": 0, "reasons": [], "cdn": [], "title": "", "server": "",
|
|
316
|
+
"status": "", "cert_sans": []}
|
|
317
|
+
if not no_verify and time.monotonic() < end:
|
|
318
|
+
try:
|
|
319
|
+
cand = http_probe(ip, domain, timeout)
|
|
320
|
+
row.update({k: cand.get(k) for k in
|
|
321
|
+
("status", "title", "server", "cdn", "cert_sans")})
|
|
322
|
+
row["score"], row["reasons"] = score_candidate(base or {}, cand)
|
|
323
|
+
except Exception as e:
|
|
324
|
+
row["error"] = str(e)[:80]
|
|
325
|
+
return row
|
|
326
|
+
|
|
327
|
+
if no_verify or not pending:
|
|
328
|
+
rows = [verify(it) for it in pending]
|
|
329
|
+
else:
|
|
330
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as ex:
|
|
331
|
+
futs = {ex.submit(verify, it): it for it in pending}
|
|
332
|
+
try:
|
|
333
|
+
for f in concurrent.futures.as_completed(futs, timeout=max(5.0, budget)):
|
|
334
|
+
try:
|
|
335
|
+
rows.append(f.result())
|
|
336
|
+
except Exception:
|
|
337
|
+
pass
|
|
338
|
+
except concurrent.futures.TimeoutError:
|
|
339
|
+
_err("[预算] 验证超过 %.0fs,先返回已完成的候选" % budget)
|
|
340
|
+
finally:
|
|
341
|
+
ex.shutdown(wait=False, cancel_futures=True)
|
|
342
|
+
rows.sort(key=lambda r: (-r["score"], r["ip"]))
|
|
343
|
+
report[domain] = rows
|
|
344
|
+
|
|
345
|
+
if as_json:
|
|
346
|
+
continue
|
|
347
|
+
print("\n%s 候选源站(分数高=更像源站)" % domain)
|
|
348
|
+
print("%-16s %5s %-28s %-10s %s" % ("ip", "score", "sources", "server", "title"))
|
|
349
|
+
for r in rows[:max_c]:
|
|
350
|
+
print("%-16s %5d %-28s %-10s %s%s" % (
|
|
351
|
+
r["ip"], r["score"], ",".join(r["sources"])[:27] or "-",
|
|
352
|
+
(r["server"] or "-")[:10], (r["title"] or "")[:40],
|
|
353
|
+
(" [cdn:%s]" % ",".join(r["cdn"])) if r["cdn"] else ""))
|
|
354
|
+
strong = [r for r in rows if r["score"] >= 4 and not r["cdn"]]
|
|
355
|
+
if strong:
|
|
356
|
+
print("[结论] 高置信候选(无 CDN 头): %s" % ", ".join(r["ip"] for r in strong[:5]))
|
|
357
|
+
elif rows:
|
|
358
|
+
print("[结论] 无高置信候选:可能源站未暴露/全站 CDN/TUN 干扰(用 --json 看细节)")
|
|
359
|
+
rc = rc or 1
|
|
360
|
+
else:
|
|
361
|
+
rc = 1
|
|
362
|
+
|
|
363
|
+
if as_json:
|
|
364
|
+
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
365
|
+
return rc
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
if __name__ == "__main__":
|
|
369
|
+
sys.exit(main())
|