在威胁情报团队扫描任何目标之前,它可以从其他方已经保存的记录中了解到一个域名的大量信息。证书日志列出了它的主机名,注册数据给出了它的日期和域名服务器,而网络存档则显示了它过去发布过的内容。对安全团队来说,这一被动阶段是整个工作中成本最低、最不引人注意的部分,而且它所展示的内容任何攻击者同样能够看到。
本指南涵盖了主要的被动来源、一个经过测试的小型采集工具,以及”被动”不再成立的边界所在。请在你自己的组织内使用它,或者在经过授权的范围内使用。
被动的含义
被动收集意味着你的请求发往已经持有相关数据的第三方,而从不发往目标自身的服务器。MITRE ATT&CK 在”侦察”(Reconnaissance)战术下描述了攻击者的这一做法,包括搜索”可自由获取的技术数据库”(T1596),其中列出了 DNS 与被动 DNS、WHOIS、数字证书、CDN 以及扫描数据库等条目。
MITRE 也解释了防御者为何应该关注这一点。它指出,这类活动可以发生在目标组织的可见范围之外,因此难以检测,也无法通过预防性控制措施轻易缓解。你无法阻止有人去读取一份公开的日志。你能做的,是先一步去查看,并减少这些日志所暴露的信息。
值得检查的来源
| 来源 | 能提供什么 | 局限性 |
|---|---|---|
| 证书透明度日志 | 已签发 TLS 证书上的主机名 | 只显示名称,不表明是否在用;通配符会隐藏子域名 |
| RDAP | 注册商、日期、域名服务器 | 个人数据通常会被遮蔽 |
| 网络存档 | 旧页面、URL 和主机 | 仅限于被抓取过的内容,往往已是多年前的 |
| 被动 DNS | 一个名称曾解析到哪些 IP,以及解析时间 | 多为商业服务,覆盖范围各不相同 |
| 互联网扫描数据库 | 其他人观测到的开放端口与服务标语 | 属于快照,可能已经过时 |
证书透明度是最丰富的免费来源。RFC 9162 描述了一种协议,用于”公开记录”TLS 证书的存在,以便任何人都能审计证书颁发机构,而且日志是只能追加、不可删改的。这也意味着,即便 staging.internal-tool.example.com 这样的主机只存在了一周,它的证书记录依然会保持可见。
RDAP 是 WHOIS 的结构化替代方案。ICANN 表示,自 2025 年 1 月 28 日起,通用顶级域的注册局和注册商必须提供 RDAP,而不再被要求提供 WHOIS,因此新的工具应当从这里开始。
我们关于什么是 OSINT 以及其法律边界所在的指南涵盖了这一更广泛的学科,包括与人相关的来源,而本指南不涉及这些内容。
一个小型被动采集工具
下面的脚本会向三个第三方服务询问有关某个域名的信息,并且从不连接该域名本身。它运行于 Python 3.9 及以上版本,只需要 requests 库。当某个来源不可用时它会继续运行,因为免费的公共服务经常会出问题:在测试本文内容期间,crt.sh 和 Wayback Machine 都曾连续数分钟返回错误和超时。
from __future__ import annotations
import sys
import time
import requests
TIMEOUT = (10, 60)
HEADERS = {"User-Agent": "passive-recon-demo/1.0"}
def get_json(url, params=None):
"""Every request goes to a third-party source, never to the target itself."""
for attempt in range(3):
try:
r = requests.get(url, params=params, headers=HEADERS, timeout=TIMEOUT)
r.raise_for_status()
return r.json()
except requests.RequestException:
if attempt == 2:
raise
time.sleep(10 * (attempt + 1)) # free public services go down often
def in_scope(host, domain):
"""The domain or a real subdomain of it; CT names also hold labels and lookalikes."""
return " " not in host and (host == domain or host.endswith("." + domain))
def certificate_names(domain):
"""Hostnames from Certificate Transparency logs, through crt.sh."""
rows = get_json("https://crt.sh/", {"q": f"%.{domain}", "output": "json"})
names = {n.strip().lower() for row in rows for n in row["name_value"].splitlines()}
return sorted(n for n in names if "*" not in n and in_scope(n, domain))
def archived_hosts(domain, limit=500):
"""Hostnames the Wayback Machine has captured, without loading any live page."""
rows = get_json("https://web.archive.org/cdx/search/cdx", {
"url": domain, "matchType": "domain", "output": "json",
"fl": "original", "limit": limit,
})
urls = [row[0] for row in rows[1:]] # the first row is the header
hosts = {u.split("//")[-1].split("/")[0].split(":")[0].lower() for u in urls}
return sorted(h for h in hosts if in_scope(h, domain))
def registration(domain):
"""Registrar and key dates from RDAP, the structured successor to WHOIS."""
data = get_json(f"https://rdap.org/domain/{domain}")
dates = {e["eventAction"]: e["eventDate"][:10] for e in data.get("events", [])}
return {"registered": dates.get("registration"), "expires": dates.get("expiration"),
"nameservers": sorted(n["ldhName"].lower() for n in data.get("nameservers", []))}
def collect(domain):
sources = {"certificates": certificate_names, "archive": archived_hosts,
"rdap": registration}
found, failed = {}, {}
for name, fetch in sources.items():
try:
found[name] = fetch(domain)
except (requests.RequestException, ValueError, KeyError) as exc:
failed[name] = type(exc).__name__ # a source being down is a result too
return found, failed
if __name__ == "__main__":
domain = sys.argv[1] if len(sys.argv) > 1 else "example.com"
found, failed = collect(domain)
hosts = set(found.get("certificates", [])) | set(found.get("archive", []))
only_ct = set(found.get("certificates", [])) - set(found.get("archive", []))
only_archive = set(found.get("archive", [])) - set(found.get("certificates", []))
print(f"{domain}: {len(hosts)} hostnames seen by third parties")
print(f" in certificates only: {sorted(only_ct)[:6]}")
print(f" in the archive only: {sorted(only_archive)[:6]}")
print(f" registration: {found.get('rdap')}")
print(f" sources unavailable: {failed or 'none'}")
对 example.com 的输出结果:
example.com: 7 hostnames seen by third parties
in certificates only: ['dev.example.com', 'emudhra-lab.example.com', 'm.example.com', 'products.example.com', 'support.example.com']
in the archive only: []
registration: {'registered': '1995-08-14', 'expires': '2027-08-13', 'nameservers': ['elliott.ns.cloudflare.com', 'hera.ns.cloudflare.com']}
sources unavailable: none
这段代码中有两处细节来自测试过程。证书日志中包含的不仅仅是主机名:早期版本使用了简单的后缀检查,结果放过了一个带空格的证书标签,以及一个形似的名称 m.testexample.com。这就是为什么 in_scope 要匹配整个子域名。另一个细节是,每个来源看到的内容各不相同,因此应当相互比对,而不是只信任其中一个。一个只出现在旧存档中的主机名可能已经不存在了,而一个只出现在近期证书中的主机名可能是新建的。
被动不再是被动的边界
这条边界比看上去更容易越过。
- 打开你发现的某个 URL。 访问结果中的一个活跃主机名,会从你的 IP 向目标发送请求。
- 带有实时资源的存档页面。 一个被保存下来的页面仍可能尝试从原网站加载图片或脚本。请禁用外部加载,或者直接读取原始响应内容。
- DNS 查询。 通过解析器发出的查询可能会到达目标的权威域名服务器。而历史性的被动 DNS 数据则不会。
- 你的查询对数据来源可见。 你所询问的服务会知道你在查看什么。对于敏感的调查工作,要考虑这会暴露什么信息,并阅读其服务条款和速率限制。
把结果当作线索来对待。记录会过时,证书的存在时间可能比主机本身更长,而共享主机环境会让 IP 历史记录产生误导。
如何处理这些结果
对防御者而言,这一结果呈现的是外部世界已经能够看到的内容清单。一个合理的流程如下:
- 与你的资产清单进行比对。 一个你无法与所有者对应起来的主机名,本身就是一个发现。
- 检查你的命名会暴露什么。 公开日志中像
vpn-old或jenkins-test这样的名称会帮到攻击者。可以考虑在适合的场景下为内部命名使用通配符证书,同时权衡其自身的风险。 - 重复执行,并对新出现的内容发出告警。 我们关于持续性外部攻击面监控的指南展示了如何将这一流程转化为一个带有责任人的差异对比机制。
- 有计划地转向主动检查。 直接验证你自己的资产。对于组织以外的任何对象,先取得授权。当威胁研究需要查看某个可疑网站向特定国家的真实访客展示了什么内容时,那属于主动工作,有其自身的规则,相关内容在我们关于用于威胁情报的住宅代理的文章中有所介绍。
结论
被动收集能以几乎为零的成本、且不惊动目标的方式,为你提供一个域名的有用概况。从证书日志、RDAP 和存档入手,相互比对,把发现的内容当作线索,并且对工作不再是被动的那一刻保持诚实。先在自己身上运行一遍,因为其他任何人也能这么做。
来源与参考资料
- MITRE ATT&CK, Reconnaissance (TA0043): attack.mitre.org
- MITRE ATT&CK, Search Open Technical Databases (T1596): attack.mitre.org
- RFC 9162, Certificate Transparency Version 2.0: rfc-editor.org
- Certificate Transparency, How CT works: certificate.transparency.dev
- RFC 9083, JSON Responses for RDAP: rfc-editor.org
- ICANN, Registration Data Access Protocol: icann.org
- Internet Archive, Wayback CDX Server API: github.com/internetarchive/wayback