服务器暴露面自测绘实战:ss 监听底图、nmap 服务指纹与 Docker 端口绕过 ufw 的收口清单

如果你自己做站超过三年,你的服务器上一定躺着几样东西:一个当年为了测试反代随手装在 8080 的 Node 服务,一个用完忘记卸的 phpMyAdmin,一个作者跑路多年、但还开放着上传接口的 CMS 插件,还有一个当初为了给朋友演示 K8s 而开的 6443。它们不吵不闹,不占 CPU,不写日志,只是安安静静地把你的服务器对全世界敞开。

这类东西的可怕之处在于:它们不会出现在你的任何清单上。你的监控看的是 80 和 443,你的防火墙规则是围绕业务端口写的,你的备份策略关心的是数据库。没人会去检查一个自己都忘了的东西。真正发现它的场合,往往是 auth.log 里出现了一个从没见过的 IP,或者是主机商发来一封「你的 IP 正在扫描他人」的邮件。

很多人知道用 nmap 扫远端,但我更推荐的方法是从自己站的角度去测。因为外部扫描器看到的和攻击者看到的不完全一样,而你自己站在服务器上,能同时验证「公网看到了什么」和「本地监听的是什么」,两者的差集才是最值得关注的。

先建立一张「监听态」的底图

不要一上来就扫端口。先在服务器上确认自己到底监听了几样东西,这是所有后续比对的基础。

ss -lntup | awk 'NR>1{print $1, $5, $7}' | sort -k2

输出里会看到三列:协议、本地地址、进程。重点看本地地址里的这几类:

127.0.0.1:3306 是安全的,只监听回环,外网连不上。0.0.0.0:3306 就麻烦了,所有网卡都在听,只要防火墙(或者云安全组)没拦,全世界都能连。[::]:3306 更容易被忽略,IPv6 的监听在 netstat -lnt 里可能显示不全,但 IPv6 一旦有 AAAA 记录、或者攻击者直接连你的 v6 地址,这条路径就通了。还有 *:80,代表所有地址族的所有网卡,等同于 v4 + v6 全开。

很多人以为「我有防火墙就没事」,但现实里云厂商的安全组、系统的 ufw/nftables、应用自己的 listen 地址,这三层任意一层漏了,就是一条通路。所以正确的做法是把 listen 地址本身收紧,别指望防火墙兜底。数据库只该听 127.0.0.1,Redis 只该听 127.0.0.1,Docker 的 API 只能听 unix socket。

顺手把这张底图存下来,之后每周比对一次,新出现的监听项会自己跳出来:

ss -lntup | awk 'NR>1{print $1,$5}' | sort -u > /var/log/listen-baseline.txt

从公网测:不只看端口开没开,还要看「服务是谁」

有了底图,再去公网侧确认。nmap 的正确用法不是 -p 1-65535 一把梭,那是攻击者的姿势,也是被云厂商风控的姿势。个人站真正需要的是「有目标地确认」,分三步。

第一步,只扫常见端口 + 全端口快扫。常见的 20 来个端口先过一遍,用 -sT(全连接)避免半开扫描被某些 IDS 记成攻击;同时用 -sS 在你有 root 的机器上做一次全端口快速探测。两次结果对照,能发现那些被安全组挡住、但从内网路径其实可达的端口。

nmap -sT -Pn -p 21,22,25,80,443,3306,6379,8080,8443,9000,9090,11211 203.0.113.10
nmap -sS -Pn -p- --min-rate 2000 203.0.113.10

第二步,对开着的端口做服务指纹。这一步比「端口开着」重要得多。22 开着不稀奇,但如果 -sV 告诉你那是 OpenSSH 7.4,你就知道该紧张了;3306 开着不稀奇,但如果它回的是 MySQL 5.5.5-10.1,那就是一个没人管的老 MariaDB。

nmap -sV -Pn -p 22,80,443,3306,8080 203.0.113.10

第三步,手工看 HTTP 服务的身份。nmap 的 http-title 脚本能给出页面标题,但更有用的是直接 curl 一下,看响应头和默认页面。一个「默认的 Apache 欢迎页」意味着这个端口上没有你的任何业务,纯粹是个暴露面。

curl -sI http://203.0.113.10:8080/ | head -20
curl -s http://203.0.113.10:8080/ | grep -iE 'title|generator' | head -5

最容易漏的三类东西

扫完之后你会发现,真正需要处理的从来不是那些「主业务端口」,而是下面三类。

第一类,Docker 发布的端口。这是现代服务器最大的暴露面来源。很多人以为 docker run -p 127.0.0.1:8080:80 就安全了,但实际上 Docker 会在 iptables 的 DOCKER 链里插入规则,这条链在 INPUT 之前处理(更准确地说,走的是 FORWARD 链),所以你的 ufw 规则对它无效——这是「ufw 明明拒绝却还能访问」的经典成因。-p 8080:80 不带地址,等于 0.0.0.0:8080。

检查方式很直接:docker ps --format '{{.Names}}\t{{.Ports}}',任何以 0.0.0.0: 或 ::: 开头的映射都要问一句「这个真的需要公网访问吗」。不需要的,改 compose 里的 127.0.0.1:8080:80,然后 docker compose up -d 重建。想彻底让 Docker 不碰 iptables,可以在 /etc/docker/daemon.json 里加 "iptables": false,但那会连带影响容器出网和容器间通信,个人站一般没必要,改绑定地址就够了。

第二类,只靠「路径隐蔽」保护的管理面板。把 phpMyAdmin 放在 /pma-8f3a2/ 这种随机目录,是很常见的偷懒做法。它的防护强度等于零——目录名会出现在日志里、备份里、Robots 的爬取里、甚至某些插件的引用里。正确的做法是三层收口:面板本身只监听 127.0.0.1,通过 SSH 隧道访问;或者用 nginx 的 allow/deny 只放行你的固定出口 IP;再或者上 auth_basic 加一道密码。三选一,都比「藏起来」强。

location /pma/ {
    allow 203.0.113.5;
    deny all;
    auth_basic "restricted";
    auth_basic_user_file /etc/nginx/.htpasswd-pma;
}

第三类,监控和可视化组件。Prometheus 的 9090、Grafana 的 3000、Node Exporter 的 9100、cAdvisor 的 8080、Kibana 的 5601——这些组件的默认配置里全都没有认证。Node Exporter 尤其危险,它会把整机的 CPU、内存、磁盘、网络、甚至 systemd 服务列表全部吐出来,等于免费给攻击者做了一份侦察报告。cAdvisor 更狠,能列出你所有容器的镜像名和环境变量(有些环境变量里就是数据库密码)。

这类组件的正确定位是「只给 Prometheus 抓,不给人类看」。所以它们应该只监听 127.0.0.1,或者放在一个独立的内部网络里,由 nginx 反代并加上认证。任何时候在公网 nmap 结果里看到 9100/8080/9090,都要当成事故处理。

把「扫描」变成例行公事

一次性扫描的价值很有限,因为你明天还会装新东西。真正有用的是把它变成每周一次的低成本检查,并且和基线比对,而不是每次都人工看一遍输出。

#!/bin/bash
# /usr/local/bin/port-audit.sh
BASE=/var/log/listen-baseline.txt
NOW=/tmp/listen-now.txt
CHANGED=/tmp/port-audit-report.txt

ss -lntup | awk 'NR>1{print $1,$5}' | sort -u > "$NOW"

{
  echo "== 新增监听 =="
  comm -13 "$BASE" "$NOW"
  echo "== 消失的监听 =="
  comm -23 "$BASE" "$NOW"
  echo "== 仍在监听 0.0.0.0 的项 =="
  grep -E '0\.0\.0\.0:|\*:|\[::\]:' "$NOW" || echo "无"
} > "$CHANGED"

if grep -qE '^[0-9]' "$CHANGED"; then
  curl -s -d "服务器出现新的对外监听,请查看报告" https://ntfy.sh/your-topic
fi
cat "$CHANGED"

这个脚本有一个容易忽略的细节:ss 的进程列里带 PID,每次重启都会变,所以 awk 只取前两列(协议 + 本地地址),否则每次比对都会发现「所有东西都变了」,报告就废了。这是我自己第一版踩的坑,白了一周的告警。

另外,comm 要求输入按字典序排序,sort -u 不能省;中文环境下如果 LC_ALL 不同,排序结果可能不一致,稳妥起见在脚本开头加 export LC_ALL=C。

发现暴露面之后,处置的优先级

扫出一堆东西之后,不要一股脑全关掉,那样很可能把自己的业务也关掉。按这个顺序处置:

先看高危服务的版本。Redis 未授权访问、MySQL 允许 root 远程、MongoDB 无认证、Elasticsearch 的 9200 开放——这四个是「扫到即沦陷」级别的,优先级最高。检查命令分别是 redis-cli -h 目标 ping(回 PONG 就是无认证)、mysql -h 目标 -uroot -e 'select 1'、curl 目标:9200/_cat/indices。这些端口如果确实需要远程访问,至少要加防火墙白名单。

再看「有业务但能收紧」的。比如 8080 上跑着一个内部工具,业务需要它,但不需要它对公网开放。这时候改绑定地址、加 nginx 反代 + 认证,是最优解;直接关掉会导致同事访问不了。

最后清理「确认无用」的。老版本的 Node 服务、测试用的静态服务器、废弃的 CMS 后台,直接 systemctl disable --now 然后卸载。这一步经常能省出不少内存,算是意外收获。

顺手做两件事,把「能被扫到」变成「扫了也没用」

暴露面收窄是治标,还有两件治本的事值得一起做。

一是把 SSH 从密码认证改成密钥认证,并且限制来源。22 端口开放本身不是问题,问题是它允许密码登录。改完之后你可以在 auth.log 里看到海量爆破请求全部失败在 publickey 这一步,服务器的负载和日志体积都会明显下降。具体做法是 PasswordAuthentication no、PermitRootLogin prohibit-password,然后 sshd -t && systemctl reload sshd。改之前一定要保持一个已经登录的窗口不关,新开窗口验证能进再退出。

二是给敏感路径加统一入口。如果你有多个自建服务(面板、监控、备份界面),与其每个都单独加认证,不如用一个 auth_request 把鉴权交给一个统一端点,未认证的请求在 nginx 层就被拦掉,后面的应用根本收不到流量。这种模式的好处是:应用升级、换版本都不影响认证;坏处是多一次内部请求,几十毫秒,个人站完全可接受。

location / {
    auth_request /auth-check;
    auth_request_set $auth_user $upstream_http_x_user;
    proxy_set_header X-Forwarded-User $auth_user;
    proxy_pass http://127.0.0.1:8080;
}
location = /auth-check {
    internal;
    proxy_pass http://127.0.0.1:9001/verify;
    proxy_pass_request_body off;
    proxy_set_header Content-Length "";
}

注意 internal 这一行不能少,否则外网可以直接请求 /auth-check 来试探认证服务的行为。proxy_pass_request_body off 也是必须的,否则大文件上传会在鉴权阶段被整个读进内存。

一次真实的清扫记录

我最近在一台跑了三年多的机器上做了一次完整清扫,结果挺有代表性:共发现 9 个对外监听,其中 4 个是本该只在回环的数据库和监控组件(3306、6379、9100、9090),2 个是废弃的测试服务,1 个是 Docker 里 0.0.0.0:8080 的内部工具,只有 2 个是真正需要公网的 80/443。换句话说,四分之三的暴露面是「我以为关了其实没关」。

处置花了不到四十分钟:三个服务改绑定 127.0.0.1,两个 systemctl disable --now,一个加 nginx 认证,Docker 那个改 compose 文件重建。之后重新扫了一遍,公网只剩 22/80/443,其中 22 已经限制为密钥登录。整个过程没有一次业务中断,因为每一步都先改了绑定地址再重启,验证 443 正常之后才动下一个。

值得说的是,这四十分钟的价值不在于「安全了多少分」,而在于它把一个看不见的风险变成了看得见的清单。以前我对这台机器的认知是「大概就那几个服务吧」,现在我能准确说出每一个监听项的用途、归属和为什么需要它。这种认知上的差异,才是运维里最值钱的部分。

所以,如果你也很久没认真看过自己的服务器了,建议今天花半小时,跑一遍 ss -lntup,把每一个非 80/443 的监听项都问一句:这个是谁?为什么对公网开着?真的需要吗?大概率,你会关掉至少两个。

Last modification:October 17th, 2026 at 08:26 pm

Leave a Comment