APISIX - 3 - etcd 暴露后配置被改写
说明 APISIX 在 etcd 无认证且端口映射到宿主机时,配置如何被改写并在网关里执行,以及不删除正式路由的修复顺序。

云厂商报挖矿时,先别把容器删掉。这套 APISIX 的入口是配置中心,不是网页上的登录框。etcd 没有认证,并且 2379 被映射到宿主机后,任何人只要能连上这个端口,就可以直接改网关配置。APISIX 会自己把新配置加载进来,不需要 Admin API 的密钥,也不需要 Dashboard 的密码。

下面按一次真实排查来写:配置是怎么被写进去的,什么条件会触发执行,以及怎样清掉恶意配置、同时留下正式路由。文中的地址、账号和密钥都用占位符。不要把示例原样套到生产机器上。

版本和上一篇相同:APISIX 3.2.2,Dashboard 3.0.0,etcd 3.5.11。Dashboard 不走 Admin API,它和 APISIX 一起读写 etcd 里的 /apisix 前缀。

先看哪几个端口

这次能被写配置,同时满足了三件事:

  1. docker-compose.yml 里 etcd 有 ports: "2379:2379/tcp",宿主机所有网卡都能接到这个端口。
  2. 环境变量 ALLOW_NONE_AUTHENTICATION 为 yes,etcd 不校验账号。
  3. 云安全组或宿主机防火墙没有拦住 2379。安全组一旦放行,容器映射会把端口直接送到 etcd。

Admin API 的 9180 可以一直不映射。示例配置里的 admin_key 也可以已经换成自己的值。这两处都改过,仍然挡不住。Dashboard 的 9000 同样危险,因为它也能写同一份 etcd,但它不是这次挖矿配置的写入点。

用下面几条区分“端口开着”和“服务真的能用”。在服务器上执行:

docker ps --format 'table {{.Names}}\t{{.Ports}}'
docker exec <etcd容器名> etcdctl auth status
curl -m 3 http://127.0.0.1:2379/version

auth status 里 Authentication Status: false,同时本机 curl 能返回 etcd 的版本 JSON,就说明认证没开,而且进程在应答。再到另一台机器上对公网地址请求 http://<主机>:2379/version。能返回同样的 JSON,说明安全组已经放行,不需要登录就能读配置。连接成功但长时间没有 HTTP 响应,只能说明链路奇怪,不能当成“端口是安全的”。

80 和 443 要留给正常业务。没有 SNI 时连 IP 的 443 会握手失败,这是 APISIX 没有默认证书,不是端口被封。

配置写进去之后怎么执行

APISIX 启动后会监听 etcd 的 /apisix 前缀。前缀下的路由、上游、证书、全局规则一旦变化,网关热加载,不需要重启容器。

因此触发链是:

  1. 外部客户端连上 2379。没有认证时,读和写使用同一条路。
  2. 客户端在 /apisix/global_rules/ 或 /apisix/routes/ 下写入新键。
  3. APISIX 收到变更,把对象装进当前配置。
  4. 对象里如果带了 serverless-pre-function,其中的代码会在网关进程里、正式转发之前运行。
  5. 全局规则对经过这台网关的请求都会走到这段逻辑。伪装成健康检查的路由则挂在特定路径上,用来反复触发。

这次 etcd 里多出来 10 个对象,正式路由没有被改。这 10 个对象的插件都是 serverless-pre-function,执行阶段都是 access。

access 是 APISIX 选定路由之后、把请求转给上游之前。插件配置里是一段函数,网关在这个阶段直接执行。执行环境是 APISIX 容器里的 Nginx 进程。这套 compose 把容器用户设成了 root,所以函数能在容器里写文件、拉远程程序。它不需要再打 Admin API,也不需要登录 Dashboard。

全局规则

全局规则不看路径和域名。请求只要进了这台网关,这条规则就会跑。键名是字母 g 加上机器公网地址(去掉点)。这是扫描器按目标机器自动生成的名字,不是在 Dashboard 里手工建的。

函数的动作是:向外部地址的几个备用端口发请求,把返回内容写进容器的 /tmp,再执行。容器里能对上的文件是:

  • /tmp/.kv,大约 8.3MB,时间是 10 月 16 日,是拉下来的程序
  • /tmp/.kw.sh 和 /tmp/.kx,时间是 10 月 20 日,是配套脚本和再次投放的文件

宿主机计划任务里没有这些名字。矿工程序只在 APISIX 容器里。机器刚启动时 CPU 可以是空的,因为函数要等下一次有人访问 80 或 443 才会再跑。只删 /tmp 里的文件不够。全局规则还在 etcd 里,下一次访问会再拉一次。

九条假路由

这 9 条都没有上游。它们被匹配到时,只执行函数。内容只有两种。名字和路径用来混进路由列表,并让扫描器回头确认已经写进去。

数量 名字 路径 作用
3 health-check /* 任意路径都能匹配
6 healthcheck_ 加一串数字 /pwned_ 加同一串数字 只有访问这个探测路径才匹配

3 条 /* 的函数相同,6 条 /pwned_* 的函数也相同,只是 ID 不同。这是同一种配置被重复写入,不是 9 套业务。/* 没有绑定域名,优先级又和普通路由一样。正常带域名、路径更具体的业务路由仍然会先被选中,所以站点看起来还能打开。/pwned_* 是扫描器自己来访问的确认地址,正常用户不会碰到。

这些函数比全局规则更直白:在容器里写一条每分钟执行的计划任务,任务是下载脚本并运行,同时立刻再执行一次。计划任务写在容器内的 /etc/cron.d,不是宿主机。镜像里就算没有正常的 cron 服务,立刻执行的那一次仍然会跑。所以宿主机 crontab 是干净的,不能用来证明容器没被写过。

两类都要删

全局规则负责任何人访问网站就再投放一次。假路由负责扫描器访问特定路径,或误匹配到 /* 时,在容器里留下每分钟执行的任务。两边用的远程地址不是同一套,落地文件名也不同,所以同一个容器里会看到两拨痕迹。

删除时只删这 10 个键。重建容器可以清掉 /tmp 里的文件。etcd 数据卷还在的话,这些键会跟着新容器再加载。只看宿主机 crontab,会得出没有中招的错误结论。

正式业务的路由、上游和证书也在同一个前缀里。清理时要按键删除,不能把整个前缀或整个数据卷清掉。

怎样确认已经被写过

先做快照,再查。快照文件放到宿主机,别只留在容器里:

docker exec <etcd容器名> etcdctl snapshot save /tmp/etcd-before-clean.db
docker cp <etcd容器名>:/tmp/etcd-before-clean.db ./etcd-before-clean.db

然后只列出键名,先不要把值打到终端历史里:

docker exec <etcd容器名> etcdctl get /apisix/global_rules --prefix --keys-only
docker exec <etcd容器名> etcdctl get /apisix/routes --prefix --keys-only
docker exec <etcd容器名> etcdctl get /apisix --prefix | grep -c serverless-pre-function

自己的配置里本来就没有 serverless-pre-function 时,计数应该是 0。大于 0 就要逐条对照:哪条全局规则不是自己建的,哪些路由的名字、路径对不上现有站点。常见伪装是“健康检查”之类的名字,路径则是通配,或者是一串不存在的探测路径。

值要导出到文件再看,确认里面是不是在拉远程地址、写临时目录、执行下载下来的程序。确认是恶意对象后,只删除这些键:

docker exec <etcd容器名> etcdctl del <恶意键名>

删完再数一次 serverless-pre-function,并核对正式路由的键还在。路由 ID 是一长串数字,删除前把要留的 ID 抄下来。

容器里的落地文件单独看。进入 APISIX 容器检查临时目录里异常的脚本和可执行文件,记下时间。这些文件随着容器重建会消失,但 etcd 里的键不会。

修复时怎么改 compose

目标是:etcd 只留在 Compose 网络里给 APISIX 和 Dashboard 用,宿主机不再发布 2379。正式路由所在的数据卷保留。

不要执行 docker compose down -v。-v 会删掉名为 etcd_data 的卷,正式路由、上游和证书一起没掉。

修改前先复制一份 compose 文件。etcd 服务里要删掉整个 ports 段,而不是只把端口行注释掉。只注释列表项时,ports: 后面没有合法列表,docker compose 会直接拒绝启动。改完后的 etcd 服务类似:

  etcd:
    image: bitnami/etcd:3.5.11
    restart: always
    volumes:
      - etcd_data:/bitnami/etcd
    environment:
      ETCD_ENABLE_V2: "yes"
      ALLOW_NONE_AUTHENTICATION: "yes"
      ETCD_ADVERTISE_CLIENT_URLS: "http://etcd:2379"
      ETCD_LISTEN_CLIENT_URLS: "http://0.0.0.0:2379"
    networks:
      apisix:
        ipv4_address: 172.25.0.2

ETCD_LISTEN_CLIENT_URLS 写成容器内的 0.0.0.0 只表示容器里的进程在监听。没有 ports 时,这个监听不会出现在宿主机网卡上。APISIX 仍通过网络别名 http://etcd:2379 访问。

Dashboard 如果还要给内网用,把发布地址写成内网 IP,不要写成 0.0.0.0:

    ports:
      - "<内网IP>:9000:9000/tcp"

80 和 443 保持原样。9180 继续不要映射。

然后在 compose 目录执行:

docker compose up -d --force-recreate

不要加 -v。重建后确认三件事:etcd 容器健康、正式路由键还在、宿主机上已经没有 0.0.0.0:2379。

docker exec <etcd容器名> etcdctl endpoint health
ss -lnt | grep -E ':2379|:9000' || true

9000 若按上面的写法,应只出现在内网地址上。从公网再访问一次 2379 的 /version,不应再得到版本 JSON。

ALLOW_NONE_AUTHENTICATION 这次先留着。认证一旦打开,APISIX 和 Dashboard 里都要补上同一套 etcd 账号,配错会导致网关读不到配置。端口已经不再发布到宿主机之后,优先保证业务连续;etcd 账号可以另排一次变更,不要和清恶意键、改端口放在同一次重启里。

暂时不能重建容器时

有的机器上 APISIX 已经连续跑了很久,前面还挂着正在用的站点,不适合马上 up --force-recreate。这时可以先在 Docker 转发链上丢掉新进来的连接,容器和 etcd 数据都不动。

规则要同时满足三条:从外网网卡进来、状态是新建连接、原始目标端口是 2379 或 Dashboard 的 9000。

IF=$(ip route get 1.1.1.1 | awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}')
iptables -I DOCKER-USER -i "$IF" -p tcp -m conntrack --ctstate NEW --ctorigdstport 2379 -j DROP
iptables -I DOCKER-USER -i "$IF" -p tcp -m conntrack --ctstate NEW --ctorigdstport 9000 -j DROP

--ctstate NEW 不能省。省掉之后,匹配的是“这台机器上曾经把某个连接的目标端口当成 9000”的全部转发流量。网关主动访问上游的 9000 时,回包也会被丢掉,经这台网关反代到 Dashboard 的域名会超时。加上 NEW 之后,只拦截从外网新发起的连接,已经建立的回包继续放行。

改完立刻用正式域名访问一次 80/443,并在容器内执行 etcdctl endpoint health。业务请求和容器内访问 etcd 都应正常。

这台机器上的其他发布端口,例如缓存、队列、业务进程自己的端口,不要按同样方式一刀切。那些端口可能被别的程序从内网使用。先逐个确认没有调用方,再决定是否关闭。

DOCKER-USER 里的规则在 Docker 重启后可能消失。把上述命令放进开机脚本,并等 DOCKER-USER 链出现后再插入。云安全组仍然要继续拒绝 2379 和公网 9000。安全组是持久控制,iptables 只是补上“安全组被误开”的那一层。

UFW 的 deny 2379 经常挡不住 Docker 已经映射的端口,原因在上一篇里写过:发布端口走的是 Docker 自己的转发链。

Dashboard 密码

密码写在 dashboard_conf/conf.yaml 的 authentication.users 里,和 Admin API 的 admin_key 不是同一套。

含有 # 的密码必须加引号。YAML 里 # 开头是注释,下面这种写法实际生效的只有井号前面的部分:

      password: place#holder

应写成:

      password: "place#holder"

同时把 authentication 下的 secret 从示例里的默认值换成一长串随机字符串。这个值用来签 Dashboard 的登录令牌。改完后只重启 Dashboard 容器,不要重启 APISIX:

docker restart <dashboard容器名>

再用登录接口做一次检查,确认返回成功且带有令牌。旧令牌会失效。检查命令里不要把密码写进可被别人翻到的历史文件;从已经改好的配置里读取即可。

登录接口只需要在本机或内网地址上测。公网如果已经被前面的规则拦住,从公网测 9000 超时是预期结果。

做完后的复查

按这个顺序看,避免只看一个端口就收工:

  • 宿主机不监听 0.0.0.0:2379,公网请求 /version 拿不到 etcd 的版本信息。
  • etcdctl auth status 仍然可以在容器内执行,endpoint health 为健康。
  • /apisix/routes 里自己的路由还在,serverless-pre-function 计数为 0。
  • 正式域名的 80、443 返回和修复前一致。反代到别的机器上的 Dashboard 时,确认该域名也能打开。
  • Dashboard 只绑在内网地址,新密码可以登录,旧会话已退出。
  • APISIX 容器的临时目录里没有新的异常可执行文件。
  • 没有执行过 docker compose down -v,etcd 数据卷还在。

allow_admin 若仍是 0.0.0.0/0,只要 9180 没有映射出来,外部还打不到 Admin API。以后如果有人把 9180 加回 ports,这行白名单会立刻变成全放行。示例里的 admin_key 只要还在配置文件里,就换成自己生成的值。

同一套 compose 如果部署了多台,每台都要看 2379 有没有映射。安全组暂时挡住公网,只说明当前进不来,不说明 compose 是安全的。容器一直不重建时,用上面的 DOCKER-USER 规则先补上,再安排维护窗口去掉 ports。

免责声明

本文只记录一次配置暴露后的排查和修复,用来核对端口、etcd 键和容器数据卷。它不能代替 Apache APISIX、etcd 的官方安全公告,也不能代替云厂商的安全组策略。恶意脚本和下载地址没有写入本文。/tmp 下的文件名只作为在自己容器里对照的标记。不要在不属于自己的系统上连接 2379 或写入 /apisix。生产变更前先做 etcd 快照,并在维护窗口验证正式站点。


最后修改于 2026-10-29

此篇文章的评论功能已经停用。