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.
@@ -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 列表;只接受 IPv4 字面量(UDP 查询走 AF_INET),去重保序"""
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) and a not in rs:
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
- return rs or list(FALLBACK_RESOLVERS)
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 _parse_answers(data):
217
- """从应答中提取 ANSWER 段的 A 记录(其余类型跳过);异常报文返回已解析部分"""
218
- ips = []
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 ips
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
- if t == 1 and ln == 4:
234
- ips.append('.'.join(str(b) for b in data[o:o + 4]))
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 ips
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
- """查询 A 记录。
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', 1, 1)
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', 1, 1)
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
- if k and v and _is_addr_list(v):
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
- print(f'{n}\t{",".join(ips)}')
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(ips)
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}[args.cmd](args)
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__':