公开面审计:哪些服务该公网可读,哪些必须私有
把「有登录页」当成安全的后果,是域名下堆起一片产品名的登录墙:匿名访客一无所获,风险却照单全收。给出三种暴露形态的判据、一次可复现的公开面审计流程,以及按代价排序的四档处置与「门面静态化」原则。
现象:一个域名下堆着三个登录框
自建环境很容易长成这样:notes.example.com、files.example.com、panel.example.com,三个域名都返回 200,TLS 正常,看起来"服务都上线了"。但匿名访客在三处看到的都只是一个登录框:
$ for h in notes.example.com files.example.com panel.example.com; do
curl -s -o /dev/null -w "$h %{http_code} %{redirect_url}\n" "https://$h/"
done
notes.example.com 302 https://notes.example.com/.auth
files.example.com 200
panel.example.com 301 https://panel.example.com/webui
三个域名,零个匿名可见的内容。裸域则干脆没有 DNS 记录。
这就是"自建面板集群"的指纹:每个域名背后都是一个产品名的登录墙。它和一家正经公司的公开站点在探测层面完全不像——后者通常有裸域、有 robots.txt,首页在没有 cookie 的情况下也会返回真实正文。
问题出在把「有登录页」当成了「安全」。登录页不是防御措施,它是攻击面的一部分:它意味着一个可解析外部输入的入口、一套会话机制、一份凭据存储,以及一个会持续收到漏洞公告的第三方应用。每一项风险都在,而匿名访客什么也拿不到。
更糟的是,登录页本身是免费的侦察信号:<title> 与 generator 元标签直接把产品名告诉扫描器,无需认证就能把版本对应到漏洞列表上。
核心判据只有一句,后面所有决策都用它:如果一个服务匿名访问只能看到登录框,它对公网的价值是零,而风险是正数。 这样的域名应当按负资产处理。
三种暴露形态
从外部看,"需认证"和"纯管理"长得一模一样,都是 302 到认证端点或一个登录表单。区分它们不需要技术手段,只需要回答一个问题:这个服务的存在目的,是给别人看东西,还是给自己改东西?
| 形态 | 匿名访客看到 | 判断标准 | 处置方向 |
|---|---|---|---|
| 公开可读 | 真实内容 | 不登录、不执行 JS 也能读到有意义的东西 | 保留公网,但需刻意设计 |
| 需认证 | 登录框(内容客观存在) | 内容只对特定的人有用 | 边缘认证,或降为内网 |
| 纯管理 | 登录框(后台面板) | 不存在"访客"这个概念 | 从 ingress 摘除 |
第二种里还有一层:如果那个"特定的人"就是自己,它实际上没有访客,应当按纯管理处理。真正的分界线是是否存在第二个需要访问它的人。只有一个使用者的笔记服务、文件服务、面板,放在公网上没有任何收益。
第三种最容易被忽略:管理面板天生是"登录 → 操作"的界面,从没为匿名读者设计过任何东西,暴露到公网的唯一产出是风险。
审计方法
第一步:枚举,且以权威表为准
不要凭记忆列域名。来源只有两个,而且必须双向对账:
# 1. 隧道/反代的完整路由表快照(只读模式)
./restore-ingress.sh --dump
# 2. DNS zone 里的全部记录
curl -s -H "Authorization: $CF_AUTH" \
"https://api.<边缘提供商>/client/v4/zones/<区域ID>/dns_records?per_page=100" \
| jq -r '.result[] | "\(.type)\t\(.name)\t\(.content)"'
两边对不上的情况各有含义:有 DNS 记录但没有 ingress 规则,落到兜底 404,是无害的死记录(但会泄漏源站地址,如果它是 A 记录);有 ingress 规则但 DNS 已删,暂时不可达,一旦有人重新挂上记录就立即复活。
对账时特别要查指向同一后端的旧主机名。服务改过名字、换过域名之后,为"兼容"保留的旧记录极容易长期留在表里——同一后端上挂两个域名,其中一个是你早就不记得还在公开可达的。
第二步:逐个域名探测
probe() {
h="$1"
code=$(curl -s -o /tmp/body -w '%{http_code}' "https://$h/")
loc=$(curl -s -o /dev/null -w '%{redirect_url}' "https://$h/")
title=$(grep -o '<title>[^<]*</title>' /tmp/body | head -1)
printf '%-24s %s %-42s %s\n' "$h" "$code" "$loc" "$title"
}
不要加 -L:跟着跳转会把"整个域名都是登录墙"这个最关键的信息藏起来,只剩最终那个登录页的 200。要的就是那个 302 的目标地址。
第三步:对文件类服务直接问匿名 API
网页可能只是没渲染,API 会直接给出授权判定,这比看 HTML 有说服力得多:
$ curl -s -X POST https://files.example.com/api/fs/list \
-H 'Content-Type: application/json' -d '{"path":"/"}'
{"code":401,"message":"Guest user is disabled"}
Guest user is disabled 是一句明确的结论:匿名用户被识别了,并且被拒绝了。这类接口通常无需认证即可调用,正好用来确认"匿名到底能拿到什么",而不是"看起来能拿到什么"。
第四步:分类,然后按形态定处置
把每个域名填进上面那张三形态表,同一形态走同一套处置。这一步的输出是"哪些必须动",而不是一份清单。
判据要来自证据,不是印象
服务身份通常不需要认证就能确认,三处证据足够:
$ curl -s https://files.example.com/ | grep -iE '<title>|generator|favicon|apple-touch'
<title>AList</title>
<meta name="generator" content="AList V3">
<link rel="icon" href="/favicon.ico" type="image/x-icon">
<meta name="generator">——应用自己写上去的,最直白。很多后台默认带这一行,而且多数可以在配置里关掉。<title>里的产品名——模板默认值,往往从没被改过。- 默认 favicon——没被替换过的话,它的内容散列稳定且全网唯一。前两项是文本证据、改起来容易;favicon 是二进制证据、最常被漏掉,而测绘引擎正是按这个散列反查资产。顺带看一眼
Server:响应头。
一个低成本的形态判别:请求 /robots.txt 与 /sitemap.xml。真实的公开站一般有;面板集群一般没有,而且 404 也会被重定向到认证页——这本身就说明整站都被认证包住了。
处置选项,按代价排序
| 处置 | 代价 | 适用 | 残留风险 |
|---|---|---|---|
| 从 ingress 摘除 | 高(要另找访问方式) | 纯管理类 | 基本归零 |
| 中性主机名 + 自定义标题/图标 | 低(几分钟) | 必须保留在公网的服务 | 暴露面不变,只降指纹 |
| 边缘认证 | 中(多一跳外部依赖) | 需认证类 | 应用自身漏洞仍在 |
| 只读访客 + 精选内容 | 中高(需持续维护内容) | 内容确实值得公开 | 内容泄漏,且不可撤回 |
1. 从 ingress 摘除(最彻底)。 删掉路由规则和 DNS 记录,服务只保留内网监听(或仅回环 + SSH 端口转发)。这一步的效果是服务在公网上不存在,而不是"难以访问"。执行时不要只改监听地址就收工——还要确认路由表里没有别的 hostname 也指向这个后端。
2. 中性主机名 + 自定义标题与图标(降价指纹)。 主机名不含产品名,<title> 改成自己的站点名,favicon 换掉,能关的 generator 关掉。理想情况下探测者拿到的只剩"这里有个登录页",不剩产品名。局限要说清楚:这只降低被动扫描的命中率,对已经盯上你的目标无效——它降的是指纹,不是暴露面。
3. 加边缘认证(保留可用性)。 把认证从应用挪到边缘(在隧道/CDN 边缘做 SSO,例如 Cloudflare Access 这类零信任入口),应用本身不再对匿名开放,同时保留多设备访问和统一登录。代价是多了一跳和一个外部依赖;而且边缘只拦未授权访问,应用内已授权用户触发的漏洞依然存在。它替代不了打补丁。
4. 启用只读访客并精选内容(把负资产变成资产)。 这是唯一一种能同时消除风险又创造价值的做法:匿名可见的只有你明确挑选出来的内容。关键是把选择做成白名单——显式列出公开目录,而不是"把敏感目录关掉"。后者是黑名单,新加一个目录默认就是公开的,迟早出事。同时要接受一个事实:公开的东西会被索引、镜像、归档,撤不回来。
更根本的一步:公开面应当静态化
如果目的确实是"让公网看到一个站点",那么承载它的不该是一个带登录的后台,而应该是一个静态站。理由不是"静态站更快",而是它的构成里没有可以被利用的东西:
- 没有数据库 → 没有注入、没有拖库
- 没有会话 → 没有会话固定、越权、CSRF
- 没有可执行的输入面 → 没有上传、反序列化、命令注入
- 没有依赖升级压力 → 不背 CVE;一个只做静态分发的 Web 服务器,漏洞面远小于一个全功能应用
它的攻击面大致等于一个文件服务器:能读到什么,取决于文件权限和目录索引开关。
有一个可以量化的表现:因为产物零脚本、零外链,CSP 可以直接写成 default-src 'none'。这条策略不是"配得严",而是它如实描述了内容本身。反过来,如果某个页面必须放宽 CSP 才能显示,那说明它引入了需要先被质疑的东西。
容器侧同理:只读根文件系统、cap_drop: ALL、非 root 用户、低 pids_limit。这些限制对静态站不产生任何功能代价——它本来就没有写入需求,也不需要额外 capability。动态应用则总要可写数据目录与更多系统调用,每一条都是辩解的起点。
由此得到设计原则:把门面和工具分开。门面静态、公网、无状态;工具动态、私有、要认证。
可操作要点
- 枚举以路由表和 DNS zone 为准,双向对账,特别检查指向同一后端的旧主机名。
- 每个域名跑一次不带
-L的 curl,记录状态码、跳转目标、<title>。 - 对文件类服务直接调用其匿名 API,看返回是内容还是拒绝;
Guest user is disabled就是终局答案。 - 三处证据定身份:
generator元标签、title里的产品名、默认 favicon 的散列。文本容易改,favicon 最容易漏。 - 判据只有一句:匿名只见登录框 → 公网价值零、风险正数,进处置队列。
- 处置按代价排序:摘 ingress > 中性化 > 边缘认证 > 只读访客白名单。
- 想让公网看到内容,就让静态站承载;门面与工具分开维护,各自的风险模型才说得清。