跳到正文
SL Blog 技术探索 · 工程实践 · AI 时代思考
返回

一次 DSH「Host 头信任」漏洞的排查与全链路加固实录

文章目录
  1. 背景:一个 9.8 分的漏洞通报
  2. 第一步:摸清自己的真实暴露面
  3. 第二步:模拟攻击者,逐跳验证
  4. 第三步:真正的风险在横向移动
  5. 第四步:加固实施
  6. 4.1 Docker 端口白名单(关键认知:docker 不走 INPUT 链)
  7. 4.2 规则持久化:重启不丢
  8. 4.3 fail2ban 覆盖 PostgreSQL(踩了两个坑)
  9. 4.4 密钥从 compose 明文移到 .env
  10. 4.5 PostgREST 从 superuser 降为最小权限角色
  11. 4.6 收回 3080 的本机访问权(uid 白名单)
  12. 4.7 取证后收回 rag 库公网映射
  13. 最终验证矩阵
  14. 几点心得

背景:一个 9.8 分的漏洞通报

某天看到一份漏洞通报(QVD-2026-57410):DeepSeek Harness(DSH)的 /api 接口用 HTTP Host 请求头判断请求是否来自本机回环,以此把一批高权限 RPC 接口(bash、文件读写、代码执行)限定在本地访问。

问题在于:Host 头是客户端可以任意构造的字段。如果 DSH 被部署到公网或经反向代理对外暴露,攻击者只需伪造 Host: 127.0.0.1,就能让高权限接口把远程请求当成”本机请求”放行,进而注册虚假 LLM Provider、驱动 Agent 工具执行任意系统命令——整条链路不需要任何有效 API Key,CVSS 9.8。

我正好在一台云服务器上远程使用 DSH,于是做了一次完整的排查和加固。这篇是全过程实录,文末有可复用的脚本。

第一步:摸清自己的真实暴露面

漏洞公告说的是”受影响版本 X”,但安全排查不能只看版本号,要看实际部署形态。三个关键事实:

# 1. 安装的版本
cat /path/to/dsh/package.json | grep version
# 2. 监听地址——决定外部能否直达
ss -tlnp | grep 3080
# 3. 进程启动参数
ps aux | grep dsh

我的情况:

  • DSH 监听 127.0.0.1:3080,公网不可直达 ✅
  • 但存在一条远程访问链:公网 → nginx:8080 → 认证代理:3081 → dsh:3080
  • 启动参数里有 --trusted-host——这正是”信任 Host 头”的模式,通报的根因在这个版本里同样存在

也就是说:漏洞代码模式存在,但能否被利用,取决于前置链路是否先做了认证。

第二步:模拟攻击者,逐跳验证

不猜,直接打。对每一跳分别测试”未认证 + 伪造 Host”会发生什么:

# 1. 经 nginx 入口,不带任何凭证
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/
# → 303(跳转登录页)✅

# 2. 经 nginx,伪造 Host 头打 RPC
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
  -H 'Host: 127.0.0.1:3080' -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"bash.exec","params":{"command":"id"}}' \
  http://127.0.0.1:8080/api/rpc
# → 401 ✅ 认证在 Host 改写之前拦截

# 3. WebSocket 通道同样未认证
curl -s -o /dev/null -w '%{http_code}\n' \
  -H 'Connection: upgrade' -H 'Upgrade: websocket' \
  http://127.0.0.1:8080/api/events.mux
# → 401 ✅

# 4. 本机直连 3080 伪造 Host(验证漏洞模式确实存在)
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: 127.0.0.1' http://127.0.0.1:3080/api/xxx
# → 404 而非 403:Host 信任判断生效了,伪造被接受 ⚠️

结论:远程攻击链在第二步就被 401 斩断——认证代理先做认证、通过后才把 Host/Origin 改写为 loopback 转发。这恰好命中通报给出的缓解措施”反向代理层补一道独立认证”。

但第 4 条测试说明:那层”Host 头信任”的窗户纸真实存在,任何能在本机回环上发请求的进程都能捅破它。

第三步:真正的风险在横向移动

盘点这台机器上其他暴露在 0.0.0.0 的服务——其中任何一个被攻破或有 SSRF,都能成为打 127.0.0.1:3080 的跳板:

ss -tlnp | grep 0.0.0.0
docker ps --format '{{.Names}}\t{{.Ports}}'

结果不容乐观:

端口服务状态
5432PostgreSQL(rag 库)公网裸奔,日志里正被爆破(pgg_superadmins、wog 之类的扫描器用户名)
18154PostgreSQL(业务库)公网暴露
3000PostgREST API公网裸奔
5244/5245Alist 网盘公网裸奔
8081静态博客公网(静态文件,几乎无攻击面)

而防火墙现状:ufw 未启用,iptables 只有一个”封扫描器 IP”的黑名单链——追着坏人跑的被动防御,默认全放。

第四步:加固实施

4.1 Docker 端口白名单(关键认知:docker 不走 INPUT 链)

Docker 发布端口走 DNAT/FORWARD,ufw 和 INPUT 链的 DROP 根本碰不到它,必须在 DOCKER-USER 链上下手:

# 已建立连接、本机回环、docker 内网放行
iptables -I DOCKER-USER 1 -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN
iptables -I DOCKER-USER 2 -s 127.0.0.0/8 -j RETURN
iptables -I DOCKER-USER 3 -s 172.16.0.0/12 -j RETURN   # 容器间互访不受影响
# 其余来源访问 3000/5244/5245 一律丢包
iptables -A DOCKER-USER -p tcp -m multiport --dports 3000,5244,5245 -j DROP

验证(经公网 IP 访问,源地址非回环,模拟外部):

curl -m 5 http://<server-ip>:3000/    # 超时,DROP 计数器增长 ✅
curl -m 5 http://127.0.0.1:3000/      # 200 ✅

4.2 规则持久化:重启不丢

iptables 规则重启即失,且 docker 重建链时也会冲掉 DOCKER-USER 的内容。写了一个幂等脚本 + systemd oneshot 单元(After=docker.service),开机自动重放全部规则。要点是每条规则先 -C 检查再 -I/-A 插入,保证幂等可重复执行。

4.3 fail2ban 覆盖 PostgreSQL(踩了两个坑)

fail2ban 原本只护了 SSH。给数据库加爆破防护需要解决两个问题:

坑 1:docker 日志里捞不出 IP。 一个库的 log_line_prefix 不含 %h,认证失败日志没有客户端 IP。解决:

ALTER SYSTEM SET log_line_prefix='%t [%p]: [%l-1] user=%u,db=%d,app=%a,client=%h ';
SELECT pg_reload_conf();  -- 无需重启

坑 2:fail2ban 正则的两个反直觉行为。

  • datepattern 匹配成功后,failregex 作用在剥离时间戳的行上,^ 锚点会失效——别用 ^ 开头
  • 针对 docker json 日志,指定 datepattern = "log":"%Y-%m-%d %H:%M:%S 可绕开一个 datedetector 的 IndexError bug

最终的 filter / action / jail:

# filter.d/postgresql-docker.conf
[Definition]
datepattern = "log":"%%Y-%%m-%%d %%H:%%M:%%S
failregex = client=<ADDR>[^"]*FATAL:\s+(password authentication failed|unsupported frontend protocol|no pg_hba\.conf entry)

# action.d/iptables-docker-user.conf —— 封禁必须进 DOCKER-USER,进 INPUT 没用
[Definition]
actionstart = iptables -w -N f2b-postgres
              iptables -w -C DOCKER-USER -j f2b-postgres || iptables -w -I DOCKER-USER 1 -j f2b-postgres
actionban   = iptables -w -I f2b-postgres 1 -s <ip> -j DROP
actionunban = iptables -w -D f2b-postgres -s <ip> -j DROP
actionstop  = iptables -w -D DOCKER-USER -j f2b-postgres
              iptables -w -F f2b-postgres
              iptables -w -X f2b-postgres

# jail.d/postgresql-docker.conf
[postgresql-docker]
enabled  = true
filter   = postgresql-docker
backend  = polling                # glob 路径用 polling 才可靠
logpath  = /var/lib/docker/containers/*/*-json.log   # 通配,容器重建不换路径
maxretry = 10
findtime = 600
bantime  = 3600
action   = iptables-docker-user

参数故意调保守(10 次/10 分钟才封 1 小时),合法用户输错几次密码不会被锁。上线几分钟后 Total failed 就开始增长——扫描器还在孜孜不倦。

4.4 密钥从 compose 明文移到 .env

检查时发现数据库密码、JWT secret 明文写在 docker-compose.yml 里(644 权限,谁都能读)。改为 ${VAR} 插值 + .env(chmod 600):

environment:
  POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
  PGRST_DB_URI: ${PGRST_DB_URI}
  PGRST_JWT_SECRET: ${PGRST_JWT_SECRET}

密码值不变 → 远程客户端零感知。别忘了把含旧密钥的 compose 备份文件也 chmod 600。

4.5 PostgREST 从 superuser 降为最小权限角色

这是收益最大的一项。原来 PostgREST 直接用 superuser postgres 连库——API 层的任何缺陷都等于数据库最高权限。改为标准的 authenticator 模式:

-- 低权登录角色:本身无任何表权限,只允许切换角色
CREATE ROLE authenticator LOGIN NOINHERIT PASSWORD '<强随机密码>';
GRANT web_anon TO authenticator;   -- 匿名只读
GRANT web_user TO authenticator;   -- JWT 认证后可写

PGRST_DB_URI 改用 authenticator,重建 postgrest 容器(数据库本身不用动,停机约 2 秒)。日志确认:

connection authenticated: identity="authenticator" method=scram-sha-256

4.6 收回 3080 的本机访问权(uid 白名单)

针对”本机跳板”这条路,用 iptables owner 模块按进程 uid 收口:

iptables -A OUTPUT -o lo -p tcp --dport 3080 -m owner --uid-owner ubuntu -j ACCEPT
iptables -A OUTPUT -o lo -p tcp --dport 3080 -m owner --uid-owner root   -j ACCEPT
iptables -A OUTPUT -o lo -p tcp --dport 3080 -j DROP

验证:DSH 远程链路正常(走 nginx→3081,不经 3080),而 sudo -u nobody curl 127.0.0.1:3080 直接被 DROP。即使某个服务被攻破拿到低权 shell,也摸不到 DSH 的高权限接口。

4.7 取证后收回 rag 库公网映射

5432 要不要收回,用证据说话:

ss -tn state established '( dport = :5432 )'   # 无远程客户端在线
grep POSTGRES_HOST /path/to/.env                # 127.0.0.1——本地应用直连

证据表明这个库只有本机应用在用,公网上唯一的”访问者”是爆破扫描器。于是把 compose 端口改为 127.0.0.1:5432:5432 收回,本地应用验证无感。

最终验证矩阵

检查项预期实际
DSH 根路径未认证303 跳登录✅
DSH 登录页200✅
伪造 Host 打 RPC401✅
业务库 18154 远程认证正常登录✅
公网访问 3000/5244/5432不可达✅
本机访问上述服务正常✅
静态博客 8081200✅
fail2ban jail运行中,已捕获新爆破✅

几点心得

  1. “不受漏洞影响”要靠验证,不是靠版本号。公告没列我的版本,但根因代码模式实测存在——真正决定风险的是部署形态和前置认证。
  2. docker 时代的防火墙直觉是错的。ufw deny 对 docker 发布端口无效,必须懂 DOCKER-USER;fail2ban 封禁同理。
  3. superuser 直连业务组件是最常见的隐性巨坑。PostgREST 用 postgres 超管连库这种配置,在无数项目里躺着。
  4. 加固可以全程零业务中断。所有动作(防火墙增量规则、.env 迁移、角色降权、端口收回)都是可验证、可回滚的小步操作,每步执行前后各跑一次验证矩阵。
  5. 黑名单式防火墙是心理安慰。154 条”封扫描器 IP”的规则挡不住明天的第 155 个扫描器,默认拒绝 + 白名单才是正解。

残余风险也诚实记录:业务库 superuser 仍可从 18154 远程登录(业务需要),下一步计划用 Tailscale 组网后把数据库端口彻底收回内网。安全没有终点,只有不断缩小的暴露面。


RAG 智能问答

针对本文继续提问:《一次 DSH「Host 头信任」漏洞的排查与全链路加固实录》