云厂商报挖矿时,先别把容器删掉。这套 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 前缀。
先看哪几个端口
这次能被写配置,同时满足了三件事:
docker-compose.yml里 etcd 有ports: "2379:2379/tcp",宿主机所有网卡都能接到这个端口。- 环境变量
ALLOW_NONE_AUTHENTICATION为yes,etcd 不校验账号。 - 云安全组或宿主机防火墙没有拦住
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 前缀。前缀下的路由、上游、证书、全局规则一旦变化,网关热加载,不需要重启容器。
因此触发链是:
- 外部客户端连上
2379。没有认证时,读和写使用同一条路。 - 客户端在
/apisix/global_rules/或/apisix/routes/下写入新键。 - APISIX 收到变更,把对象装进当前配置。
- 对象里如果带了
serverless-pre-function,其中的代码会在网关进程里、正式转发之前运行。 - 全局规则对经过这台网关的请求都会走到这段逻辑。伪装成健康检查的路由则挂在特定路径上,用来反复触发。
这次 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
此篇文章的评论功能已经停用。