agent-bypass 2.16.0 → 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 +13 -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 +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 +110 -0
|
@@ -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())
|
package/scripts/portscan.py
CHANGED
|
@@ -1,19 +1,32 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
# -*- coding: utf-8 -*-
|
|
3
3
|
"""
|
|
4
|
-
并发端口扫描(无需 nmap
|
|
4
|
+
并发端口扫描(无需 nmap)——iSH / TUN / fake-IP 友好版
|
|
5
5
|
|
|
6
6
|
提示: 手机环境(iSH/Termux)优先用 fastrecon.py scan —— selectors 并发实测快 5-45 倍
|
|
7
7
|
|
|
8
|
+
iSH + TUN(Clash/mihomo/sing-box)常见问题:系统 DNS 被劫持成 fake-IP(198.18.0.0/15),
|
|
9
|
+
直接扫域名会得到不可路由假地址 → 全部超时/误判。本脚本默认自动检测并用 DoH 拿真实 IP。
|
|
10
|
+
|
|
8
11
|
用法:
|
|
9
12
|
python3 portscan.py <host> [--ports 1-65535] [--conc 500] [--timeout 2]
|
|
10
13
|
python3 portscan.py 1.2.3.4 --ports top100
|
|
11
|
-
python3 portscan.py
|
|
14
|
+
python3 portscan.py example.com --resolve doh # 强制 DoH 解析
|
|
15
|
+
python3 portscan.py example.com --resolve none # 只扫 IP,不解析
|
|
16
|
+
python3 portscan.py example.com --allow-fake # 允许扫 fake-IP(一般不要)
|
|
12
17
|
|
|
13
|
-
输出: jsonl
|
|
18
|
+
输出: jsonl(每行一个开放端口,含 ip / host / port / service)
|
|
19
|
+
退出码: 0 正常 / 2 参数错误 / 3 目标解析失败或只剩 fake-IP
|
|
14
20
|
"""
|
|
15
21
|
import argsmini as argparse
|
|
16
|
-
import asyncio, json, re, sys
|
|
22
|
+
import asyncio, json, os, re, sys, time
|
|
23
|
+
|
|
24
|
+
try: # 共享 DNS 工具(同目录 scripts/netdns.py)
|
|
25
|
+
import netdns
|
|
26
|
+
except Exception: # 单文件拷贝场景:退化为基础 socket 解析
|
|
27
|
+
netdns = None
|
|
28
|
+
|
|
29
|
+
ISH = os.path.isdir('/var/minis') or bool(os.environ.get('MINIS_ROOT'))
|
|
17
30
|
|
|
18
31
|
# 100 端口 = 40 高价值服务 + 60 经典(对齐 nmap top100 覆盖面)
|
|
19
32
|
TOP100 = [21,22,23,25,53,80,110,111,135,139,143,443,445,993,995,1433,1521,1723,
|
|
@@ -40,6 +53,11 @@ COMMON = {
|
|
|
40
53
|
50000:"db2"
|
|
41
54
|
}
|
|
42
55
|
|
|
56
|
+
FAKE_HINT = ("[fake-IP] 目标解析到 fake-IP(TUN 劫持 DNS),该地址不可信:\n"
|
|
57
|
+
" 1) 用 DoH 真 IP:python3 portscan.py <真实IP> 或 --resolve doh\n"
|
|
58
|
+
" 2) 关掉代理的 TUN 全局/DNS 劫持,或把目标加 DIRECT/fake-ip-filter\n"
|
|
59
|
+
" 3) 溯源同理:先 DoH 拿真 IP,再扫 IP(nmap -n -Pn <ip>)")
|
|
60
|
+
|
|
43
61
|
|
|
44
62
|
def parse_ports(s):
|
|
45
63
|
s = (s or "").strip().lower()
|
|
@@ -74,6 +92,53 @@ def parse_ports(s):
|
|
|
74
92
|
return sorted(out)
|
|
75
93
|
|
|
76
94
|
|
|
95
|
+
def split_host(raw):
|
|
96
|
+
"""'https://example.com:8443/x' / 'example.com' / '1.2.3.4' → ('example.com', 8443|None)"""
|
|
97
|
+
s = (raw or "").strip()
|
|
98
|
+
s = re.sub(r'^[a-zA-Z][a-zA-Z0-9+.-]*://', '', s)
|
|
99
|
+
s = s.split('/', 1)[0]
|
|
100
|
+
m = re.match(r'^(\[[^\]]+\]|[^:\s/]+)(?::(\d{1,5}))?$', s)
|
|
101
|
+
if not m:
|
|
102
|
+
return None, None
|
|
103
|
+
host = m.group(1).strip('[]')
|
|
104
|
+
port = int(m.group(2)) if m.group(2) else None
|
|
105
|
+
if port is not None and not (1 <= port <= 65535):
|
|
106
|
+
return None, None
|
|
107
|
+
return host, port
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def is_ipv4_literal(s):
|
|
111
|
+
if netdns is not None:
|
|
112
|
+
return netdns.is_ipv4(s)
|
|
113
|
+
return bool(re.match(r'^\d{1,3}(?:\.\d{1,3}){3}$', str(s)))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def is_fake_ip(ip):
|
|
117
|
+
if netdns is not None:
|
|
118
|
+
return netdns.is_fake_ip(ip)
|
|
119
|
+
return str(ip).startswith(('198.18.', '198.19.', '240.', '198.51.100.', '203.0.113.'))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def resolve_target(host, mode="auto", timeout=6.0):
|
|
123
|
+
"""返回 (ip, source, fake_list)。mode: auto/system/doh/none。"""
|
|
124
|
+
if is_ipv4_literal(host):
|
|
125
|
+
return host, "ip", []
|
|
126
|
+
if mode == "none":
|
|
127
|
+
return None, "none", []
|
|
128
|
+
if netdns is not None:
|
|
129
|
+
ips, source = netdns.resolve_real(host, prefer=("system" if mode == "system" else "doh" if mode == "doh" else "auto"),
|
|
130
|
+
timeout=timeout)
|
|
131
|
+
else:
|
|
132
|
+
try:
|
|
133
|
+
import socket as _s
|
|
134
|
+
ips, source = [_s.gethostbyname(host)], "system"
|
|
135
|
+
except Exception:
|
|
136
|
+
ips, source = [], "fail"
|
|
137
|
+
fake = [ip for ip in ips if is_fake_ip(ip)]
|
|
138
|
+
real = [ip for ip in ips if not is_fake_ip(ip)]
|
|
139
|
+
return (real[0] if real else (ips[0] if ips else None)), source, fake
|
|
140
|
+
|
|
141
|
+
|
|
77
142
|
async def probe(host, port, timeout=2.0):
|
|
78
143
|
try:
|
|
79
144
|
r, w = await asyncio.wait_for(
|
|
@@ -88,31 +153,45 @@ async def probe(host, port, timeout=2.0):
|
|
|
88
153
|
return None
|
|
89
154
|
|
|
90
155
|
|
|
91
|
-
async def scan(host, ports, conc=500, timeout=2.0):
|
|
156
|
+
async def scan(host, ports, conc=500, timeout=2.0, deadline=None):
|
|
157
|
+
"""deadline(time.monotonic 秒)用于总时限:到点停发新探测,返回已扫到的部分结果。
|
|
158
|
+
返回 (open_ports, timed_out)。"""
|
|
92
159
|
sem = asyncio.Semaphore(conc)
|
|
93
160
|
open_ports = []
|
|
161
|
+
timed_out = False
|
|
94
162
|
|
|
95
163
|
async def one(p):
|
|
164
|
+
nonlocal timed_out
|
|
165
|
+
if deadline is not None and time.monotonic() > deadline:
|
|
166
|
+
timed_out = True
|
|
167
|
+
return
|
|
96
168
|
async with sem:
|
|
97
169
|
r = await probe(host, p, timeout)
|
|
98
170
|
if r:
|
|
99
171
|
open_ports.append(r)
|
|
100
172
|
|
|
101
173
|
await asyncio.gather(*[one(p) for p in ports])
|
|
102
|
-
return sorted(open_ports)
|
|
174
|
+
return sorted(open_ports), timed_out
|
|
103
175
|
|
|
104
176
|
|
|
105
177
|
def main():
|
|
106
|
-
ap = argparse.ArgumentParser(description="并发端口扫描(无需 nmap)")
|
|
178
|
+
ap = argparse.ArgumentParser(description="并发端口扫描(无需 nmap;自动绕 fake-IP)")
|
|
107
179
|
ap.add_argument("host")
|
|
108
180
|
ap.add_argument("--ports", default="top20",
|
|
109
181
|
help="1-65535 / top100 / top20 / 22,80,443")
|
|
110
182
|
ap.add_argument("--conc", type=int, default=500, help="并发数")
|
|
111
183
|
ap.add_argument("--timeout", type=float, default=2.0)
|
|
112
184
|
ap.add_argument("--json", action="store_true", help="只输出 JSONL")
|
|
185
|
+
ap.add_argument("--resolve", choices=["auto", "system", "doh", "none"], default="auto",
|
|
186
|
+
help="DNS 解析方式: auto(系统→DoH) / system / doh / none(只接受 IP)")
|
|
187
|
+
ap.add_argument("--allow-fake", action="store_true",
|
|
188
|
+
help="允许扫描 fake-IP 地址(默认拒绝,防误判)")
|
|
189
|
+
ap.add_argument("--deadline", type=float, default=0,
|
|
190
|
+
help="总时限秒(0=自动:按端口数/并发估算,最长 600s;到点返回部分结果)")
|
|
113
191
|
a = ap.parse_args()
|
|
114
192
|
|
|
115
|
-
|
|
193
|
+
name, inline_port = split_host(a.host)
|
|
194
|
+
if not name:
|
|
116
195
|
print("[错误] host 只接受域名或 IP(可带 :端口)", file=sys.stderr)
|
|
117
196
|
sys.exit(2)
|
|
118
197
|
try:
|
|
@@ -124,20 +203,47 @@ def main():
|
|
|
124
203
|
if conc != a.conc:
|
|
125
204
|
print(f"[提示] 并发钳制为 {conc}(允许 1-2000)", file=sys.stderr)
|
|
126
205
|
timeout = max(0.1, min(a.timeout, 30.0))
|
|
127
|
-
if
|
|
128
|
-
|
|
206
|
+
if ISH and conc > 200:
|
|
207
|
+
conc = 200
|
|
208
|
+
print("[提示] iSH 环境并发降至 200(CPU 模拟 + 弱网更稳)", file=sys.stderr)
|
|
209
|
+
if ISH and timeout <= 2.0:
|
|
210
|
+
timeout = 3.0
|
|
211
|
+
print("[提示] iSH 环境超时上调至 3.0s", file=sys.stderr)
|
|
212
|
+
if inline_port and inline_port not in ports:
|
|
213
|
+
ports = sorted(set(ports) | {inline_port})
|
|
214
|
+
|
|
215
|
+
ip, source, fake = resolve_target(name, mode=a.resolve, timeout=max(timeout, 6.0))
|
|
216
|
+
if not ip:
|
|
217
|
+
print(f"[错误] 无法解析 {name}(--resolve {a.resolve})", file=sys.stderr)
|
|
218
|
+
print(FAKE_HINT, file=sys.stderr)
|
|
219
|
+
sys.exit(3)
|
|
220
|
+
if fake and not any(not is_fake_ip(x) for x in [ip]):
|
|
221
|
+
if not a.allow_fake:
|
|
222
|
+
print(f"[错误] {name} 只解析到 fake-IP:{','.join(fake)}", file=sys.stderr)
|
|
223
|
+
print(FAKE_HINT, file=sys.stderr)
|
|
224
|
+
sys.exit(3)
|
|
225
|
+
print(f"[警告] 正在扫描 fake-IP(--allow-fake):{ip}(结果不可信)", file=sys.stderr)
|
|
226
|
+
elif source == "doh":
|
|
227
|
+
print(f"[提示] 系统解析为 fake/IP 不可信,已用 DoH 真 IP: {ip}", file=sys.stderr)
|
|
228
|
+
|
|
129
229
|
if not a.json:
|
|
130
|
-
print(f"# 扫描 {
|
|
230
|
+
print(f"# 扫描 {name} → {ip}({source})共 {len(ports)} 端口 (并发 {conc})",
|
|
131
231
|
file=sys.stderr)
|
|
132
232
|
|
|
133
|
-
|
|
233
|
+
overall = a.deadline if a.deadline > 0 else min(600.0, max(20.0, len(ports) * timeout / conc * 3 + 10))
|
|
234
|
+
deadline = time.monotonic() + max(5.0, min(overall, 3600.0))
|
|
235
|
+
open_ports, timed_out = asyncio.run(scan(ip, ports, conc, timeout, deadline))
|
|
236
|
+
if timed_out:
|
|
237
|
+
print(f"# 达到总时限 {overall:.0f}s:返回部分结果(可加大 --deadline 或降低端口数)", file=sys.stderr)
|
|
134
238
|
|
|
135
239
|
for p in open_ports:
|
|
136
|
-
rec = {"host":
|
|
240
|
+
rec = {"host": name, "ip": ip, "port": p, "service": COMMON.get(p, "?")}
|
|
137
241
|
print(json.dumps(rec, ensure_ascii=False))
|
|
138
242
|
|
|
139
243
|
if not a.json:
|
|
140
244
|
print(f"# 开放 {len(open_ports)} 个: {open_ports}", file=sys.stderr)
|
|
245
|
+
if ISH:
|
|
246
|
+
print("# iSH 提示:并发已限制、超时已上调;长扫描用 --deadline 控制,配合 nohup 后台跑", file=sys.stderr)
|
|
141
247
|
|
|
142
248
|
|
|
143
249
|
if __name__ == "__main__":
|