☰
Docker部署VHostScan:虚拟主机枚举与Host头识别实战
2026/10/21 18:29:07 网站建设 项目流程

做资产盘点时我经常遇到一种情况:目标IP开着80/443端口,直接访问却只有一个平平无奇的默认页面,响应头里也翻不出有价值的信息。很多人看一眼就把它归为“低价值目标”,但实际上这个页面背后可能挂着十几个业务系统,只是它们都绑定在特定域名上,不带着正确的Host头根本访问不到。后来我把VHostScan放进Docker容器里做虚拟主机枚举,才真正解决了这类问题。这篇文章就是一次完整的Docker部署与使用记录,从工具原理到参数调优,再到我实际踩过的坑,都会按实战顺序讲清楚。无论你是做Web安全评估、资产梳理还是运维巡检,只要涉及“同一个IP藏着多个站点”的场景,这篇内容都能直接照着用。

1. 虚拟主机扫描的核心逻辑:先搞懂VHostScan在做什么

很多第一次接触虚拟主机枚举的同行容易犯一个错误:拿到IP之后直接跑端口扫描、目录扫描,却完全忽略Host头这个维度。等我把虚拟主机的概念讲透,你会发现这个维度往往才是突破口。

1.1 一次看似寻常的IP访问,为什么会只看到默认页面

把一台Web服务器想象成一栋写字楼:IP地址是大门的位置,而Host头就是访客进门时报出的“公司名”。前台会根据你报的公司名,把你引导到对应的楼层;如果你只说“我要找人”,前台只会把你带到大厅休息区。服务器上的默认站点就是那个“大厅休息区”,真正跑业务的虚拟主机则分散在各个“楼层”。

这个机制对应到Nginx或Apache里,就是一个IP地址上配置了多个server块或VirtualHost,每个块绑定不同的server_name。举个最常见的Nginx配置例子:

server { listen 80 default_server; root /var/www/default; } server { listen 80; server_name www.example.com; root /var/www/www; } server { listen 80; server_name admin.example.com; root /var/www/admin; }

这里第一段配置设定了默认站点,第二、第三段配置分别绑定了两个域名。当你用IP直接访问这台服务器时,Nginx只会把请求交给default_server;只有当你带上“Host: www.example.com”或“Host: admin.example.com”时,请求才会被路由到对应的业务站点。

安全视角下,问题就出在这里:端口扫描只能让你看到“大厅”,却看不到“楼层”。很多内部系统、测试环境、后台管理页面,恰恰只允许通过特定域名访问。比如某公司在同一个公网IP上既跑官网,又挂了一个刚上线还没推广的业务后台,这个后台只绑定了内网专用的域名。如果资产梳理阶段没有枚举虚拟主机,这个后台就会从攻击面上彻底消失。VHostScan这类工具存在的意义,就是在授权范围内把这些“藏在门牌号后面的公司名”一个个试出来。

1.2 VHostScan的判定原理:通过Host头差异识别“隐藏站点”

VHostScan的做法并不复杂,原理上就是一问一答的批量对比:它先用一个“肯定不会存在”的Host头去请求目标,拿到一条基线响应;然后拿着字典里的候选Host头逐条替换,再对比每条响应与基线之间的差异。一旦差异超过设定阈值,就判定这个候选Host指向了一个真实存在的虚拟主机。

判定时常用的指标包括:

判定指标基线响应(不存在的Host)疑似虚拟主机响应说明
状态码404 / 403 / 500200 / 301 / 302最直观的差异
Content-Length几百字节几千到几万字节页面规模差异明显
Title默认页面标题业务系统名称需要看响应正文
Location跳转无指向新域名常见于多个站点共用IP
Set-Cookie无存在业务Cookie后端真实有应用

实际项目中,状态码和响应长度是最常用的两个维度,因为不需要解析完整正文就能筛出绝大多数候选。有些情况下还需要看响应里的“Server”头或正文关键词,比如某个站点返回的标题是“统一身份认证平台”,这一条基本就能确认是一个真实业务系统。

有一点需要说明:VHostScan本质上是资产发现的“筛子”,不是漏洞利用工具。它只负责把潜在的虚拟主机名单捞出来,后面每个候选是否真实可用、是否存在漏洞,都要人工跟进确认。我在实际使用中很少直接相信工具的单一维度输出,一般都会挑响应差异明显的前几十条,再手工验证一遍。

1.3 为何选择Docker化部署而非直接安装到本机

VHostScan这类Python写的安全工具,最大的痛点不是功能不够,而是环境兼容性太随缘。老版本的扫描脚本依赖第三方库,在不同Python版本、不同操作系统上跑,经常出现编译报错或者TLS握手异常。用Docker化部署,最直接的好处有三点。

第一是环境隔离。我自己的本机装了一堆业务开发环境,Python版本、OpenSSL版本都和工具要求的不一致。硬装VHostScan的话,不出意外会污染本机Python环境。放进Docker容器里,工具该用哪个Python版本、装哪些依赖,全都固化在镜像里,跟宿主机无关。

第二是开箱即用。Docker镜像构建完成后,在任何一台装了Docker的机器上都能跑。去某个客户现场做授权评估时,带一个镜像文件或者上公共镜像仓库拉取,几分钟就能把整套工具链恢复起来,不需要现场装一堆依赖。

第三是善后简单。扫描是一次性任务,我用--rm参数运行容器,跑完容器直接删除,不会在宿主机上留下Python安装痕迹、临时文件或者历史记录。如果是自己手动装一套VHostScan,光排查依赖就得花半天,卸载也不干净。这三点加起来,足以说明为什么在“部署与使用”这个环节,Docker是比本机安装稳得多的选择。

2. 部署前的准备:镜像选择与基础环境搭建

准备工作看起来只是跑两条命令,但实际决定后面扫描是否顺利的,往往就是这一步有没有做扎实。我见过不少人栽在镜像选错或者Dockerfile依赖不全上,这里把两种方式都讲清楚。

2.1 公共镜像仓库拉取现成镜像

如果不想自己折腾Dockerfile,最省事的办法就是直接到公共镜像仓库搜现成的VHostScan镜像。搜索时注意三个点:看镜像的下载量、看最近的更新时间、看镜像描述里是否写清楚入口命令。

docker search vhostscan

搜索结果出来后,选择一个维护比较活跃的镜像拉取:

docker pull vhostscan

不同镜像由不同人维护,封装方式差异很大。有的镜像把入口直接封装成了vhostscan命令,有的还是原始状态需要手动执行python vhostscan.py。拿到镜像后别急着执行,先用交互式Shell进去看一下目录结构:

docker run --rm -it vhostscan bash

进去之后用ls看看工作目录、用python --version确认Python版本、用cat requirements.txt浏览依赖清单。如果镜像里是用ENTRYPOINT指定的启动命令,那么可以直接试一下:

docker run --rm -it vhostscan --help

这一步的目的是确认工具的入口文件路径和参数风格。VHostScan不同版本的参数名会有一点点差别,比如输出格式、代理参数、线程控制等,拉取镜像后先摸清这些细节,后面正式扫描的时候能少踩很多坑。

2.2 自己动手构建:Dockerfile与依赖处理

公共镜像虽然方便,但有时候会存在版本老旧、不肯更新依赖的问题。我自己更习惯自己构建镜像,尤其是需要在多个项目间复用同一套工具链的时候,自己构建至少知道里面放了什么。

使用Dockerfile构建时,基础镜像我一般选python:3.10-slim,而不是完整版的Ubuntu或Debian镜像。系统镜像越大,构建越慢,且暴露面也大,没必要。

FROM python:3.10-slim RUN apt-get update && apt-get install -y --no-install-recommends \ git gcc g++ libssl-dev libffi-dev \ && rm -rf /var/lib/apt/lists/* WORKDIR /opt/vhostscan COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . ENTRYPOINT ["python", "vhostscan.py"]

这里有几个细节值得解释。libssl-dev和libffi-dev一定要装,因为扫描HTTPS站点时需要处理TLS连接,缺少这两个开发库会导致Python的ssl模块编译失败或者运行时报错。gcc和g++也是为那些需要编译C扩展的Python包准备的。构建时把源码文件放到和Dockerfile同一个目录下,然后执行:

docker build -t vhostscan-local .

构建完成后,先用--help验证镜像能跑起来:

docker run --rm -it vhostscan-local --help

如果输出参数列表,说明环境已经通了。自己构建的镜像体积通常会大一些,但胜在可控。我一般在项目中同时保留这两个方案:优先用自己构建的镜像,公共镜像作为备选。

3. 快速上手:三条命令完成扫描流程

部署完成之后,很多人会急着把扫描命令跑起来,但如果没有先解决字典问题,扫出来的结果往往要么全空、要么全是无效数据。这一步我把完整的操作流程走一遍,从验证工具到准备字典再到正式扫描。

3.1 第一次运行:验证工具是否能在容器内正常工作

正式扫描前,先做一次快速的“冒烟测试”。直接执行帮助命令,看工具能否正常响应:

docker run --rm -it vhostscan-local --help

我一般会重点确认这几个参数是否存在:--ip(目标IP)、--wordlist(字典文件)、--output(输出文件)、--threads(线程数)、--proxy(代理地址)。不同版本对参数名称可能会改,比如有的版本用-w代替--wordlist,用-o代替--output。如果发现参数名对不上,就以当前版本--help给出的实际输出为准。

顺手还可以测试一次最小化的扫描,用非常小的字典跑一遍,看能否正常返回结果。最小化测试能提前发现网络模式问题、文件挂载权限问题,避免在正式扫描时才暴雷。

3.2 准备子域字典:决定扫描质量的关键一步

VHostScan的扫描质量很大程度取决于字典质量。字典里每行一个候选Host头,格式很简单。比如要枚举example.com下的虚拟主机,字典内容可以是:

www admin oa portal crm api dev test

工具会把这一行内容和主域名组合成完整的Host头,比如admin.example.com。如果手头已经有从公开情报、证书透明度日志或者历史数据里整理出来的完整子域列表,直接把完整域名放进去也是可以的,每行一个:

www.example.com admin.example.com old-blog.example.com

这里我有个实践心得:第一遍扫描不要一上来就上几万条的大字典。先用几十条常见命名前缀,快速确定哪些IP存在虚拟主机枚举的必要,再对重点目标换大字典慢速复扫。原因很简单,大字典意味着长耗时和高误报,先小后大可以省下大量时间。

字典文件的挂载方式也需要注意。把字典放在宿主机某个目录,例如当前目录的dict文件夹下,通过Docker的-v参数挂载进容器:

-v "$(pwd)/dict:/dict"

这样字典的更新和替换都在宿主机侧完成,不用反复进入容器内部操作。

3.3 执行一次完整扫描并保存报告

工具验证通过、字典准备妥当之后,就可以跑正式扫描了。一次典型的扫描命令长这样:

mkdir -p output docker run --rm -it \ -v "$(pwd)/dict:/dict" \ -v "$(pwd)/output:/output" \ vhostscan-local \ --ip 192.0.2.10 \ --port 80,443,8080 \ --domain example.com \ --wordlist /dict/prefix.txt \ --threads 50 \ --output /output/report.html

拆开看每个参数的作用:--ip指定目标IP;--port指定要探测的端口,多个端口用逗号分隔,80、443以及常见的8080都可以放进去;--domain指定主域名,工具会自动把字典里的前缀拼成完整Host;--wordlist指定字典文件在容器内的路径;--threads控制并发线程数;--output指定结果输出路径,这里写入容器内的/output目录。

关键是输出目录为什么一定要挂载。容器本身是临时的,加了--rm参数运行完会自动删除,如果结果只写在容器内部,扫描一结束数据就丢了。挂载出来的目的是让报告落到宿主机磁盘上,方便后续分析归档。

执行完扫描后,打开output目录下的报告文件,看看是否有Content-Length或者状态码有明显差异的候选结果。如果一条差异都没有,不要急着下结论,先检查网络连通性和目标行为,这部分我在后面“踩坑实录”里专门展开。

4. 常见参数深入解析:从能跑到跑得好

跑通一次扫描只是及格线。实际项目中,真正拉开差距的往往是对参数的调控能力。有些参数不调好,扫描要么慢得让人抓狂,要么结果噪点密布。

4.1 速度与稳定性:线程、间隔、超时怎么组合

VHostScan扫描的本质是短时间内发起大量HTTP请求,速度参数会直接影响目标服务和扫描结果。我对不同场景的选择建议如下:

扫描场景线程数请求间隔超时时间重试次数
内网资产梳理100~200不设或很小3~5秒3次
外网授权测试30~801~2秒5秒2次
线上生产目标10~202秒以上5秒1次

线程数为什么不能无脑拉高?原因是每个线程都是一个并发TCP连接,线程太多会瞬间占满目标服务器的连接池,触发限流或封禁策略,反而导致大量请求超时。另一个更现实的问题是,扫描时可能恰好影响线上业务,把生产系统扫挂了,这是任何授权测试都不愿意看到的局面。

如果工具支持请求速率控制参数,就把每秒请求数限制在一个稳妥范围。如果不支持,可以用请求间隔参数来降速。我的经验是,内网环境可以放开手脚跑,因为内网带宽充足、延迟低;外网和线上目标一定要保守,扫描速度放慢一点,误报概率会小很多,对业务的影响也可控。

4.2 字典动态生成:让覆盖面扩大一个量级

固定字典跑完一遍,结果可能不理想,因为很多虚拟主机命名并不按常规套路来。比如一个企业内部的ERP系统,可能绑定在erp-01.example.com上,也可能绑定在finance-system-2023.example.com上。这种非规律命名,靠常见前缀字典很难覆盖。

VHostScan针对这个痛点提供了动态生成机制。你可以先准备一份“主域名”参数,再准备一份“前缀字典”,工具会自动排列组合出完整Host,也能根据命令行参数指定的规则动态扩充候选列表。这样,原本只准备了100个前缀,实际探测的可能有几千种变化。

实际操作中,我喜欢把动态生成和外部数据源结合起来:先从第三方子域收集平台导出目标主域的已知子域列表,导入工具作为第一轮候选;再结合前缀字典做动态扩展,作为第二轮补充。两轮结果合并去重之后,基本能覆盖大部分真实存在的虚拟主机。

不过要注意,动态生成也不是越大越好。如果目标存在泛解析问题,也就是任意不存在的子域名都会返回同一个默认页面,那再大的字典也无法区分。遇到这种情况,可以先用一个随机生成的不可能存在子域名去请求目标,把响应内容长度记录下来作为聚类基准。所有与这个基准内容长度相同的响应,大概率都是泛解析产生的垃圾数据,可以直接过滤掉。

4.3 请求伪装与代理:绕过限制与流量调试

不少目标Web服务器会对扫描请求做简单过滤,比如检测User-Agent或者异常频率。VHostScan支持随机User-Agent和自定义请求头,这些参数不是为了搞什么见不得人的操作,而是在授权测试场景下用来模拟真实访问,降低对目标日志的污染。

使用随机User-Agent时,每次请求会从常见浏览器UA池里取一个值,避免所有请求都顶着同一个扫描器UA,目标日志里看起来更像正常流量。如果你需要模拟特定客户端的访问行为,也可以手动指定一个固定的UA字符串。

自定义请求头参数则用来追加额外的Header。比如有些系统会基于X-Forwarded-For这类头做访问控制,授权测试时可以通过自定义Header来验证这种依赖是否可靠。但这里我要提醒一句:一定要在授权范围内操作,不要把这些能力用到未经授权的目标上。

代理参数的实际价值更多在于调试,而不是“隐藏扫描行为”。我会在扫描前把请求转发到本地抓包工具,通过抓包工具查看VHostScan实际发出的HTTP请求头格式,确认Host头替换是否符合预期。尤其是遇到TLS站点时,通过代理检查ClientHello里的SNI字段非常有用,很多问题都能在这一步排查出来。

5. 踩坑实录:Docker方式跑VHostScan的典型问题

这个部分是我最想分享的。工具本身的用法文档写得再清楚,也不如实际踩过的坑来得有参考价值。我按出现频率从高到低,整理了四个典型问题。

5.1 输出目录无权限:容器用户与宿主机所有者不一致

问题现象很典型:容器内扫描正常执行,日志也不报错,但最终报告文件没有出现在宿主机挂载的output目录里。进入容器检查后会发现,明明扫描程序生成了结果,但写文件时提示Permission denied。

原因在于Linux系统下Docker的权限模型:容器内进程默认以root运行,但如果挂载目录在宿主机上属于某个普通用户,或者目录权限被设置成只读,容器内进程写入就会失败。反过来也一样,如果镜像构建时指定了非root用户运行,而挂载目录属于宿主机root,同样没法写。

解决这个问题有两条路。第一条是给宿主机目录一个宽松的权限:

chmod 777 output dict

这种做法简单粗暴,适合临时环境。第二条更规范,用当前用户的UID和GID运行容器:

docker run --rm -it \ --user "$(id -u):$(id -g)" \ -v "$(pwd)/dict:/dict" \ -v "$(pwd)/output:/output" \ vhostscan-local \ ...

这样容器内的写操作会以当前宿主机用户身份落地,文件归属就不会出现错乱。但这个方案要求镜像不依赖root写系统目录,否则容器内应用反而会启动失败。如果遇到这种冲突,我一般会先检查镜像里是否存在对系统目录的写操作,再决定用哪种方案。

5.2 全404或全200:结果异常时先查这三种可能

扫描跑完,报告里如果全是404,那很可能是一个排查信号。我第一反应不是怀疑字典,而是先确认端口和服务本身。用curl直接测试目标IP的端口是否开放、Web中间件是否正常响应。如果网络不通,先解决连通性再谈扫描。

网络通但依然全404,就需要找到一个已知存在的真实域名来做“金标准”验证。比如手工请求一下:

curl -kv -H "Host: www.example.com" http://192.0.2.10/

如果这个已知域名也返回404,说明目标可能不认这个Host,或者这个虚拟主机只绑定在别的端口上。那就换端口、换协议再试。如果已知域名能正常返回,而工具扫出来全是404,说明工具发出的请求格式有问题,需要抓包检查Host头是否被正确替换。

全200的情况则相反,工具把所有Host头都判定成了“存在”。常见的解释是目标配置了泛解析,或者对所有未知Host都返回同一个默认页面。遇到这种情况,关键不是关心状态码,而是观察响应内容是否一致。可以对比Content-Length,如果所有候选Host的响应长度都一样,基本上可以判定工具发出的请求都被路由到了同一个默认站点。

5.3 容器内缺依赖:自己构建镜像时的环境变量陷阱

自己构建Docker镜像时,最常见的报错是运行时报ImportError或者ssl模块不可用。原因往往不是Dockerfile里的pip install没执行成功,而是基础镜像缺少必要的系统库。

比如扫描HTTPS站点时,如果Python的ssl模块编译时缺少OpenSSL开发库,运行时会直接报错。另一个容易忽略的点是,有些项目代码在运行时需要访问额外的环境变量,比如指定PYTHONPATH来导入自身模块。我自己的做法是构建完成后,先进入容器验证关键依赖:

docker run --rm -it vhostscan-local python -c "import requests, ssl; print(ssl.OPENSSL_VERSION, requests.__version__)"

如果ssl.OPENSSL_VERSION能正常打印,说明系统库没问题;如果这里报错,回到Dockerfile里加上libssl-dev libffi-dev重新构建。如果requests能正常导入,说明Python依赖安装完整。两条检查都通过之后,再跑扫描。

这个习惯帮我省了很多时间。与其等正式扫描跑一半才报错,不如扫码前用一条命令把所有依赖状态确认清楚。

5.4 网络模式问题:容器到不了目标内网

Docker容器默认使用bridge网络,相当于容器在宿主机后面做了一层NAT。大多数情况下这没问题,但少数目标服务器对来源IP或者连接行为比较敏感,就会造成容器发出去的请求没有回包,扫描结果自然全空。

解决方法是让容器共享宿主机网络栈:

docker run --rm -it --network=host vhostscan-local ...

--network=host模式下,容器不再有自己的网络隔离层,直接用宿主机的IP和端口发起连接。这对内网扫描特别有用,因为目标的内网路由、防火墙规则都是基于宿主机网络生效的,容器共享后就不存在NAT转换带来的兼容问题了。

不过要留意操作系统平台差异。这个参数在Linux环境下支持得很好,在有些桌面系统的Docker中host网络模式效果会受限。如果host网络模式不可用并且目标确实无法从容器访问,那就只能绕道,先在本机完成隧道或者跳板配置,让宿主机本身先能触达目标,再重新跑容器内的扫描。

6. 结果解读与后续利用:扫描结束只是开始

扫描不是终点,把结果整理成可用的资产情报才是终点。很多时候结果文件生成了一堆,但没人会看,等于白扫。这里分享我自己的结果解读流程。

6.1 从报告里快速筛出“真站点”

工具生成的HTML报告一般会以表格形式展示所有候选结果。一次典型的结果表格长这样:

候选Host状态码Content-LengthTitle
www.example.com20048213官网首页
admin.example.com302218无(跳转到登录页)
old.example.com2005301老站维护中
test.example.com404134无

拿到表格之后,我习惯按三个优先级筛数据。第一优先级看状态码,凡是返回200、301、302、401、403的候选都值得关注。第二优先级看响应长度与基线的差异,如果某个Host的Content-Length突然比基线大出好几个数量级,说明它背后一定跑着实际业务。第三优先级看标题和跳转目标,标题里出现“登录”“后台”“管理”这类关键词的直接置顶。

这里有个容易翻车的点:状态码200并不能代表一切。如果目标配置了泛解析,所有Host都会返回200,这种情况下要结合响应长度和正文关键词来判定。我见过有人把所有200结果全当成真实虚拟主机报上去,结果交付评审时翻车,花了不少精力才收回报告。

6.2 用curl做二次确认

工具识别出的候选,务必用第二工具独立验证。我常用的命令是:

curl -vk --resolve admin.example.com:80:192.0.2.10 http://admin.example.com/

--resolve参数的作用是把指定域名的DNS解析强制指向目标IP,同时请求URL和Host头都使用这个域名。这样做的好处是:请求的HTTP Host头、URL里的域名、以及如果换到443端口时的SNI字段都会保持一致,最接近真实浏览器访问的状态。

另一种方式是直接用-H "Host: xxx"覆盖Host头:

curl -vk -H "Host: admin.example.com" http://192.0.2.10/

这种方式改写了Host头,但URL仍然是IP形式。两种方式的结果都可以作为参考,我实际操作中更偏爱--resolve,因为它能做到Host和SNI一致,在一些按SNI区分站点的场景下更准确。

验证时还要注意CDN的问题。如果某个域名解析出的真实地址是CDN节点,直接用这个域名配合目标IP来测试,可能与真实业务源站无关。这时候就要先通过响应的Server头、证书信息、响应头里的缓存标记来判断,别把CDN回包当成最终结论。

6.3 把结果沉淀进自己的资产库

验证通过之后的虚拟主机清单,我会按标准格式整理成结构化数据,包括字段:IP、端口、协议、域名、状态码、标题、备注。整理成表格后,导入自己的资产清单系统,后续做更深入的测试时可以直接调用。

还有一个容易被忽略的动作:把扫描时产生的原始请求和响应内容一并保存。我不只是在报告里看结果,还会把扫描过程中捕获的关键响应Header、跳转记录、会话特征全部存档。这些原始数据在后续写交付报告或者争议复盘时非常有用,可以证明某个站点确实存在于某台服务器上,而不是我凭空推导出来的。

保存方式很简单,在扫描命令输出原始格式文件,或者通过代理抓包工具把流量存下来,按目标IP和扫描时间建目录归档。我吃过一次亏,当时只保存了最终结论没保存原始响应,后来需要证明一个结论时发现证据不足,只能重新扫一遍。从那之后,原始数据归档变成了固定流程。

我个人在实际项目里的习惯是,先拿一份几十条常见命名前缀的小字典快速跑第一遍,几分钟内确定哪些IP存在虚拟主机枚举的必要,再针对重点IP换大字典慢速复扫。完成后每个候选结果都用`curl --resolve`独立验证一遍,最后把工具报告连同原始请求响应一并导出归档。这套流程配合Docker环境的高可复现性,让整个虚拟主机枚举过程在多个项目里都能保持一致,既不会因为本机环境差异影响结果,也不会因为漏掉细节在交付阶段返工。如果你也经常跟多站点共享IP的场景打交道,下次不妨把VHostScan放进容器里试一遍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询