agent-bypass 2.11.7 → 2.13.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/README.md +3 -3
- package/SKILL.md +6 -4
- package/package.json +1 -1
- package/references/learned.md +533 -0
- package/references/mobile-fast-recon.md +19 -0
- package/references/recon-commands.md +2 -0
- package/references/spa-recon.md +299 -0
- package/scripts/cvefetch.py +6 -5
- package/scripts/fastrecon.py +485 -28
package/scripts/fastrecon.py
CHANGED
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
fastrecon.py scan 10.0.0.0/24 -p 22,80,443 -t 0.6
|
|
15
15
|
fastrecon.py http urls.txt -b 48 # HTTP 批量探测(curl -Z 并行)
|
|
16
16
|
fastrecon.py http https://a.com,https://b.com --code
|
|
17
|
+
fastrecon.py sub example.com # 子域枚举(词典爆破 + 通配符检测)
|
|
18
|
+
fastrecon.py sub a.com,b.com --ct # 多根 + 证书透明度日志并入
|
|
19
|
+
fastrecon.py multi example.com,github.com # 多域名情报(A/NS/MX/TXT 汇总)
|
|
17
20
|
|
|
18
21
|
纯标准库(socket/selectors/struct/subprocess/urllib);无 root 依赖;Termux/iSH 通用。
|
|
19
22
|
Python 3.8+。退出码:0 正常 / 1 用法错误 / 2 参数或目标非法。
|
|
@@ -56,12 +59,18 @@ def _shq(s):
|
|
|
56
59
|
if s and all(c in _SAFE for c in s): return s
|
|
57
60
|
return "'%s'" % s.replace("'", "'" + chr(92) + "'" + "'")
|
|
58
61
|
def _run_curl(cargs):
|
|
62
|
+
if os.name == 'nt': # Windows cmd 不识别单引号与 /dev/null:走列表调用
|
|
63
|
+
try:
|
|
64
|
+
r = _sp().run(cargs, capture_output=True, encoding='utf-8', errors='replace')
|
|
65
|
+
return r.stdout or ''
|
|
66
|
+
except Exception:
|
|
67
|
+
return ''
|
|
59
68
|
p = os.popen(" ".join(_shq(a) for a in cargs) + " 2>/dev/null")
|
|
60
69
|
out = p.read(); p.close(); return out
|
|
61
70
|
import urllib.parse
|
|
62
71
|
|
|
63
72
|
CACHE_FILE = os.path.expanduser('~/.fastrecon_cache.txt')
|
|
64
|
-
FALLBACK_RESOLVERS = ['1.1.1.1', '8.8.8.8']
|
|
73
|
+
FALLBACK_RESOLVERS = ['1.1.1.1', '9.9.9.9', '223.5.5.5', '8.8.8.8'] # 8.8.8.8 境内常被墙,置末
|
|
65
74
|
MAX_DNS_WORKERS = 256
|
|
66
75
|
MAX_SCAN_WORKERS = 1024
|
|
67
76
|
MAX_HTTP_BATCH = 256
|
|
@@ -151,11 +160,15 @@ def read_targets(t):
|
|
|
151
160
|
|
|
152
161
|
|
|
153
162
|
def load_resolvers(override=None):
|
|
154
|
-
"""resolver
|
|
163
|
+
"""resolver 列表:跳过 fake-ip 网关(Clash/mihomo 系 198.18.0.0/15 假 DNS,
|
|
164
|
+
返回值不是真实 IP);系统无真 DNS 时探测公共(带缓存);纯 fake 环境保留
|
|
165
|
+
原 resolver 兜底(子域存在性判定仍有效)。平台:Linux/macOS 读
|
|
166
|
+
/etc/resolv.conf;Windows 无该文件 → 直接走公共探测。
|
|
167
|
+
"""
|
|
155
168
|
if override:
|
|
156
169
|
o = override.strip().strip('[]').split('%')[0]
|
|
157
170
|
return [o] if _is_ipv4(o) else []
|
|
158
|
-
rs = []
|
|
171
|
+
raw, rs = [], []
|
|
159
172
|
try:
|
|
160
173
|
with open('/etc/resolv.conf', encoding='utf-8', errors='ignore') as f:
|
|
161
174
|
for line in f:
|
|
@@ -163,11 +176,74 @@ def load_resolvers(override=None):
|
|
|
163
176
|
if not m:
|
|
164
177
|
continue
|
|
165
178
|
a = m.group(1).strip('[]').split('%')[0]
|
|
166
|
-
if _is_ipv4(a)
|
|
179
|
+
if not _is_ipv4(a):
|
|
180
|
+
continue
|
|
181
|
+
if a not in raw:
|
|
182
|
+
raw.append(a)
|
|
183
|
+
if not _is_fakeip(a) and a not in rs:
|
|
167
184
|
rs.append(a)
|
|
168
185
|
except OSError:
|
|
169
186
|
pass
|
|
170
|
-
|
|
187
|
+
if rs:
|
|
188
|
+
return rs
|
|
189
|
+
if raw:
|
|
190
|
+
_warn('检测到 fake-ip 网关(%s),自动改用公共 DNS' % ','.join(raw))
|
|
191
|
+
pub = healthy_resolvers()
|
|
192
|
+
if pub:
|
|
193
|
+
return pub
|
|
194
|
+
if raw:
|
|
195
|
+
_warn('公共 DNS 不可达:保留原 resolver(IP 不可信,子域存在性判定仍有效)')
|
|
196
|
+
return raw or list(FALLBACK_RESOLVERS)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _is_fakeip(ip):
|
|
200
|
+
"""fake-ip 段判定:RFC 2544 198.18.0.0/15(Clash/mihomo 默认)与 240.0.0.0/4"""
|
|
201
|
+
return ip.startswith('198.18.') or ip.startswith('198.19.') or ip.startswith('240.')
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
_PROBE_NAME = 'www.apple.com'
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _probe_resolver(srv, timeout=1.5):
|
|
208
|
+
"""单台 resolver 健康检查:能解析出非 fake 记录即算健康;返回 (srv, 耗时秒)"""
|
|
209
|
+
t0 = time.time()
|
|
210
|
+
try:
|
|
211
|
+
r = dns_query(_PROBE_NAME, [srv], timeout=timeout)
|
|
212
|
+
except Exception:
|
|
213
|
+
return None
|
|
214
|
+
if r and any(not _is_fakeip(ip) for ip in r):
|
|
215
|
+
return (srv, time.time() - t0)
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def healthy_resolvers(timeout=1.5, cache_ttl=300):
|
|
220
|
+
"""并发探测公共 resolver 并按延迟排序;结果缓存到系统临时目录(跨平台)。
|
|
221
|
+
注意:探测失败(如纯 fake-ip 无逃逸环境)返回 [],由调用方兜底。
|
|
222
|
+
"""
|
|
223
|
+
import json
|
|
224
|
+
import tempfile
|
|
225
|
+
path = os.path.join(tempfile.gettempdir(), 'fastrecon_dns_health.json')
|
|
226
|
+
try:
|
|
227
|
+
with open(path, encoding='utf-8') as f:
|
|
228
|
+
c = json.load(f)
|
|
229
|
+
if time.time() - c.get('ts', 0) < cache_ttl and c.get('ok'):
|
|
230
|
+
return list(c['ok'])
|
|
231
|
+
except Exception:
|
|
232
|
+
pass
|
|
233
|
+
cand = list(FALLBACK_RESOLVERS)
|
|
234
|
+
got = []
|
|
235
|
+
for x in _run_pool(cand, lambda s: _probe_resolver(s, timeout), len(cand)):
|
|
236
|
+
if x and isinstance(x[1], float):
|
|
237
|
+
got.append(x)
|
|
238
|
+
got.sort(key=lambda p: p[1])
|
|
239
|
+
ok = [s for s, _d in got]
|
|
240
|
+
if ok:
|
|
241
|
+
try:
|
|
242
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
243
|
+
json.dump({'ts': time.time(), 'ok': ok}, f)
|
|
244
|
+
except Exception:
|
|
245
|
+
pass
|
|
246
|
+
return ok
|
|
171
247
|
|
|
172
248
|
|
|
173
249
|
# ============ DNS ============
|
|
@@ -213,11 +289,47 @@ def _skip_name(d, o):
|
|
|
213
289
|
raise IndexError('too many labels')
|
|
214
290
|
|
|
215
291
|
|
|
216
|
-
def
|
|
217
|
-
"""
|
|
218
|
-
|
|
292
|
+
def _read_name(d, o):
|
|
293
|
+
"""读取域名(含压缩指针),返回 (name, 新偏移);结构异常抛 IndexError"""
|
|
294
|
+
parts = []
|
|
295
|
+
hops = 0
|
|
296
|
+
while True:
|
|
297
|
+
if o >= len(d):
|
|
298
|
+
raise IndexError('truncated name')
|
|
299
|
+
l = d[o]
|
|
300
|
+
if l == 0:
|
|
301
|
+
return ('.'.join(parts), o + 1)
|
|
302
|
+
if l & 0xC0 == 0xC0:
|
|
303
|
+
if o + 1 >= len(d):
|
|
304
|
+
raise IndexError('truncated pointer')
|
|
305
|
+
ptr = struct.unpack('>H', d[o:o + 2])[0] & 0x3FFF
|
|
306
|
+
hops += 1
|
|
307
|
+
if hops > 64: # 指针环防御
|
|
308
|
+
raise IndexError('pointer loop')
|
|
309
|
+
sub, _ = _read_name(d, ptr)
|
|
310
|
+
if sub:
|
|
311
|
+
parts.append(sub)
|
|
312
|
+
return ('.'.join(parts), o + 2)
|
|
313
|
+
if o + 1 + l > len(d) or l > 63:
|
|
314
|
+
raise IndexError('bad label')
|
|
315
|
+
raw = d[o + 1:o + 1 + l]
|
|
316
|
+
try:
|
|
317
|
+
parts.append(raw.decode('ascii'))
|
|
318
|
+
except UnicodeDecodeError:
|
|
319
|
+
parts.append(raw.decode('utf-8', 'replace'))
|
|
320
|
+
o += 1 + l
|
|
321
|
+
hops += 1
|
|
322
|
+
if hops > 128:
|
|
323
|
+
raise IndexError('too many labels')
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _parse_answers(data, qtype=1):
|
|
327
|
+
"""从应答 ANSWER 段提取指定类型记录;异常报文返回已解析部分。
|
|
328
|
+
qtype: 1=A 28=AAAA 2=NS 5=CNAME 15=MX 16=TXT,其余类型跳过。
|
|
329
|
+
"""
|
|
330
|
+
out = []
|
|
219
331
|
if len(data) < 12:
|
|
220
|
-
return
|
|
332
|
+
return out
|
|
221
333
|
ancount = struct.unpack('>H', data[6:8])[0]
|
|
222
334
|
o = 12
|
|
223
335
|
try:
|
|
@@ -230,12 +342,31 @@ def _parse_answers(data):
|
|
|
230
342
|
o += 10
|
|
231
343
|
if o + ln > len(data):
|
|
232
344
|
break
|
|
233
|
-
|
|
234
|
-
|
|
345
|
+
rd = data[o:o + ln]
|
|
346
|
+
if t == qtype and ln > 0:
|
|
347
|
+
if t == 1 and ln == 4:
|
|
348
|
+
out.append('.'.join(str(b) for b in rd))
|
|
349
|
+
elif t == 28 and ln == 16:
|
|
350
|
+
out.append(str(ipaddress.IPv6Address(rd)))
|
|
351
|
+
elif t in (2, 5): # NS / CNAME:单域名
|
|
352
|
+
nm, _ = _read_name(data, o)
|
|
353
|
+
out.append(nm)
|
|
354
|
+
elif t == 15 and ln > 3: # MX:优先级 + 域名
|
|
355
|
+
pref = struct.unpack('>H', rd[:2])[0]
|
|
356
|
+
nm, _ = _read_name(data, o + 2)
|
|
357
|
+
out.append('%d %s' % (pref, nm))
|
|
358
|
+
elif t == 16: # TXT:拼接全部文本串
|
|
359
|
+
txt, p = [], 0
|
|
360
|
+
while p < ln:
|
|
361
|
+
sl = rd[p]
|
|
362
|
+
p += 1
|
|
363
|
+
txt.append(rd[p:p + sl].decode('utf-8', 'replace'))
|
|
364
|
+
p += sl
|
|
365
|
+
out.append(''.join(txt))
|
|
235
366
|
o += ln
|
|
236
367
|
except (IndexError, struct.error):
|
|
237
368
|
pass
|
|
238
|
-
return
|
|
369
|
+
return out
|
|
239
370
|
|
|
240
371
|
|
|
241
372
|
def _recv_exact(s, want):
|
|
@@ -251,7 +382,7 @@ def _recv_exact(s, want):
|
|
|
251
382
|
return buf
|
|
252
383
|
|
|
253
384
|
|
|
254
|
-
def _dns_tcp_query(q, srv, timeout):
|
|
385
|
+
def _dns_tcp_query(q, srv, timeout, qtype=1):
|
|
255
386
|
"""UDP 应答被截断(TC 位)时的 TCP 重发;失败返回 [](调用方保留部分结果)"""
|
|
256
387
|
try:
|
|
257
388
|
with socket.create_connection((srv, 53), timeout=timeout) as s:
|
|
@@ -266,12 +397,12 @@ def _dns_tcp_query(q, srv, timeout):
|
|
|
266
397
|
body = _recv_exact(s, n)
|
|
267
398
|
if len(body) < 12:
|
|
268
399
|
return []
|
|
269
|
-
return _parse_answers(body)
|
|
400
|
+
return _parse_answers(body, qtype)
|
|
270
401
|
except OSError:
|
|
271
402
|
return []
|
|
272
403
|
|
|
273
404
|
|
|
274
|
-
def _dns_dot_query(q, srv, timeout):
|
|
405
|
+
def _dns_dot_query(q, srv, timeout, qtype=1):
|
|
275
406
|
"""DNS over TLS(RFC 7858):TCP 853 + TLS 包装,帧格式同 TCP。失败返回 []。"""
|
|
276
407
|
try:
|
|
277
408
|
import ssl
|
|
@@ -291,7 +422,7 @@ def _dns_dot_query(q, srv, timeout):
|
|
|
291
422
|
body = _recv_exact(s, n)
|
|
292
423
|
if len(body) < 12:
|
|
293
424
|
return []
|
|
294
|
-
return _parse_answers(body)
|
|
425
|
+
return _parse_answers(body, qtype)
|
|
295
426
|
except OSError:
|
|
296
427
|
return []
|
|
297
428
|
|
|
@@ -324,8 +455,8 @@ def _dns_exchange(sock, q, qidb, srv, timeout):
|
|
|
324
455
|
# ID 不匹配 → 丢弃并继续等(防串包)
|
|
325
456
|
|
|
326
457
|
|
|
327
|
-
def dns_query(name, servers, timeout=4.0, via="udp"):
|
|
328
|
-
"""
|
|
458
|
+
def dns_query(name, servers, timeout=4.0, via="udp", qtype=1):
|
|
459
|
+
"""查询指定类型记录(qtype: 1=A 28=AAAA 2=NS 5=CNAME 15=MX 16=TXT)。
|
|
329
460
|
|
|
330
461
|
返回 None:域名非法(未发查询);返回 []:查询失败或确无 A 记录。
|
|
331
462
|
"""
|
|
@@ -336,13 +467,13 @@ def dns_query(name, servers, timeout=4.0, via="udp"):
|
|
|
336
467
|
servers = [s for s in (servers or []) if _is_ipv4(s)] or list(FALLBACK_RESOLVERS)
|
|
337
468
|
qid = random.randint(0, 65535)
|
|
338
469
|
qidb = struct.pack('>H', qid)
|
|
339
|
-
head = struct.pack('>HHHHHH', qid, 0x0100, 1, 0, 0, 0) + qn + struct.pack('>HH',
|
|
470
|
+
head = struct.pack('>HHHHHH', qid, 0x0100, 1, 0, 0, 0) + qn + struct.pack('>HH', qtype, 1)
|
|
340
471
|
# EDNS0:通告 4096 字节 UDP 缓冲,显著降低 TC 截断概率
|
|
341
|
-
q_edns = (struct.pack('>HHHHHH', qid, 0x0100, 1, 0, 0, 1) + qn + struct.pack('>HH',
|
|
472
|
+
q_edns = (struct.pack('>HHHHHH', qid, 0x0100, 1, 0, 0, 1) + qn + struct.pack('>HH', qtype, 1)
|
|
342
473
|
+ b'\x00' + struct.pack('>HHIH', 41, DNS_UDP_BUF, 0, 0))
|
|
343
474
|
if via == 'dot':
|
|
344
475
|
for srv in servers:
|
|
345
|
-
r = _dns_dot_query(head, srv, timeout)
|
|
476
|
+
r = _dns_dot_query(head, srv, timeout, qtype)
|
|
346
477
|
if r:
|
|
347
478
|
return r
|
|
348
479
|
return []
|
|
@@ -356,9 +487,9 @@ def dns_query(name, servers, timeout=4.0, via="udp"):
|
|
|
356
487
|
rcode = data[3] & 0x0F
|
|
357
488
|
if rcode in (1, 4) and q is q_edns:
|
|
358
489
|
continue
|
|
359
|
-
ips = _parse_answers(data)
|
|
490
|
+
ips = _parse_answers(data, qtype)
|
|
360
491
|
if (data[2] & 0x02) and not ips: # TC 截断且没拿到 A → TCP
|
|
361
|
-
tcp = _dns_tcp_query(q, srv, timeout)
|
|
492
|
+
tcp = _dns_tcp_query(q, srv, timeout, qtype)
|
|
362
493
|
if tcp:
|
|
363
494
|
return tcp
|
|
364
495
|
if rcode in (0, 3) or ips: # NOERROR / NXDOMAIN / 有 A
|
|
@@ -368,7 +499,7 @@ def dns_query(name, servers, timeout=4.0, via="udp"):
|
|
|
368
499
|
sock.close()
|
|
369
500
|
if via == 'auto':
|
|
370
501
|
for srv in servers: # UDP 全挂 → DoT 853 兜底
|
|
371
|
-
r = _dns_dot_query(head, srv, timeout)
|
|
502
|
+
r = _dns_dot_query(head, srv, timeout, qtype)
|
|
372
503
|
if r:
|
|
373
504
|
return r
|
|
374
505
|
return []
|
|
@@ -387,7 +518,8 @@ def cache_load():
|
|
|
387
518
|
if len(parts) < 2:
|
|
388
519
|
continue
|
|
389
520
|
k, v = parts[0].strip(), parts[1].strip()
|
|
390
|
-
|
|
521
|
+
# 历史 fake-ip 缓存行作废(fake-ip 代理切换前后可能残留,见 _is_fakeip)
|
|
522
|
+
if k and v and _is_addr_list(v) and not all(_is_fakeip(x) for x in v.split(',')):
|
|
391
523
|
c[k] = v
|
|
392
524
|
except OSError:
|
|
393
525
|
pass
|
|
@@ -533,9 +665,13 @@ def cmd_dns(args):
|
|
|
533
665
|
if ips is None:
|
|
534
666
|
print(f'{n}\t-\t[非法域名]')
|
|
535
667
|
elif ips:
|
|
536
|
-
|
|
668
|
+
real = [x for x in ips if not _is_fakeip(x)]
|
|
669
|
+
if not real:
|
|
670
|
+
print(f'{n}\t[fake-ip 区结果:IP 不可信,存在性有效]')
|
|
671
|
+
continue
|
|
672
|
+
print(f'{n}\t{",".join(real)}')
|
|
537
673
|
k = keys[n]
|
|
538
|
-
val = ','.join(
|
|
674
|
+
val = ','.join(real)
|
|
539
675
|
# 同名(含大小写/尾部点差异)只写一行,避免缓存重复行堆积
|
|
540
676
|
if not cache.get(k) and k not in written:
|
|
541
677
|
written.add(k)
|
|
@@ -928,6 +1064,309 @@ def cmd_http(args):
|
|
|
928
1064
|
return 0
|
|
929
1065
|
|
|
930
1066
|
|
|
1067
|
+
# ============ 子域枚举 ============
|
|
1068
|
+
|
|
1069
|
+
SUB_WORDS = (
|
|
1070
|
+
'www www2 www3 mail webmail smtp pop imap ftp sftp ssh vpn rdp remote '
|
|
1071
|
+
'api api2 apidocs developer docs doc wiki blog news forum bbs shop store pay payment '
|
|
1072
|
+
'checkout cart admin admincp administrator manage manager mgmt console portal '
|
|
1073
|
+
'dashboard panel cp cpanel plesk cms content static assets cdn cache img images '
|
|
1074
|
+
'media video files download downloads upload uploads backup bak db database sql mysql '
|
|
1075
|
+
'postgres redis mongo es kibana grafana prometheus monitor status git gitlab svn '
|
|
1076
|
+
'jenkins ci cd build deploy staging dev development test testing uat qa demo beta '
|
|
1077
|
+
'sandbox lab intranet internal corp lan ns ns1 ns2 dns dns1 dns2 mx mx1 mx2 relay '
|
|
1078
|
+
'gateway gw proxy prx lb lb1 lb2 vip edge origin auth sso login oauth token session '
|
|
1079
|
+
'identity ad click track analytics stats stat log logs syslog elastic elk kafka mq '
|
|
1080
|
+
'queue rabbitmq notify push mailer crm erp hr oa bi report reports data bigdata app '
|
|
1081
|
+
'app1 app2 mobile m wap open openapi public private secure ssl ca pki cert certs '
|
|
1082
|
+
'k8s docker registry harbor nexus artifacts maven npm pypi phpmyadmin pma debug '
|
|
1083
|
+
'actuator solr search jupyter spark hadoop zabbix oa2 erp2 crm2 hr2 git2 svn2 old new'
|
|
1084
|
+
).split()
|
|
1085
|
+
|
|
1086
|
+
_SUB_LABEL_RE = re.compile(r'^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$')
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def _norm_domain(d):
|
|
1090
|
+
"""裸主域合法性检查;非法返回 None(不接受通配符前缀与路径)"""
|
|
1091
|
+
if not d:
|
|
1092
|
+
return None
|
|
1093
|
+
d = d.strip().rstrip('.').lower()
|
|
1094
|
+
if not d or '*' in d or '/' in d or len(d) > 253:
|
|
1095
|
+
return None
|
|
1096
|
+
labels = d.split('.')
|
|
1097
|
+
if len(labels) < 2:
|
|
1098
|
+
return None
|
|
1099
|
+
for lb in labels:
|
|
1100
|
+
if not _SUB_LABEL_RE.match(lb):
|
|
1101
|
+
return None
|
|
1102
|
+
return d
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
def _ct_log_names(domain, timeout=20):
|
|
1106
|
+
"""crt.sh 证书透明度日志(间歇 404/502 波动,退避重试);失败返回空集由其它源兜底。"""
|
|
1107
|
+
import json
|
|
1108
|
+
import urllib.request
|
|
1109
|
+
url = 'https://crt.sh/json?q=%25.' + domain
|
|
1110
|
+
data, last = None, None
|
|
1111
|
+
for attempt in range(3):
|
|
1112
|
+
try:
|
|
1113
|
+
req = urllib.request.Request(url, headers={'User-Agent': 'fastrecon-sub'})
|
|
1114
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
1115
|
+
data = json.loads(r.read().decode('utf-8', 'replace'))
|
|
1116
|
+
break
|
|
1117
|
+
except Exception as e:
|
|
1118
|
+
last = e
|
|
1119
|
+
time.sleep(0.6 * (attempt + 1))
|
|
1120
|
+
if data is None:
|
|
1121
|
+
_warn('crt.sh 暂不可用: %s' % str(last)[:60])
|
|
1122
|
+
return set()
|
|
1123
|
+
names = set()
|
|
1124
|
+
if isinstance(data, list):
|
|
1125
|
+
for row in data:
|
|
1126
|
+
nv = row.get('name_value') or ''
|
|
1127
|
+
for nm in str(nv).split('\n'):
|
|
1128
|
+
nm = nm.strip().lower().rstrip('.')
|
|
1129
|
+
if nm.startswith('*.'):
|
|
1130
|
+
nm = nm[2:]
|
|
1131
|
+
if (nm.endswith('.' + domain) or nm == domain) and _norm_domain(nm):
|
|
1132
|
+
names.add(nm)
|
|
1133
|
+
return names
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def _certspotter_names(domain, timeout=25):
|
|
1137
|
+
"""Cert Spotter CT 日志(免费 API,crt.sh 的可靠兜底源):返回名字集合。"""
|
|
1138
|
+
import json
|
|
1139
|
+
import urllib.request
|
|
1140
|
+
url = ('https://api.certspotter.com/v1/issuances?domain=' + domain
|
|
1141
|
+
+ '&include_subdomains=true&expand=dns_names')
|
|
1142
|
+
try:
|
|
1143
|
+
req = urllib.request.Request(url, headers={'User-Agent': 'fastrecon-sub'})
|
|
1144
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
1145
|
+
data = json.loads(r.read().decode('utf-8', 'replace'))
|
|
1146
|
+
except Exception:
|
|
1147
|
+
return set()
|
|
1148
|
+
names = set()
|
|
1149
|
+
if isinstance(data, list):
|
|
1150
|
+
for row in data:
|
|
1151
|
+
if not isinstance(row, dict):
|
|
1152
|
+
continue
|
|
1153
|
+
for nm in (row.get('dns_names') or []):
|
|
1154
|
+
nm = str(nm).strip().lower().rstrip('.')
|
|
1155
|
+
if nm.startswith('*.'):
|
|
1156
|
+
nm = nm[2:]
|
|
1157
|
+
if (nm.endswith('.' + domain) or nm == domain) and _norm_domain(nm):
|
|
1158
|
+
names.add(nm)
|
|
1159
|
+
return names
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def _hackertarget_subs(domain, timeout=15):
|
|
1163
|
+
"""hackertarget hostsearch(免费接口,有日限额,超限/异常返回空集)。"""
|
|
1164
|
+
import urllib.request
|
|
1165
|
+
try:
|
|
1166
|
+
req = urllib.request.Request(
|
|
1167
|
+
'https://api.hackertarget.com/hostsearch/?q=' + domain,
|
|
1168
|
+
headers={'User-Agent': 'fastrecon-sub'})
|
|
1169
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
1170
|
+
txt = r.read().decode('utf-8', 'replace')
|
|
1171
|
+
except Exception:
|
|
1172
|
+
return set()
|
|
1173
|
+
out = set()
|
|
1174
|
+
for line in txt.splitlines():
|
|
1175
|
+
nm = line.split(',')[0].strip().lower().rstrip('.')
|
|
1176
|
+
if nm.endswith('.' + domain) and _norm_domain(nm):
|
|
1177
|
+
out.add(nm)
|
|
1178
|
+
return out
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def _rapiddns_subs(domain, timeout=20):
|
|
1182
|
+
"""rapiddns 子域页(HTML 表格解析;被 WAF 拦时返回空集)。"""
|
|
1183
|
+
import urllib.request
|
|
1184
|
+
try:
|
|
1185
|
+
req = urllib.request.Request(
|
|
1186
|
+
'https://rapiddns.io/subdomain/%s?full=1' % domain,
|
|
1187
|
+
headers={'User-Agent': 'Mozilla/5.0'})
|
|
1188
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
1189
|
+
html = r.read().decode('utf-8', 'replace')
|
|
1190
|
+
except Exception:
|
|
1191
|
+
return set()
|
|
1192
|
+
pat = r'<td[^>]*>\s*([A-Za-z0-9._-]+\.' + re.escape(domain) + r')\s*</td>'
|
|
1193
|
+
return set(m.lower() for m in re.findall(pat, html) if _norm_domain(m))
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
def _sub_candidates(domain, words, use_ct=True, progress=False):
|
|
1197
|
+
"""组合候选集(含来源追踪);返回 (候选集合, {名字: 来源标签集合})。
|
|
1198
|
+
数据源:词典 + apex/www + 外部源聚合(crt.sh / certspotter / hackertarget / rapiddns 并发,失败静默)
|
|
1199
|
+
"""
|
|
1200
|
+
cand, srcmap = set(), {}
|
|
1201
|
+
|
|
1202
|
+
def add(name, src):
|
|
1203
|
+
cand.add(name)
|
|
1204
|
+
srcmap.setdefault(name, set()).add(src)
|
|
1205
|
+
|
|
1206
|
+
add(domain, 'apex')
|
|
1207
|
+
add('www.' + domain, 'dict')
|
|
1208
|
+
for w in words:
|
|
1209
|
+
w = w.strip().lower()
|
|
1210
|
+
if w and _SUB_LABEL_RE.match(w):
|
|
1211
|
+
add(w + '.' + domain, 'dict')
|
|
1212
|
+
if use_ct:
|
|
1213
|
+
if progress:
|
|
1214
|
+
_warn('聚合外部源(crt.sh / certspotter / hackertarget / rapiddns)...')
|
|
1215
|
+
srcs = (('crt.sh', _ct_log_names),
|
|
1216
|
+
('certspotter', _certspotter_names),
|
|
1217
|
+
('hackertarget', _hackertarget_subs),
|
|
1218
|
+
('rapiddns', _rapiddns_subs))
|
|
1219
|
+
|
|
1220
|
+
def one_src(item):
|
|
1221
|
+
label, fn = item
|
|
1222
|
+
try:
|
|
1223
|
+
return label, (fn(domain) or set())
|
|
1224
|
+
except Exception:
|
|
1225
|
+
return label, set()
|
|
1226
|
+
|
|
1227
|
+
for label, names in _run_pool(list(srcs), one_src, len(srcs)):
|
|
1228
|
+
if not isinstance(names, set):
|
|
1229
|
+
continue # _run_pool 异常占位行
|
|
1230
|
+
if progress and names:
|
|
1231
|
+
_warn('%s 贡献 %d 个名字' % (label, len(names)))
|
|
1232
|
+
for nm in names:
|
|
1233
|
+
add(nm, label)
|
|
1234
|
+
return cand, srcmap
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
def _rec_key(recs):
|
|
1238
|
+
"""结果归一化键:纯 fake-ip 结果统一为一类(fake 环境各域名分到不同假 IP,
|
|
1239
|
+
无法按具体 IP 比对泛解析噪声)"""
|
|
1240
|
+
if recs and all(_is_fakeip(ip) for ip in recs):
|
|
1241
|
+
return 'FAKE'
|
|
1242
|
+
return ','.join(recs)
|
|
1243
|
+
|
|
1244
|
+
|
|
1245
|
+
def cmd_sub(args):
|
|
1246
|
+
domain = _norm_domain(args.domain)
|
|
1247
|
+
if not domain:
|
|
1248
|
+
_err('主域非法: %r' % args.domain)
|
|
1249
|
+
return 2
|
|
1250
|
+
workers = _clamp_int(args.workers, 1, MAX_DNS_WORKERS, 50)
|
|
1251
|
+
timeout = _clamp_float(args.timeout, 0.2, 60.0, 3.0)
|
|
1252
|
+
servers = load_resolvers(args.resolver) or list(FALLBACK_RESOLVERS)
|
|
1253
|
+
words = list(SUB_WORDS)
|
|
1254
|
+
if args.wordlist:
|
|
1255
|
+
try:
|
|
1256
|
+
with open(args.wordlist, encoding='utf-8', errors='ignore') as f:
|
|
1257
|
+
words += [ln.strip() for ln in f]
|
|
1258
|
+
except OSError as e:
|
|
1259
|
+
_err('词典读取失败: %s' % e)
|
|
1260
|
+
return 2
|
|
1261
|
+
t0 = time.time()
|
|
1262
|
+
print('[*] 目标: %s' % domain, file=sys.stderr)
|
|
1263
|
+
|
|
1264
|
+
# 1. 通配符检测:随机子域 + 主域对照
|
|
1265
|
+
probe_w = 'ck%06x.%s' % (random.randint(0, 0xFFFFFF), domain)
|
|
1266
|
+
try:
|
|
1267
|
+
probe_wild = dns_query(probe_w, servers, timeout=timeout, via='udp') or []
|
|
1268
|
+
probe_apex = dns_query(domain, servers, timeout=timeout, via='udp') or []
|
|
1269
|
+
except Exception:
|
|
1270
|
+
probe_wild, probe_apex = [], []
|
|
1271
|
+
if probe_wild:
|
|
1272
|
+
pv = ','.join(ip for ip in probe_wild if not _is_fakeip(ip)) or '[fake-ip 段]'
|
|
1273
|
+
print('[!] 泛解析: *.%s → %s(默认过滤同记录噪声,--all 显示全部)'
|
|
1274
|
+
% (domain, pv), file=sys.stderr)
|
|
1275
|
+
|
|
1276
|
+
# 2. 候选生成 + 并发解析(逐条实时打印)
|
|
1277
|
+
cand_set, srcmap = _sub_candidates(domain, words, use_ct=not args.no_ct, progress=True)
|
|
1278
|
+
cand = sorted(cand_set)
|
|
1279
|
+
_warn('候选 %d 个,开始并发解析 ...' % len(cand))
|
|
1280
|
+
ok, done = [], [0]
|
|
1281
|
+
|
|
1282
|
+
def one(nm):
|
|
1283
|
+
try:
|
|
1284
|
+
recs = dns_query(nm, servers, timeout=timeout, via='udp')
|
|
1285
|
+
except Exception:
|
|
1286
|
+
recs = []
|
|
1287
|
+
done[0] += 1
|
|
1288
|
+
if done[0] % 50 == 0:
|
|
1289
|
+
_warn('进度 %d/%d' % (done[0], len(cand)))
|
|
1290
|
+
return nm, recs or []
|
|
1291
|
+
|
|
1292
|
+
pw, pa = _rec_key(probe_wild), _rec_key(probe_apex)
|
|
1293
|
+
for nm, recs in _run_pool(cand, one, workers):
|
|
1294
|
+
if not recs:
|
|
1295
|
+
continue
|
|
1296
|
+
if not args.all:
|
|
1297
|
+
if probe_wild and _rec_key(recs) == pw:
|
|
1298
|
+
continue # 与泛解析记录同类 → 噪声
|
|
1299
|
+
if probe_apex and _rec_key(recs) == pa and nm != domain:
|
|
1300
|
+
continue # 与主域同类 → 常见 CDN 噪声
|
|
1301
|
+
ok.append((nm, recs))
|
|
1302
|
+
real = [ip for ip in recs if not _is_fakeip(ip)]
|
|
1303
|
+
if real:
|
|
1304
|
+
if args.src:
|
|
1305
|
+
print('%s\t%s\t[%s]' % (nm, ','.join(real),
|
|
1306
|
+
','.join(sorted(srcmap.get(nm, ())))))
|
|
1307
|
+
else:
|
|
1308
|
+
print('%s\t%s' % (nm, ','.join(real)))
|
|
1309
|
+
else:
|
|
1310
|
+
print('%s\t[仅 fake-ip:IP 不可信,存在性有效]' % nm)
|
|
1311
|
+
xsrc = sum(1 for nm, _r in ok if len(srcmap.get(nm) or ()) > 1)
|
|
1312
|
+
print('[完成] %s: 存活 %d(多源交叉 %d)/ 候选 %d / %.1fs'
|
|
1313
|
+
% (domain, len(ok), xsrc, len(cand), time.time() - t0), file=sys.stderr)
|
|
1314
|
+
return 0
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
# ============ 多域名 DNS 情报 ============
|
|
1318
|
+
|
|
1319
|
+
_QTYPES = ((1, 'A'), (28, 'AAAA'), (5, 'CNAME'), (2, 'NS'), (15, 'MX'), (16, 'TXT'))
|
|
1320
|
+
_TYPE_BY_NAME = {n: q for q, n in _QTYPES}
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
def _multi_get(domain, types, servers, timeout):
|
|
1324
|
+
out = {}
|
|
1325
|
+
for q, label in types:
|
|
1326
|
+
try:
|
|
1327
|
+
r = dns_query(domain, servers, timeout=timeout, via='udp', qtype=q)
|
|
1328
|
+
except Exception:
|
|
1329
|
+
r = []
|
|
1330
|
+
if q == 1: # A 记录:剔除 fake-ip 段
|
|
1331
|
+
r = [ip for ip in r if not _is_fakeip(ip)]
|
|
1332
|
+
if r:
|
|
1333
|
+
out[label] = r
|
|
1334
|
+
return out
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
def cmd_multi(args):
|
|
1338
|
+
domains = []
|
|
1339
|
+
for d in read_targets(args.target):
|
|
1340
|
+
nd = _norm_domain(d)
|
|
1341
|
+
if nd:
|
|
1342
|
+
domains.append(nd)
|
|
1343
|
+
else:
|
|
1344
|
+
_warn('跳过非法域名: %r' % d)
|
|
1345
|
+
if not domains:
|
|
1346
|
+
_err('未提供有效域名')
|
|
1347
|
+
return 2
|
|
1348
|
+
servers = load_resolvers(args.resolver) or list(FALLBACK_RESOLVERS)
|
|
1349
|
+
timeout = _clamp_float(args.timeout, 0.2, 60.0, 4.0)
|
|
1350
|
+
types = list(_QTYPES)
|
|
1351
|
+
if args.types:
|
|
1352
|
+
pick = [(_TYPE_BY_NAME[t], t) for t in
|
|
1353
|
+
(x.strip().upper() for x in args.types.split(',')) if t in _TYPE_BY_NAME]
|
|
1354
|
+
if pick:
|
|
1355
|
+
types = pick
|
|
1356
|
+
t0 = time.time()
|
|
1357
|
+
for d in domains:
|
|
1358
|
+
print('=== %s ===' % d)
|
|
1359
|
+
rec = _multi_get(d, types, servers, timeout)
|
|
1360
|
+
if not rec:
|
|
1361
|
+
print(' (无记录)')
|
|
1362
|
+
continue
|
|
1363
|
+
for _q, label in types:
|
|
1364
|
+
for v in rec.get(label, []):
|
|
1365
|
+
print(' %-5s %s' % (label, v))
|
|
1366
|
+
print('[完成] %d 域名 / %.1fs' % (len(domains), time.time() - t0), file=sys.stderr)
|
|
1367
|
+
return 0
|
|
1368
|
+
|
|
1369
|
+
|
|
931
1370
|
# ============ 入口 ============
|
|
932
1371
|
|
|
933
1372
|
def main():
|
|
@@ -959,11 +1398,29 @@ def main():
|
|
|
959
1398
|
h.add_argument('-t', '--timeout', type=int, default=8)
|
|
960
1399
|
h.add_argument('--code', action='store_true', help='只输出 URL 和状态码')
|
|
961
1400
|
|
|
1401
|
+
u = sub.add_parser('sub', help='子域枚举(词典爆破 + CT 日志 + 通配符过滤)')
|
|
1402
|
+
u.add_argument('domain', help='主域(example.com)')
|
|
1403
|
+
u.add_argument('-w', '--wordlist', help='追加自定义词典(每行一个前缀)')
|
|
1404
|
+
u.add_argument('--no-ct', action='store_true', help='跳过外部数据源(纯爆破快速模式)')
|
|
1405
|
+
u.add_argument('--all', action='store_true', help='显示全部(含泛解析噪声)')
|
|
1406
|
+
u.add_argument('--src', action='store_true', help='输出行附加来源标注(多源交叉)')
|
|
1407
|
+
u.add_argument('--resolver', help='指定 resolver(默认自动探测)')
|
|
1408
|
+
u.add_argument('-j', '--workers', type=int, default=platf.env()['dns_workers'],
|
|
1409
|
+
help='并发数(按平台自动适配)')
|
|
1410
|
+
u.add_argument('-t', '--timeout', type=float, default=3, help='单查询超时(默认 3s)')
|
|
1411
|
+
|
|
1412
|
+
m = sub.add_parser('multi', help='多域名情报(A/AAAA/CNAME/NS/MX/TXT 汇总)')
|
|
1413
|
+
m.add_argument('target', help='域名 / 逗号列表 / 文件')
|
|
1414
|
+
m.add_argument('--types', help='类型子集,如 A,MX,TXT(默认全部)')
|
|
1415
|
+
m.add_argument('--resolver', help='指定 resolver(默认自动探测)')
|
|
1416
|
+
m.add_argument('-t', '--timeout', type=float, default=4, help='单查询超时(默认 4s)')
|
|
1417
|
+
|
|
962
1418
|
args = ap.parse_args()
|
|
963
1419
|
if not args.cmd:
|
|
964
1420
|
ap.print_help()
|
|
965
1421
|
return 1
|
|
966
|
-
return {'dns': cmd_dns, 'scan': cmd_scan, 'http': cmd_http
|
|
1422
|
+
return {'dns': cmd_dns, 'scan': cmd_scan, 'http': cmd_http,
|
|
1423
|
+
'sub': cmd_sub, 'multi': cmd_multi}[args.cmd](args)
|
|
967
1424
|
|
|
968
1425
|
|
|
969
1426
|
if __name__ == '__main__':
|