ffuf目录与虚拟主机枚举实战:扫描面收敛

ffuf 目录枚举是 Web 资产梳理中最常用的一步:它用字典并发请求路径,按响应码、长度、词数过滤噪声,快速暴露未在导航中出现的后台、备份、调试接口。对防守方而言,同一套命令的价值在于「用攻击者视角检查自己的暴露面」。本文给出 ffuf 的完整实战命令,并把扫描结果转成可执行的加固清单,而不是停在「扫出一堆 200」。

适用场景

  • 新接手站点的资产盘点:拿到域名后需要快速摸清有哪些隐藏路径,尤其是历史遗留的后台、测试目录、上传点。
  • 上线前的暴露面自查:开发环境误开到生产、备份文件未清理、.git 目录可读,这三类问题用目录枚举最快发现。
  • 多站点同 IP 的虚拟主机梳理:一台服务器挂几十个站点时,靠 DNS 反查不全,vhost 枚举能找出未在 DNS 中登记的站点。
  • 护网或渗透测试的授权探测:在书面授权范围内做路径发现,为后续验证提供入口清单。

必须强调:目录枚举属于主动探测,只应在自有资产或取得书面授权的目标上执行,并且要限制并发速率,避免把目标打成拒绝服务。

前置条件

  • ffuf 1.5 以上版本,Go 编译的单文件二进制,无需运行时依赖。
  • 一份合适的字典。推荐 SecLists 的 Discovery/Web-Content 与 Discovery/DNS 子集,不要一上来就用几十万行的大字典。
  • 目标站点的基线响应特征:先请求一个必然不存在的随机路径,记录其状态码与响应长度,这是后续过滤的依据。
  • 授权文件与速率约定。生产环境建议并发不超过 20、每秒请求不超过 50。

原理说明

ffuf 的工作模型很简单:把字典中的每一行替换到 URL 的 FUZZ 占位符位置,发起并发 HTTP 请求,然后按响应特征过滤并输出。它的性能优势来自 Go 的协程池,单机可以轻松跑到每秒数百请求,因此限速是使用者的责任而不是工具的限制。

真正的技术含量在过滤。现代站点普遍做了软 404:任何不存在的路径都返回 200 加一个「页面不存在」模板页,状态码完全失去区分度。此时必须靠响应长度(-fs)、词数(-fw)、行数(-fl)来剔除模板页。判断方法是先用 -fr 正则匹配模板页特征文本,或者直接对比几个随机路径的长度是否完全一致。

虚拟主机枚举是另一条线。当目标 IP 上挂了多个站点时,目录枚举只能看到默认站点。vhost 模式把字典值塞进 Host 头,靠响应差异判断该 Host 是否存在:存在的站点会返回自己的内容,不存在的会落到默认站点。这一步常用响应长度差异做判别。

操作步骤

第一步:安装与基线探测

# 安装(Linux amd64)
cd /tmp
wget -q https://github.com/ffuf/ffuf/releases/latest/download/ffuf_2.1.0_linux_amd64.tar.gz
tar xzf ffuf_2.1.0_linux_amd64.tar.gz
install -m 0755 ffuf /usr/local/bin/ffuf
ffuf -V

# 拉取字典
git clone --depth 1 https://github.com/danielmiessler/SecLists.git /opt/SecLists

# 基线:请求三个随机路径,观察状态码与长度是否一致
for i in 1 2 3; do
  curl -s -o /dev/null -w "%{http_code} %{size_download}\n" \
    "https://target.example.com/$(openssl rand -hex 6)"
done

如果三条输出的长度完全一致,说明站点存在软 404,后续必须用 -fs 过滤这个长度值。

第二步:基础目录枚举

ffuf -u https://target.example.com/FUZZ \
  -w /opt/SecLists/Discovery/Web-Content/raft-medium-directories.txt \
  -mc 200,204,301,302,307,401,403 \
  -fs 4821 \
  -t 20 \
  -rate 50 \
  -timeout 10 \
  -o /tmp/ffuf_dir.json -of json

参数含义:-mc 只保留有意义的状态码,其中 401 与 403 尤其重要,它们说明路径存在但被拒绝,往往是后台入口;-fs 4821 过滤掉软 404 模板页的长度;-t 20 并发线程数;-rate 50 全局每秒请求上限,这是保护目标的关键参数。

第三步:加过滤器与递归

实际站点噪声更多,需要组合多个过滤条件,并对发现的目录做递归:

ffuf -u https://target.example.com/FUZZ \
  -w /opt/SecLists/Discovery/Web-Content/raft-medium-files.txt \
  -mc all -fc 404 \
  -fs 4821,0 \
  -fw 312 \
  -fr "Page Not Found|访问的页面不存在" \
  -recursion -recursion-depth 2 \
  -recursion-strategy default \
  -e .php,.bak,.zip,.tar.gz,.sql,.env \
  -t 20 -rate 50 \
  -o /tmp/ffuf_rec.json -of json

要点说明:-e 扩展名爆破能直接命中 index.php.bak、db.sql 这类高危残留,比纯目录扫描的收益率高得多;-recursion-depth 2 限制递归层数,避免字典膨胀导致请求量失控;-fr 用正则剔除模板页文本,比单纯靠长度更稳。

第四步:虚拟主机枚举

先确认目标 IP 上是否有未登记站点。这一步不加 -fs 而是先用 -fs 0 观察原始分布,找出默认站点的响应长度再过滤:

# 找出默认站点响应长度
curl -s -o /dev/null -w "%{size_download}\n" -H "Host: not-exist-random.example.com" https://TARGET_IP/

# vhost 枚举
ffuf -u https://TARGET_IP/ \
  -H "Host: FUZZ.example.com" \
  -w /opt/SecLists/Discovery/DNS/subdomains-top1million-5000.txt \
  -mc 200,301,302,403 \
  -fs 612 \
  -t 10 -rate 30 \
  -o /tmp/ffuf_vhost.json -of json

-k 参数在证书不匹配时需要加上,但会同时关闭证书校验,仅建议在测试环境使用。生产目标建议用 --servername 或直接解析到源站 IP 后再扫,避免 TLS 握手失败产生大量误判。

第五步:结果整理与去噪

把 JSON 结果转成清单,按状态码分组,方便逐条核实:

python3 - <<'EOF'
import json, collections
d = json.load(open('/tmp/ffuf_rec.json'))
buckets = collections.defaultdict(list)
for r in d.get('results', []):
    buckets[r['status']].append((r['length'], r['url']))
for code in sorted(buckets):
    print(f"=== {code} ({len(buckets[code])}) ===")
    for ln, url in sorted(buckets[code])[:40]:
        print(f"{ln:>7}  {url}")
EOF

输出中优先看 401/403(存在但受限)与长度异常的 200(可能是真实页面而非模板)。301 跳转要跟一次,确认跳转目标是登录页还是外部域名,后者可能是开放重定向。

配置验证

扫描结果必须逐条人工核实,工具输出只是线索。建议按下面的顺序验证:

  • 确认真实存在:对每个 200 结果用 curl -I 复核状态码与 Content-Type,排除 WAF 返回的拦截页。
  • 确认是否应公开:.git/config、.env、*.sql、backup.zip 这类路径一旦可访问即为高危,无需进一步判断。
  • 确认速率未造成影响:扫描期间观察目标 5xx 比例与响应时间,若明显上升说明速率过高,需下调 -rate。
  • 确认结果可复现:换一个出口 IP 复扫关键路径,排除 CDN 或 WAF 的地域策略干扰。
# 批量复核 200 结果
jq -r '.results[] | select(.status==200) | .url' /tmp/ffuf_rec.json \
  | while read -r u; do
      printf "%s  " "$u"
      curl -s -o /dev/null -w "%{http_code} %{content_type} %{size_download}\n" "$u"
    done

常见问题

Q1:扫出来几百个 200,全是同一个长度,怎么处理?

这是典型的软 404。先用随机路径确认模板页长度,然后用 -fs 精确过滤该长度值。如果模板页长度会随 URL 变化(比如页面上回显了请求路径),单靠长度无效,此时改用 -fr 匹配模板页里的固定文案,或加 -fw/-fl 组合过滤。极端情况下可以用 -mr 做正匹配,只保留包含特定关键词的响应。

Q2:扫描被 WAF 拦截,大量 403 或 429,是否继续?

先停。403 与 429 大量出现说明已触发限频,继续加并发只会让出口 IP 被封更久,而且拦截页会污染结果。正确做法是降到 -rate 10、-t 5,加 -p 1-3 做请求间隔随机化,并换用干净的出口。若目标是自有资产,应从源站 IP 直连并临时把扫描 IP 加入白名单,扫完立即移除。

Q3:vhost 枚举结果和 DNS 查询结果对不上,以哪个为准?

以 vhost 枚举为准来发现「物理上存在」的站点,以 DNS 为准来判断「公网可解析」的站点。两者差集就是风险点:vhost 能访问但 DNS 无记录,说明该站点只靠 Host 头区分,一旦攻击者猜到域名就能直连,而它往往缺少统一的安全策略。这类站点应优先接入 WAF 或直接下线。

总结

ffuf 目录枚举的产出质量取决于三件事:基线是否准确、过滤器是否贴合目标、速率是否克制。-mc 决定看什么,-fs/-fr 决定不看什么,-rate 决定会不会把事做坏。把这三项定下来,几百行字典就能给出高信噪比的清单。

更关键的是后半程:扫出来的路径要逐条归类为「应公开」「应鉴权」「应删除」,并把「应删除」的部分固化成上线检查项,例如构建流程里禁止打包 .env 与备份文件、Nginx 层显式 deny \.(git|svn|bak|sql)$。只扫不修,下一次扫描结果不会变。