续:换了密码,他还是进来了 —— 青龙面板攻防复盘(下)
一、前情提要
上篇《我的青龙面板被黑了半年》记录了第一轮攻防:境外黑产从 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 | A /bin/softirq ← 伪装成内核线程名 |
这些文件落地时间是 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 | # 下载源(多镜像互备) |
对方很懂「因地制宜」:Gitee、GitLab、GitHub 加速镜像三路互备,专治国内网络的下载问题。
4.2 十月的版本:反向 Shell 后门
昨晚新下载的版本摇身一变,不再是矿工,而是可交互控制的后门(脚本内标签 TAG=corecheck):
1 | LHOST="${LHOST:-172.86.86.60}" # C2 服务器 |
脚本内置六种回连实现,按环境可用性依次尝试: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 | // 第一层:验签名(无状态) |
两层看起来完备,但对方有容器 root——第二层要的「会话列表」,他可以自己往数据库里写。用一个必输的场景推演:默认密钥签令牌 → 直写数据库把令牌塞进会话列表 → 一个「完全合法」的会话就诞生了,登录日志里不会留下任何记录。
这也完美解释了两件事:为什么他在我清空会话列表之后还能操作;为什么他改完密码还要自己再登录一次(保持一个「正常」会话做掩护)。
5.2 密码明文存储
青龙 2.20.0 把面板密码以明文存在 keyv.sqlite 里。容器 root 读一下数据库文件就能拿到我三天前刚设置的新密码——密码轮换在这种存储方式面前毫无意义。
这个缺陷在 2.22.0 里被官方修掉(下文验证)。
5.3 OpenAPI 应用密钥
面板里有一个部署平台创建的 OpenAPI 应用(scope 含 crons、system)。密钥在面板 UI 里明文可读——对方有数月面板访问权限,这份密钥大概率也早已被复制。拿着它可以直接调 OpenAPI 创建/运行任务,等于另一条 RCE 通道,且不受「面板密码」约束。
5.4 回看上篇:我处置时踩的三个坑
诚实复盘,上篇的处置有三处错误:
- 只 restart 未 recreate → 容器可写层的后门全部存活(第三节);
- 没查/没设
JWT_SECRET→ 改密码挡不住伪造令牌(5.1); - 以为「清空 tokens = 吊销会话」 → 对一个能直写数据库、能伪造令牌的对手,这层防线形同虚设。
结论:上一轮我治的是症状,这一轮才治了病。
六、处置:从「重启」升级为「重建」
这轮的处置原则只有一条:假设容器内的一切都不可信,全部换新;假设所有凭据都已泄露,全部轮换。
- 切断:
docker stop—— 对方的会话立即失效; - 取证:
docker cp出/tmp全部样本、pm2 日志、两个数据库、docker diff输出,归档到独立目录(这步必须在新容器创建之前做,重建后证据即销毁); - 重建:
docker compose up -d --force-recreate—— 干净文件系统,后门归零; - 补上致命一环,给 docker-compose.yml 增加:
1 | environment: |
- 全面轮换:面板密码 + JWT_SECRET + OpenAPI client_secret,并清空面板会话列表与 OpenAPI 令牌;用户名一并复位;
- 镜像升级:
whyour/qinglong2.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 | import hmac, hashlib, base64, json, time, subprocess |
结果矩阵(全部实测):
| 攻击向量 | 结果 | 含义 |
|---|---|---|
| 新密码登录 | 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 说明签名过了、卡在会话列表层(令牌不在册)。看响应就能定位伪造尝试打到了哪一层。
八、教训清单(升级版)
- 容器被入侵后必须 recreate,不是 restart。
docker diff列出自镜像以来的全部改动,是容器取证第一条命令;/tmp、/dev/shm、/var/tmp是载荷最爱落地的三个位置; - 开源面板先查默认密钥。青龙的
JWT_SECRET不设置就用公开默认值——改密码、清会话都挡不住伪造令牌。同类产品有一个查一个; - 「改密码」永远不等于「清访问」。真正的吊销 = 换签名密钥(JWT_SECRET)+ 轮换所有 API/应用密钥 + 重建容器;
- 旧版青龙密码明文存库——只要没升级到 2.22+,就将面板数据库视为绝密文件,任何容器内代码可读即泄露;
- 端口暴露是原罪。做到安全组白名单,或改绑
127.0.0.1用 SSH 隧道访问:1
ssh -N -L 15700:127.0.0.1:15700 root@<服务器> # 浏览器访问 http://127.0.0.1:15700
- 别被空日志骗了。攻击者会把输出重定向到
/dev/null再后台执行——日志干净时,去查文件系统和进程; - 镜像版本就是安全水位。落后十个月 = 十个月补丁缺席;定期
docker pull+ recreate; - 升级后顺手开 两步验证(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 |


