theHarvester 虚拟主机发现(Virtual Host Discovery)完整实战指南:基于 Host 头与 SNI 的有界安全扫描
【免费下载链接】theHarvesterE-mails, subdomains and names Harvester - OSINT项目地址: https://gitcode.com/GitHub_Trending/th/theHarvester
导读
虚拟主机发现(Virtual Host Discovery)是 theHarvester 中的一项 P2 级主动探测能力:一台 Web 服务器可以在同一个 IP 地址上托管多个站点,服务器根据 HTTPHost头(HTTPS 下还依据 TLS 的 Server Name Indication,即 SNI)决定返回哪个站点。本指南讲解 theHarvester 如何将已授权的目标主机名作为候选(candidate),对收割到的字面 IP 端点发起探测,通过与默认/通配响应做差异分类来发现真实存在的虚拟主机,并完整覆盖 CLI 用法、请求限额、分类器原理、JSONL/SQLite 证据模型与常见排障。读完本文,你将能够安全、有界地运行虚拟主机发现扫描,并正确解读其结果与证据。本文对应仓库文档 docs/wiki/Virtual-Host-Discovery.md。
⚠️ 授权边界:虚拟主机发现会向收割到的 IP 地址(或操作者指定的单个字面 IP 端点)直接发送 HTTP/HTTPS 请求,属于 P2 直接交互。仅在目标、地址、端口与探测技术均处于评估授权范围内时使用。使用前请阅读 Responsible Use and Scope 与 Operator Workflows。
虚拟主机发现原理与探测边界
当多个站点共享一个 IP 时,服务器依据请求中的Host头(以及 HTTPS 下的 TLS SNI)选择返回哪个虚拟主机。theHarvester 的虚拟主机发现不做 DNS 解析、不基于字典暴力枚举,而是基于已收割候选的差异测试(harvested-candidate testing):它检查一个范围内的主机名是否会产生与该端点默认(default)或通配(wildcard)响应不同的响应。
从实现上看,整个功能封装在 theHarvester/lib/virtual_host.py 中,其模块 docstring 开宗明义:Probe authorized IP endpoints for virtual hosts without resolving candidate names——候选名称永远不会被解析,每个候选必须是目标主机名本身或其子域。范围边界是精确的:对www.example.com的授权并不自动覆盖admin.example.com。
一次扫描分为两个阶段,先由收割源(provider)收集证据,再对目标端点发起探测,如下面两幅架构图所示:
运行一次有界扫描
最小命令:收割后直接扫描
网络活动:先是面向 provider 的发现请求,随后是对收割到的 IP 端点发起的目标侧请求。
AUTHORIZED_DOMAIN='replace-with-a-domain-you-control' uv run theHarvester \ -d "$AUTHORIZED_DOMAIN" \ -b rapiddns \ --vhost \ -f report这里选择rapiddns是因为它能同时返回主机名与字面 IP 地址。如果你选用的源只返回主机名,就需要启用一个能贡献 IP 证据的 DNS 动作,或显式提供--vhost-endpoint。
补充已授权的候选名
重复使用--vhost-candidate可以追加那些已授权、但所选源没有返回的名称:
uv run theHarvester \ -d "$AUTHORIZED_DOMAIN" \ -b rapiddns \ --vhost \ --vhost-candidate "admin.${AUTHORIZED_DOMAIN}" \ --vhost-candidate "preview.${AUTHORIZED_DOMAIN}"传输与 TLS 行为
虚拟主机发现使用直连传输,明确拒绝--proxies。在 theHarvester/main.py 中可以看到,只要虚拟主机功能被启用而同时又传入了代理参数,会直接抛出ValueError('virtual-host discovery supports direct transport only; do not use --proxies')。
HTTPS 证书校验默认开启;--vhost-insecure会关闭校验并在证据中记录tls_verified: false。仅在评估确实需要未校验 TLS 且端点已授权时才使用它。
探测对象:什么会被纳入扫描
--vhost使用本次运行已收集的证据:
- 字面 IP 地址成为端点:扫描先在整组地址上尝试 HTTPS 443 端口,之后才尝试 HTTP 80 端口。在源码 virtual_host.py 的
_harvested_endpoints()中,地址会先按(address.version, int(address))排序去重,再生成https://ip:443/优先、http://ip:80/次之的端点序列。 - 精确落在目标边界内的已收割主机名成为候选。
- DNS 暴力枚举得到的 IP 与名称也会被纳入,因为虚拟主机发现在 DNS 暴力枚举之后运行(见 Operator Workflows)。
- 反向 DNS(reverse DNS)发现的范围内名称成为候选;但反向 DNS 的
/24网段不会成为一组新的虚拟主机端点。
这是“对已收割候选做差异测试”,不是基于字典的虚拟主机暴力枚举。重复--vhost-candidate只是补充一份小的已授权列表,弥补所选源未返回的名称。
对一次常规收割运行来说,--vhost是唯一需要的虚拟主机选项。请求数、运行时、超时与并发等选项属于高级安全覆盖项,省略时自动套用有界默认值(见下文“请求与运行时限制”)。
共享的请求上限可能在扫描覆盖全部端点之前就将其停止。此时vhost动作状态为partial,停止原因为request-limit,扫描不会声称覆盖完整。
显式端点替换收割端点
网络活动:仅对该字面 IP 端点发起目标侧请求。
AUTHORIZED_DOMAIN='replace-with-a-domain-you-control' AUTHORIZED_IP='replace-with-an-authorized-ip' uv run theHarvester \ -d "$AUTHORIZED_DOMAIN" \ --vhost-endpoint "https://${AUTHORIZED_IP}:443/" \ --vhost-candidate "admin.${AUTHORIZED_DOMAIN}" \ -f report端点规范非常严格。源码 normalize_virtual_host_endpoint() 的校验规则如下:
| 约束 | 说明 |
|---|---|
| scheme | 必须是http或https |
| 地址形式 | 必须是字面 IPv4 或 IPv6 地址(ipaddress.ip_address可解析) |
| 端口 | 可选,范围为 1–65535;省略时 https 默认 443、http 默认 80 |
| 路径/查询/片段 | 只允许空路径或/,不允许 query 与 fragment |
| 用户信息 | 不允许包含用户名/密码(userinfo) |
| 主机名/CIDR | 一律拒绝(主机名端点与 CIDR 网段不合法) |
规范化后的端点为scheme://[压缩地址]:port/形式(IPv6 用方括号包裹)。
候选名的范围校验在 normalize_virtual_host_candidates():每个候选要么等于范围(scope),要么以其为后缀(.scope),否则抛出ValueError('virtual-host candidate is outside authorized scope: ...')。同时候选名永远不会被本功能解析——它们只作为 HTTPHost头与 HTTPS SNI 发送。
分类器如何工作
对每个端点,theHarvester 会发送:
- 1 次字面 IP 上下文请求(context,不带 Host 覆盖,直接访问端点);
- 至少 3 次合成的未知主机控制请求(controls),且控制名的标签形状与所测候选一致。
形状匹配很关键:源码中的_candidate_shape()会计算候选相对 scope 的各标签长度元组(如admin.example.com相对example.com为(5,),a.b.example.com为(1,1)),而_new_control_names()用a-z0-9的 base36 字母表、以secrets.randbelow随机起点生成同形状的未知名称(如x7.example.com)。这样做的原因是:有些服务器的通配行为会随标签深度变化,只有同形状的未知主机控制请求才是有效的基线对照。源码还强制要求每个形状至少保留 3 个可用未知控制名(36 ** sum(shape) - candidate_count < VHOST_CONTROL_COUNT时直接拒绝),并在请求数不足以覆盖“1 次上下文 + 每形状 3 次控制”时抛错。
请求行为(见 virtual_host.py 的_probe()):
- HTTPS 候选与控制请求在TLS SNI 与 HTTP
Host头中发送相同的主机名;HTTP 请求只在Host头中发送主机名。 - 使用
aiohttpGET 请求,allow_redirects=False——重定向会被记录为证据但不会跟随。 - 单次请求由
timeout_seconds(默认 5 秒)限制;响应体最多读取VHOST_BODY_LIMIT = 1 MiB(1024 × 1024 字节),超出即标记body_truncated。 - 探测阶段(phase)分为
connect、tls、headers、body四级,记录请求在哪个阶段失败或成功。
扫描首先把收割到的证据与操作者的覆盖项合并成一个有界的可比请求集合(对应上文的vhost-sweep-overview.svg),随后响应集合进入一个独立分类器(对应vhost-classifier.svg)。
反射归一化(Reflection Normalization)
在比较之前,分类器会把响应体或Location头中对当前 authority 的精确反射替换为{authority}占位符。源码_replace_authority()用正则匹配独立的 authority 串(前后不允许再有主机名字符)并做大小写不敏感替换,随后基于归一化后的内容计算body_sha256与body_size。这样做的目的是:一个仅仅重复了所请求主机名的通用错误页不应被误判为发现。是否发生替换会记录在证据的reflection_normalized字段中。
可比较的信号
归一化之后,比较可以使用以下信号(对应_FINGERPRINT_SIGNALS):
| 信号 | 含义 |
|---|---|
status | HTTP 状态码与两条基线均不同 |
location | 归一化后的重定向位置不同 |
body_size | 有界响应体大小不同(仅当双方都未被截断时才比较) |
body_sha256 | 有界响应体内容不同(仅当双方都未被截断时才比较) |
注意:_fingerprint_signals()只有在候选与基线的响应体都未截断时才会比较body_size/body_sha256;一旦任一侧被截断,正文信号就不参与判定。
三种分类结果
分类器最终返回三种状态之一(见 virtual_host.py 的_classify_fingerprints()):
| 分类 | 含义 |
|---|---|
default | 候选与字面 IP 上下文响应或某个稳定的未知主机控制响应一致 |
distinct | 候选与两条基线都不同;纯响应体差异需要第二次匹配的候选响应(确认请求) |
indeterminate | 基线不可用、控制请求彼此不一致、候选只匹配一条基线、正文证据不完整,或所需确认未重复出现 |
分类器要求 3 次控制响应完全一致且可用(len(set(controls)) == 1),否则基线不稳定,直接判为indeterminate。若候选同时不等于控制与上下文基线,则取差集信号作为distinct_signals;当差异只体现在正文信号(body_size/body_sha256)时,需要发起确认请求(用同一候选名再次探测)并得到一致正文后才升级为distinct,否则保持indeterminate并标记needs_confirmation。另外,若候选恰好是 scope 本身(apex 域),_classify_candidate()会强制判为indeterminate——apex 作为边界名称不参与发现。
只有确认的distinct观察才会被保留在完成结果中。default与indeterminate响应同样消耗预算,但不会被报告为发现。
请求与运行时限制
默认限额如下(与源码DEFAULT_VHOST_*常量一致,见 virtual_host.py 与main.py 的 argparse 定义):
| 控制项 | CLI 选项 | 默认值 |
|---|---|---|
| 整个扫描的请求总数 | --vhost-request-limit | 100 |
| 整个扫描的运行时 | --vhost-runtime-seconds | 30 秒 |
| 单次请求超时 | --vhost-timeout-seconds | 5 秒 |
| 并发候选请求数 | --vhost-concurrency | 5 |
请求上限覆盖:上下文请求、未知控制请求、候选请求以及任何确认请求。未使用的请求与某个较早端点省下的时间会顺延给后续端点。较低的上限偏向广度:在开始 HTTP 之前,先在所有收割 IP 上尝试 HTTPS。
限额的校验也相当严格(VirtualHostLimits.__post_init__):
request_limit必须为整数且大于基线请求数(VHOST_BASELINE_REQUEST_COUNT = 1 + 3 = 4),否则抛错;runtime_seconds/timeout_seconds必须为正且有限(math.isfinite);concurrency必须为正整数。
预算分配逻辑(discover_harvested_virtual_hosts())会按剩余端点数均分剩余请求与剩余时间(endpoint_request_limit = remaining_requests // endpoints_left、endpoint_runtime = remaining_runtime / endpoints_left),并用_candidates_for_budget()为每个端点挑选能在预算内完成的候选子集,放不下的候选即被截断并标记。此外maximum_endpoint_count = request_limit // (VHOST_BASELINE_REQUEST_COUNT + 1),端点过多时也会被截断——这些都会导向request-limit停止原因。
行动状态(action_executions 中的 vhost 条目)
vhost条目在action_executions中记录以下状态之一(对应main.py 的状态映射):
| 行动状态 | 含义 |
|---|---|
completed | 每个选定端点与候选都在限额内完成 |
partial | 请求或运行时限制停止了覆盖,或确认了主机名后发生了请求/扫描/取消错误 |
skipped | 没有可用的候选名或字面 IP 端点 |
failed | 请求、扫描或取消错误在保留确认主机名之前就结束了行动 |
停止原因(stop_reason)
stop_reason更细致地说明结果:
| 停止原因 | 含义 |
|---|---|
completed | 每个选定端点与候选都在限额内完成 |
no-candidates | 运行中没有范围内主机名可测试 |
no-endpoints | 运行中没有收割到的字面 IP,也没有端点覆盖项 |
request-limit | 上限导致省略了端点或候选,或在确认完成前停止 |
request-errors | 每个候选都尝试过,但一个或多个请求失败 |
runtime-limit | 共享的墙钟截止时间到期;已完成的部分证据被保留 |
scan-error | 意外的探测错误停止了行动 |
cancelled | 操作者取消了运行 |
停止原因的优先级在源码中一目了然:runtime-limit>scan-error>request-limit>request-errors> 其余子结果取非completed者。操作者发起的取消与运行时限额不同:取消会通过 worker 生命周期传播并关闭活动连接,同时在 virtual_host.py 的VirtualHostDiscoveryCancelled中携带已完成的部分证据(_preserve_partial_on_cancel=True),以便保留已确认的发现。
阅读终端输出
扫描结束后,汇总行报告确认的端点观察与覆盖情况:
[*] Virtual hosts: confirmed=1; candidate-endpoints=18/48; endpoints=4/8; requests=100 [!] Coverage stopped at the request limit; raise --vhost-request-limit or narrow the scan. admin.authorized.example at https://192.0.2.10:443/: status=401; signals=status- candidate-endpoint是“一个主机名对一个 IP 端点”的测试对数量。用配对计数可以避免“某个主机名在一个 IP 上测过”被误读为“在其它所有 IP 上都测过”。
- 当覆盖提前停止时,
[!]行会说明到达的是哪个限额以及由哪个选项控制(对应main.py 的stop_hint映射)。 - 每个确认观察单独一行:
主机名 at 端点: status=...; signals=...,其中signals为该发现命中的差异信号列表。
HarvestView 与 REST API
HarvestView Web 界面
HarvestView 暴露一个“虚拟主机发现”复选框,以及可选的端点与候选输入框。高级安全控制项包含请求数、运行时、超时、并发与证书校验的覆盖字段。端点留空则使用收割到的 IP。相关实现见 theHarvester/lib/api/static/harvestview/app.js 与 theHarvester/lib/api/harvestview.py。
REST API
同一个运行也可以提交到POST /api/v1/runs:
{ "target": "authorized.example", "sources": ["rapiddns"], "vhost": true }网络活动:API 请求本身是本地调用,但入队的运行会执行面向 provider 的发现以及面向目标的虚拟主机请求。
仅当收割证据无法提供端点或候选时,才需要追加vhost_endpoint或vhost_candidates:
{ "target": "authorized.example", "sources": [], "vhost_endpoint": "https://192.0.2.10:443/", "vhost_candidates": ["admin.authorized.example"] }请求数、运行时、超时、并发与不安全 TLS 字段覆盖上文相同的默认限额(字段名为vhost_request_limit、vhost_runtime_seconds、vhost_timeout_seconds、vhost_concurrency、vhost_insecure,见 tests/e2e/test_harvestview.py)。
行为要点:
- 提供
vhost_endpoint或vhost_candidates之一即可启用行动,即使省略了vhost字段;这与 CLI 的vhost_enabled = args.vhost or bool(args.vhost_endpoint) or bool(args.vhost_candidates)逻辑一致(main.py)。 sources: []的运行必须同时提供端点和至少一个候选;否则缺失的一侧必须来自收割结果。- API 在入队前拒绝:代理使用、主机名端点、超出范围的候选、以及 IP 目标。完整 API 约定见 REST API。
一个发现对应多个端点观察
JSONL 输出
JSONL 是未版本化的。汇总行之后,它为每个确认的主机名存储一条规范发现:常规value字段存放主机名,actions记录vhost来源,而observations是原生数组(而不是藏在字符串里的 JSON)。候选名是作为 HTTPHost值与 HTTPS SNI 发送的,此功能从不解析它们。
如果某个主机名在两个端点上都是distinct的,两条端点记录都会留在同一条主机名发现下:
完整 JSONL 发现示例
{ "type": "hostname", "value": "admin.authorized.example", "sources": [], "actions": ["vhost"], "observations": [ { "endpoint": "https://192.0.2.10:443/", "http_host": "admin.authorized.example", "tls_server_name": "admin.authorized.example", "classification": "distinct", "phase": "body", "status": 401, "location": null, "body_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "body_size": 123, "body_truncated": false, "context_phase": "body", "context_status": 200, "context_location": null, "context_body_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "context_body_size": 123, "context_body_truncated": false, "control_phase": "body", "control_status": 200, "control_location": null, "control_body_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "control_body_size": 123, "control_body_truncated": false, "confirmation_body_sha256": null, "tls_verified": true, "distinct_signals": ["status"], "reflection_normalized": false }, { "endpoint": "https://192.0.2.11:443/", "http_host": "admin.authorized.example", "tls_server_name": "admin.authorized.example", "classification": "distinct", "phase": "body", "status": 403, "location": null, "body_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "body_size": 87, "body_truncated": false, "context_phase": "body", "context_status": 404, "context_location": null, "context_body_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "context_body_size": 87, "context_body_truncated": false, "control_phase": "body", "control_status": 404, "control_location": null, "control_body_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "control_body_size": 87, "control_body_truncated": false, "confirmation_body_sha256": null, "tls_verified": true, "distinct_signals": ["status"], "reflection_normalized": false } ] }字段含义:
context_*字段是字面 IP 上下文响应,control_*是稳定的未知主机控制响应。一个 distinct 候选必须与两者都不同。confirmation_body_sha256仅在重复响应确认了纯正文差异时才出现。- 这些字段让审查者无需保留每一次合成控制请求即可核验记录的
distinct_signals。 sources列表为空,因为虚拟主机发现是行动(action),不是被动的信息源(source)。
SQLite 存储
SQLite 使用相同的形状而不引入新的证据概念:results表存一条hostname行,result_origins将其关联到vhost执行记录,该结果的 details 中存放端点观察数组。JSONL 导出与 API 运行详情把数组作为原生 JSON 暴露。vhost是行动名,不是结果类型(result kind)。
兼容报告的限制
JSON 与 XML 兼容报告只包含虚拟主机名列表,不含端点或响应证据。自动化需要结构化观察时,请使用 JSONL 或GET /api/v1/runs/{run_id}。更完整的本地数据说明见 Results and Local Data。
排障
运行提示no-candidates
选择能返回主机名的源,或用--vhost-candidate添加一个已授权名称。如果目标本身是www.example.com这样的主机名,请检查精确的目标边界——此时example.com的其它子域并不在候选范围内。
运行提示no-endpoints
选择能返回 IP 地址的源、启用贡献字面 IP 证据的 DNS 动作,或提供--vhost-endpoint。注意反向 DNS 的/24范围不会自动成为端点集合。
运行提示request-limit
上限对全部端点、候选形状和确认请求来说太小了。在提高上限之前,先收窄源集合或候选列表。终端汇总中的attempted/total会显示已尝试的端点比例,帮助判断收窄方向。
某个候选没有出现在结果中
只有确认的distinct观察会被保留。候选可能:匹配了默认响应、产生了不稳定的控制响应、传输失败、返回了被截断的正文、或未通过纯正文差异的确认。
HTTPS 结果全是indeterminate
证书校验或 TLS 协商可能在服务器返回 HTTP 证据之前就失败了。请检查证书对该候选 SNI 是否有效。如果评估允许未校验 TLS,可以用--vhost-insecure重跑这个有界测试,并把记录的校验状态与结果一起保留。
相关文档
- Responsible Use and Scope
- Operator Workflows
- Results and Local Data
- REST API
- Troubleshooting
【免费下载链接】theHarvesterE-mails, subdomains and names Harvester - OSINT项目地址: https://gitcode.com/GitHub_Trending/th/theHarvester
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考