从一次 I/O 告警说起:追踪一条跨越应用、主机和数据库的入侵链
本文目录
D1 凌晨 04:13,最先暴露出来的是几个看似常见的故障信号:HTTP 错误、磁盘 I/O 异常,以及大量数据库连接。顺着活跃会话查到进程树,问题开始变得不一样——PostgreSQL 正在派生 shell,搜索系统文件。再沿日志往前追,公网查询接口、Doris 文件读取、共享 SSH 私钥和多台主机的横向登录,逐渐连成了一条线。
追查到最后,已经能确认的影响包括:6 台主机发生异常 root SSH 登录,另有生产主库和从库出现 OS postgres 权限的命令执行。主库 44.50 MB、50,124 条逻辑变更正文已到达受控节点;从库还有 55.99 MB 的回传网络记录。先记住这两组数字,后面会再拆开看:它们的统计口径不同,不能直接相加成“总泄露量”。
这篇文章先把路线和时间线铺开,再回到凌晨的告警现场,讲这条链是怎样一步步被找出来的。其中有几处很容易误读:查询失败了,数据却可能已经被读走;SQL 报错了,系统命令却可能已经执行。也有一些问题至今没有答案,包括管理员密码最初来自哪里、最终向外传了多少数据,以及全部受影响用户有多少。走到这些地方时,会把已知和未知分开讲。
本文依据脱敏调查笔记整理。真实身份、地址和凭据使用代号或省略;保留钟点、数量、接口及技术细节。D0 / D1 / D2 表示相对日期,文章日期不是事件日期。
读下去之前,先认几个代号
节点和账号
| 代号 | 含义 |
|---|---|
| D0 / D1 / D2 | 事件前一天、主要入侵与故障日、后续复核日;保留北京时间钟点,隐藏日历日期 |
| EXTERNAL-A | 已与异常 SSH、部分 HTTP 活动关联的外部来源;不代表已识别自然人 |
| QueryConsole | 公网查询管理应用,域名统一写成 manager.example.com |
| APP-TEST / APP-PROD | 测试应用服务器 / 承载数据库管理网关的生产应用服务器 |
| OPS-1 / OPS-2 / OPS-3 | 三台调度或运维节点,其中 OPS-1 是主要横向与取数节点 |
| ANALYTICS-1 / 2 / 3 | 三台 Doris 节点 |
| DB-PRIMARY / DB-REPLICA | 生产 PostgreSQL 主库 / 从库 |
| DB-AUX / DB-AGENT | 另外两个被访问的数据库实例 |
| query_service / db_reader / cdc_reader | 应用预置查询账号 / 普通数据库账号 / 复制账号 |
| db_admin | 事件中的高权限数据库账号,真实产品角色名不在示例中保留 |
| KEY-OLD / KEY-NEW | 旧共享 SSH 密钥 / 后续轮换密钥,指纹已替换 |
为了让这条链仍然读得懂,节点关系、统计数字和事件间隔都按调查记录保留。真实 IP、域名、人员姓名、用户 UUID、密钥指纹、查询 ID、项目名称和内部文件定位则换成代号;密码、令牌、私钥正文和业务提示词不在文中展示。
后面会穿插三类代码,读到时可以留意它们各自的用途:
- 原代码逻辑节选:来自已保存源码或构建产物,已删减和改名,说明当时的缺陷。
- 修复示意:讲解应放置的安全边界,不代表已经合入或部署。
- 取证方法的脱敏整理版:基于调查实际采用的方法重新整理,供本地只读分析;不声称与当时执行脚本逐字相同。
哪些已经确认,哪些还差一块拼图
| 等级 | 使用条件 | 本案例子 |
|---|---|---|
| 已确认 | 有实际日志、命令结果或返回正文支持 | Accepted publickey、主库逻辑解码结果文件 |
| 强关联 | 多个独立特征吻合,但缺少唯一关联标识或关键正文 | PEM 文件大小/行数与后续 SSH 指纹吻合 |
| 待验证 | 技术上成立,但没有形成读取到使用的证据链 | 配置表大响应中包含管理员密码 |
| 排除当前归因 | 已有证据表明该记录属于正常操作,或原判断不成立 | 经人员确认的运维 SSH 和报表任务 |
后文说“没有找到”时,都会带上检查范围。毕竟,今天看到的文件、角色和安全组未必还是事发时的样子;扫描命中、HTTP 状态码和字段名称,也都需要后续证据才能说明攻击是否成功、数据是否泄露。这一点会贯穿整篇复盘。
先看这条链是怎样连起来的
先抓住三个位置:APP-TEST 是查询入口所在的测试应用服务器,OPS-1 是取得 root 后的主要跳板,APP-PROD 承载通往主库的管理网关。 后面的时间线和日志关联都围绕这些节点展开。
公网请求
│
▼
QueryConsole 的 /api/query 缺少服务端认证与授权
│ 使用服务器自己的 query_service 凭据
▼
Doris 查询服务
├─ 查询业务与认证相关数据
└─ LOCAL 读取 BE 节点文件
│ BE 当前运行身份为 root,root 目录文件已有读取证据
▼
疑似获得 KEY-OLD 私钥〔强关联,缺当次 PEM 返回正文〕
│
▼
EXTERNAL-A → root SSH → OPS-1
├─ KEY-OLD → 另外五台主机 root
├─ cdc_reader → DB-PRIMARY → 逻辑解码 → 结果落盘 OPS-1
├─ db_reader → DB-REPLICA 代理 → 配置查询、大响应
├─ db_admin〔凭据初始来源未明〕→ DB-REPLICA → OS postgres
└─ 其他数据库 → 配置与审计样本读取
EXTERNAL-A / 已受控 DB-REPLICA
│ 调用生产数据库管理接口
▼
APP-PROD 上的 Kong → Supabase Meta /query
│ 使用 Meta 服务端数据库凭据
▼
DB-PRIMARY 的 db_admin 会话
├─ 跨表搜索密钥等内容
├─ COPY PROGRAM → OS postgres 文件扫描
└─ 从主库访问 APP-PROD 的 Redis → 部分任务与配置读取
读这张图时,最好始终分清四种身份:发起请求的 HTTP 身份、应用连接数据库的账号、运行数据库服务的 OS 用户,以及 SSH 登录用户。比如,在 OPS-1 上用 root 启动数据库客户端,并不会让远端数据库服务也变成 root。
尤其要记住主库的两个入口:一条是复制账号直接读变更,另一条是管理接口借助受信任应用服务器执行高权限 SQL。后面出现的连接失败和查询成功,很多就来自这两条路线的差别;把它们合并成“管理员账号从公网直连主库”,反而会解释不通日志。
把时间拨回告警之前
告警发生在 D1 凌晨,但能追到的活动从 D0 就开始了。把记录按时间排开,才能看清入口发现、文件读取、横向登录和取数之间的先后关系。表中保留了钟点,也标出了文件时间和证据限制:
| 时间 | 事件 | 判断 |
|---|---|---|
| D0 00:19 | 访问 QueryConsole,下载含默认用户名的公开 JS | 站点和用户名可发现;不是密码泄露证据 |
| D0 00:21–00:30 | 列表读取、任务创建/修改/删除返回成功 | 多个管理接口存在未鉴权问题 |
| D0 12:14 起 | EXTERNAL-A 探测生产数据库管理路径,早期 401/403 | 已发现入口,尚不能算 SQL 成功 |
| D0 16:01–16:53 | 测试后端和会话服务被枚举,并进入访客业务流程 | 有实际业务调用,不能统称全部被拦截 |
| D0 18:30–18:31 | 193 个 API 路径按字母顺序被 POST,命中 /api/query | 连续日志支持字典式路径枚举 |
| D0 18:32:13 | 查询接口 200,Doris 同秒返回 100 行表结构 | 未授权查询已经执行 |
| D0 18:34 | 两个敏感表各返回 100 行 | 数据库返回已确认,明文凭据数量未确认 |
| D1 01:33:47 | LOCAL 读取 ANALYTICS-1 的 root 目录 PEM,27 行、1,675 字节 | 文件读取已确认 |
| D1 01:59:32 | EXTERNAL-A 以 KEY-OLD 登录 OPS-1 的 root | 已确认主机控制 |
| D1 02:15:54–55 | OPS-1 用 KEY-OLD 登录另外五台 root | 已确认横向登录 |
| D1 02:27:04 | DB-PRIMARY 出现 cdc_reader 已认证 SQL | 普通表 SELECT、文件读取部分失败,但账号已进入数据库 |
| D1 02:30–02:36 | DB-AUX 两个账号已认证;DB-REPLICA 的 db_reader 已认证 | 存在多个数据库访问分支 |
| D1 02:44:58〔mtime〕 | DB-AGENT 查询结果保存到 OPS-1 | 7 张表、73 行实际样本 |
| D1 02:53:39 / 02:54:02 | DB-REPLICA 向 OPS-1 回传约 18.35 MB / 17.89 MB | 与 SQL 文件、SSH 会话强关联,正文未保存 |
| D1 03:01:38 | db_admin 直连主库被 HBA 拒绝 | 尚未验证密码,不能算密码正确或认证成功 |
| D1 03:10:23〔mtime〕 | 留存脚本已包含正确的旧管理员密码 | 来源仍未明,mtime 不是取密时刻 |
| D1 03:11:06 | 从库管理员提交目录枚举 SQL,语句失败 | 已认证;后续结果另证实 OS postgres 命令执行 |
| D1 03:17:19 | 从库再回传约 17.89 MB | 查询顺序指向配置表;已晚于管理员认证 |
| D1 03:21–03:25 | 主库逻辑槽创建和读取,结果落盘 OPS-1 | 44.50 MB、50,124 条变更 |
| D1 03:38:59–03:40 | 生产管理接口出现 200;主库出现恶意跨表搜索 | HTTP 与 SQL 日志可相互印证,部分没有共享请求 ID |
| D1 03:44 | 从库经生产管理接口进入主库 | 不同于被 HBA 拒绝的管理员直连路线 |
| D1 04:12:55–04:13:06 | COPY 错误跨层对应,主库开始长时间系统文件扫描 | 与告警观察点对齐 |
| D1 06:12:37 | 主库六个异常后端终止 | 处置点,不是攻击结束点 |
| D1 11:05–11:22 | 从库仍有管理员 SQL、系统程序调用 | 攻击相关活动持续存在 |
| D1 12:50 | 另一运维节点仍有 KEY-OLD 外部 root 登录 | 不能以单次数据库处置结束整个事件 |
| D2 09:45 | QueryConsole 无进程、端口无监听,恢复项已清理 | 应用停用验证通过,不等于所有主机已清理 |
查询入口为什么没有挡住请求
请求借用了服务端的查询身份
先从入口说起。数据库审计里有个很容易把人带偏的线索:查询账号看起来像一位真实员工的姓名。核对应用后才发现,它只是服务端预置的查询账号。审计记下了应用使用谁的身份连接数据库,却没有因此证明是谁发起了请求。
实际调用链是:
POST /api/query
→ 查询助手
→ 规划查询 / 模型生成 SQL
→ 只读 SQL 字符串校验
→ 服务端数据库连接
→ 执行查询
→ 按列名脱敏并返回
原代码逻辑节选,函数和身份字段已泛化:
export async function POST(request: Request) {
const input = await request.json();
const question = String(input.question || "").trim();
// 缺少可信的登录态和权限检查。
const result = await runQueryAssistant({
question,
source: "api",
requesterId: input.requesterId, // 客户端声明,不是认证身份。
});
return Response.json({ ok: result.status === "success", result });
}
底层执行使用服务器配置:
const url = process.env.DB_QUERY_URL || process.env.DATABASE_URL;
const connection = await mysql.createConnection(url);
try {
const [rows, fields] = await connection.query(sql);
// 整理结果。
} finally {
await connection.end();
}
到这里,入口的问题就清楚了:未授权请求者可以直接调用一个本来就能查询数据库的服务,由服务端替他使用高权限账号。现有证据支持的是这条路线,尚不足以归为传统的“用户字符串拼接导致 SQL 注入”。即使连接驱动没有注入漏洞,这个接口也已经把查询能力交了出去。
更容易被忽略的是,同一应用的机器人入口其实有成员/管理员校验,只是 HTTP 入口没有调用它。授权逻辑存在于项目中,并不等于每个入口都经过了它。保存下来的源码和部署构建产物在关键逻辑上吻合,让这个判断有了更扎实的依据;单看文件 mtime,仍然还原不了可靠的部署历史。
修复示意,以下辅助函数均需按实际系统实现:
export async function POST(request: Request) {
const principal = await authenticateServerSession(request);
if (!principal) return new Response("Unauthorized", { status: 401 });
if (!await canQueryApprovedDataset(principal)) {
return new Response("Forbidden", { status: 403 });
}
const input = await parseAllowedQueryInput(request);
return runApprovedQuery({
principal, // 来自可信会话。
queryTemplateId: input.templateId,
parameters: input.parameters, // 值参数化,不接受任意表/函数。
});
}
服务端要对每次敏感操作和对象归属做授权;隐藏按钮、网页登录、CORS 或请求体里的用户 ID 都不能替代它。OWASP Authorization Cheat Sheet
“只允许 SELECT”,到底限制了什么
接口并非完全没有限制。它做了“只允许 SELECT”的字符串检查,还加了 LIMIT。关键逻辑如下:
const forbiddenKeywords =
/\b(insert|update|delete|drop|alter|truncate|create|replace|grant|revoke|merge|call|execute|exec)\b/i;
if (!/^select\b/i.test(compact)) {
return { ok: false, reason: "Only SELECT queries are allowed" };
}
if (forbiddenKeywords.test(compact)) {
return { ok: false, reason: "SQL contains a forbidden keyword" };
}
// 后续还有 LIMIT 限制。
这段检查挡住了部分写入语句,却留下了几个与“能读什么”直接相关的问题:
- 能读取哪些数据库、表、列和系统目录?
- 能调用哪些函数或表函数?
- 能否读取文件、外部资源或认证材料?
- 数据库账号和服务进程分别拥有什么权限?
这次越界就发生在这里:SELECT 调用了 Doris 的 LOCAL 文件表函数,实际读到了 root 目录内容。LIMIT 管的是返回多少条结果,既管不了一次查询的全部执行代价,也挡不住藏在少量文件内容里的秘密。
当前 Doris 4.x 文档说明 LOCAL 涉及管理员权限和安全目录限制;这些控制应按实际版本、账号授权和部署配置核对,不能把当前文档反推成事发版本的配置。现场已证实读取发生,但尚未完整还原当时各项授权配置。Doris LOCAL 文档
换一个列名,脱敏规则就失去了依据
原代码逻辑节选:
function isSensitiveKey(key: string) {
return /(password|passwd|pwd|secret|token|api[_-]?key|credential|phone|email|mobile)/i
.test(key);
}
const redactedRow = Object.fromEntries(
Object.entries(row).map(([key, value]) => [
key,
isSensitiveKey(key) ? "[REDACTED]" : value,
]),
);
下面用完全虚构的常量说明问题,不读取任何真实表:
SELECT 'DEMO_ONLY' AS token;
SELECT HEX('DEMO_ONLY') AS h;
第一条结果的列名是 token,会被遮盖;第二条列名是 h,规则不认识,而 HEX 只是可逆编码。真正的缺陷是任意表达式和别名改变了输出字段名,脱敏规则丢失了字段来源。同样的问题也出现在把整行 JSON、文件内容或日志放进普通字符串列时。
这个问题也出现在保存的实际结果里:三个不同的业务令牌通过类似方式返回了。与此同时,另一些带 Key 字样的结果只是配置引用名称,统计时还要逐项分辨,不能都算成有效密钥。
所以,输出脱敏要放在权限限制之后:先用数据库最小权限、安全视图或预定义查询限定能接触的数据,再处理返回内容。把秘密能否离开数据库完全交给一条输出正则,前面的任意表达式和别名就足以让它失去依据。
一次文件读取,怎样延伸到多台主机
PEM 读取与 SSH 登录之间的 25 分 45 秒
读到文件之后,链条是怎样延伸到 SSH 的?把几处独立记录放在一起,就能看到下面这个顺序:
- Doris 审计显示在 ANALYTICS-1 读取 root 目录的 PEM。
- 返回 27 行、扫描 1,675 字节。
- 当前对应旧私钥的大小、行数相同,公钥指纹与后续登录使用的旧密钥一致,统一记为 KEY-OLD。
- 25 分 45 秒后,EXTERNAL-A 用 KEY-OLD 成功 root 登录 OPS-1。
- OPS-1 很快用 KEY-OLD 横向登录另外五台。
时间间隔、文件特征和后续使用的指纹都对上了,共享私钥经文件读取暴露是一个有力解释。不过,最关键的那次 PEM 响应正文没有保存下来,还差“逐字核对取得的私钥就是这一份”这一步,因此这里保留为强关联。
数据库进程的运行身份放大了影响
这也带出另一个问题:数据库为什么能读到 root 目录?后续复核时,三台 BE 当前都以 UID 0 运行。结合此前已经发生的 root 文件读取,可以看到操作系统身份并没有有效收窄数据库的文件读取范围。
取证时应读实际进程,而不是只看部署说明。下面是调查方法的整理版,仅读取进程身份:
sudo ps -C doris_be -o pid,ppid,user,comm
若进程名或容器环境不同,先确认真正的服务 PID,再读取 /proc/<PID>/status 的 Uid:。不要默认打印完整 args 或环境变量,它们可能包含连接串和密码。
降权是必要的影响收敛措施,但不能代替 SQL 授权:即使进程不是 root,它仍能读取自身配置和业务凭据。服务专用账号、文件最小权限、禁止存放不必要的私钥副本、SQL 能力限制需要同时落实。
进入数据库后,还要分清走的是哪条路
SELECT 被拒绝,变更数据仍然被读走了
再往数据库里看,先遇到的仍然是一批失败日志:复制账号查询部分普通表、读取文件时,都被拒绝了。只看这些记录,很容易以为数据没有被读走。
继续追查却找到了另一条路。逻辑复制槽确实被创建并读取,两份结果已经到达 OPS-1,里面有认证表和业务表的变更。普通 SELECT 被拦下,并没有挡住这次通过逻辑解码取数。
理解这段经历,需要把普通查询、原始复制槽解码和内置发布订阅分开,初始表同步也有单独的权限要求。它并不意味着“复制角色自动等于超级用户”,或“所有逻辑复制都绕过 SELECT/RLS”。真正要核对的是角色、输出插件、复制槽,以及这次实际采用的读取机制。PostgreSQL 逻辑复制安全
peek_changes 不推进槽消费位置,重复读取可能重复返回;记录数不能直接当用户数。复制管理函数
数据库日志里的 localhost 从哪里来
从库这边,则要先穿过一层 TCP 代理。已经确认的路线是:
OPS-1 → DB-REPLICA 上的 TCP 代理端口 → 127.0.0.1:5432
数据库日志中的 localhost 是代理结果,不代表请求来自本机运维人员。代理日志中的 200 是 TCP stream 状态,既不是 HTTP 200,也不是数据库密码认证成功。
判断认证是否成功,需要看到 SQL 执行、明确的认证成功记录或真实结果。对系统文件读取报“权限不足”,通常说明数据库身份已认证,但该功能未获授权;HBA 拒绝则发生在更前面的连接访问控制环节,不能证明密码正确。
管理接口借用了受信任应用服务器的身份
主库还有一组看起来矛盾的记录:OPS-1 和从库使用管理员账号多次直连被拒绝,后来却又出现了高权限 SQL。连接来自哪里,解释了这个差别。
APP-PROD 是主库允许的应用服务器来源,它上面运行着 Supabase 的数据库管理服务 Meta。请求进入管理接口后,Meta 会使用自己的数据库凭据建立连接。于是,下面三件事就需要分开看:
调用者能调用管理 API
≠ 调用者一定知道数据库密码
≠ 调用者一定能直接连接数据库
这次确实另有残留脚本证明管理员密码已经被掌握,但依据来自那份脚本。单凭 Meta 执行成功,还推不出 HTTP 接口曾经返回过数据库密码。
调查保存的网关配置结构如下。只保留路由、认证插件和授权组,没有 Consumer 的凭据;这也不是可直接部署的完整配置:
services:
- name: meta
routes:
- name: meta-all
paths: ["/pg/"]
plugins:
- name: key-auth
config:
hide_credentials: false
- name: acl
config:
hide_groups_header: true
allow: ["admin"]
Key Auth 识别 Key 对应的 Consumer,ACL 再检查其是否属于允许的 admin 组。hide_credentials: false 表示不主动从转发请求中移除 Key,不是关闭认证;hide_groups_header: true 影响上游分组头,不是关闭 ACL。Kong Key Auth、Kong ACL
| 观察 | 能确认什么 | 不能单独确认什么 |
|---|---|---|
| HTTP 401 | 某层认证或身份检查未通过 | 一定未携带 Key,或一定由 Key Auth 返回 |
| HTTP 403 | 某层拒绝访问 | 已取得管理员 Key |
| HTTP 200 | 收到成功 HTTP 状态 | SQL 全部成功、使用了哪把 Key、Key 的来源 |
| Meta 与数据库日志对应 | 部分请求已进入 SQL 执行通道 | 缺共同 ID 时每条请求的唯一对应 |
| 文件声明 key-auth 和 ACL | 保存的配置具有这些控制 | 事发时始终加载相同配置、没有其他路径 |
现场比对没有命中公开示例 Key,也没有在检查的 Nginx 配置中找到自动注入认证头的指令。这排除了两个具体解释,不能因此证明其他泄露路径不存在。状态码变化本身不证明 Kong 或 Supabase 存在被利用的产品漏洞。原调查还核对了对应版本的 Kong 2.8.1 Key Auth 源码与 ACL 源码。
SQL 报错时,系统命令可能已经执行
再回到那棵让调查方向发生变化的进程树。经过脱敏,它长这样:
postgres
└─ sh
├─ base64
└─ bash
├─ grep
└─ head
PostgreSQL 的 COPY ... PROGRAM 是受高权限控制的服务端功能,需要超级用户或相应的 pg_execute_server_program 权限,由数据库服务的 OS 用户执行程序,再处理程序输入/输出。本案中,程序输出已经产生,随后 COPY 的列解析报错;进程树、子进程信号与终止日志共同证明命令已经启动。SQL 最终失败不能撤销已经发生的外部程序执行。PostgreSQL COPY 文档
示意语法仅用于识别日志,命令内容不可执行:
COPY result_table FROM PROGRAM '<脱敏后的系统命令>';
到这一步,能够确认的是主从库上 OS postgres 权限的命令执行。它和前面六台主机上的 root SSH 控制是两类影响,仍需分开统计;这段执行链本身没有证明 root 提权。
回到凌晨 04:13:这条链是怎么查出来的
从连接总数走到具体的后端 PID
前面是事后拼出的完整路线,现场排查的顺序却是反过来的。最初手里只有 HTTP 错误、磁盘 I/O 图和连接总数。连接很多、idle 很多,离找到负载来源还差一步;真正能往下追的是活跃查询时间、事务时间、等待事件和后端 PID。
下面这段会话查询由当时使用的方法整理而来,只返回元数据和粗分类,让排查先聚焦到可疑后端;可能含秘密的 SQL 正文不直接输出:
BEGIN READ ONLY;
SET LOCAL statement_timeout = '5s';
SET LOCAL lock_timeout = '1s';
SET LOCAL TIME ZONE 'UTC';
SELECT pid, usename, application_name,
client_addr, client_port, state,
clock_timestamp() - query_start AS query_age,
clock_timestamp() - xact_start AS transaction_age,
wait_event_type, wait_event,
CASE
WHEN query ILIKE '%COPY%' AND query ILIKE '%PROGRAM%'
THEN 'program_keyword_candidate'
WHEN query ILIKE '%pg_logical_slot%'
THEN 'replication_keyword_candidate'
ELSE 'other'
END AS query_class
FROM pg_stat_activity
WHERE pid <> pg_backend_pid()
AND (state = 'active' OR state LIKE 'idle in transaction%')
ORDER BY query_start NULLS LAST
LIMIT 100;
ROLLBACK;
关键词分类可能误报,不能替代审阅 SQL。真正的语句只保存在受限证据区。只读事务避免业务写入,但查询本身仍会消耗资源;这里通过系统视图、行数和超时限制范围。
pg_stat_activity 不是历史审计,查询文本也可能被截断。进程结束后,应转向持久日志。PostgreSQL 活动视图
顺着 PID,看见数据库派生的 shell
进程仍存在时,使用其准确 PID,优先只读命令名而非完整参数:
# 将 12345 替换成已确认的数据库后端 PID。
sudo ps -p 12345 -o pid,ppid,user,lstart,etime,comm
sudo pstree -p 12345
sudo readlink -f /proc/12345/exe
正是在这一步,PostgreSQL 派生出的 shell 和搜索程序进入了视野,排查也从“慢 SQL”转向了“数据库系统命令执行”。后来进程被结束,已经无法重取当时的现场快照,只能继续从终止日志里找保留下来的完整语句。
日志收集器关着,日志去了哪里
找日志时又遇到了一个岔路:logging_collector=off,pg_current_logfile() 也为空。继续查服务是怎样启动的,才定位到真正的去向——PostgreSQL 的 stderr 被重定向到了发行版管理的日志文件。
先核对设置:
SELECT name, setting
FROM pg_settings
WHERE name IN (
'data_directory', 'log_destination', 'log_directory',
'logging_collector', 'log_line_prefix', 'log_timezone',
'log_connections', 'log_disconnections', 'log_statement',
'log_min_duration_statement', 'log_min_error_statement',
'track_activity_query_size'
)
ORDER BY name;
SELECT pg_current_logfile();
在 Debian/Ubuntu 的主机安装场景中,调查使用了下面的定位方法。版本和 cluster 路径需要按 pg_lsclusters 实际输出调整:
sudo pg_lsclusters
sudo sh -c '
db_pid=$(head -n 1 /var/lib/postgresql/15/main/postmaster.pid)
ps -p "$db_pid" -o pid,ppid,user,comm
readlink "/proc/$db_pid/fd/2"
'
之后对完整日志副本按时间、PID、错误类型筛选,而不是只 grep 一个报错单行。SQL 和 CONTEXT 可能跨行,原始上下文要保留。log_min_error_statement=error 可以留下错误语句;未开启成功语句记录时,无法补出所有成功查询。PostgreSQL 日志配置
从数据库来源回到网关和容器
数据库记录的来源是 APP-PROD,下一步自然要回到这台机器上。此时还没有理由认定它已被 SSH 控制,先沿请求经过的 Nginx、Kong 和 Meta 查下去。
使用过的只读元数据方法,容器名已改为示例名:
sudo docker inspect db-meta db-gateway \
--format '{{.Name}} started={{.State.StartedAt}} restarts={{.RestartCount}} log_driver={{.HostConfig.LogConfig.Type}} log_path={{.LogPath}} ports={{json .NetworkSettings.Ports}}'
不要直接把完整 docker inspect 或 .Config.Env 粘到公共讨论里:它可能包含数据库密码和 API Key。网关路由检查只输出路径、认证插件和 ACL 关系;凭据比较在本机内存完成,只返回布尔值。
这一轮把三层记录接了起来:Nginx 的管理请求、Meta 的请求 ID 与数据库错误,以及数据库后端中的 COPY 语句。有共同标识的请求可以直接对应;部分记录只有时间和错误模式吻合,就只能保留为“强关联”,还无法逐条唯一对应。
SSH 记录里,哪些才算成功登录
Invalid user、preauth 关闭、无效协议 banner 都不是成功登录。必须寻找 Accepted publickey 或 Accepted password,再对照会话结束、账号、源地址与密钥指纹。
应用服务器的留存日志中,没有找到已知攻击来源的成功 SSH。线索却在另一个运维节点上接上了:那里有大量 root 成功认证。再沿 KEY-OLD 的指纹追下去,五台主机的横向登录也浮现出来。
后续出现使用 KEY-NEW 的成功登录,也不能直接宣布新密钥再次泄露。调查先核对操作者,最终有一组 72 次当天登录及 229 次历史登录被确认属于内部人员,从攻击统计中移除。没有逐条时间戳的 shell history 也不能直接归到某个 SSH 会话。
沿审计账号,找回最初的查询入口
主机侧的登录链有了着落,还要回答最初的文件读取从哪里来。沿 Doris 审计里的数据库账号和客户端地址往回查,最终定位到 APP-TEST 的 QueryConsole,再把下面几项逐一对上:
- Nginx 虚拟主机和反向代理目标。
- 应用进程、部署目录与构建文件。
- API 路由是否调用授权逻辑。
- 数据库连接配置是否使用该审计账号。
- 保存的查询结果是否与审计时间和行数对应。
入口日志显示先批量枚举路径,再对 /api/query 调整方法和参数,最终成功执行查询。公开 JS 中找到了默认用户名,却没有找到该查询路径字面量;因此更支持字典枚举发现接口,而不是从 JS 直接抄出接口。
正文没留下,还能从时间关系里找到什么
这条链里最难补齐的一段,是两笔没有保存正文的从库大响应。好在 SQL 文件、SSH 认证记录和代理会话结束记录还在,把它们并排摆开,能看到这样的时间关系:
| 文件 | SSH 接受 | 文件 mtime | 从库响应结束 | SSH 关闭 |
|---|---|---|---|---|
| query_01.sql | 02:53:33.017 | 02:53:33.540 | 02:53:39 | 02:53:39.969 |
| query_03.sql | 02:53:53.789 | 02:53:54.310 | 02:54:02 | 02:54:02.911 |
连续五份文件都有约 0.52 秒的同类关系,使“外部通过 SSH 在 OPS-1 发起查询”具有较强证据。数据库报错还精确匹配了部分脚本语句。
线索到这里仍然有缝隙:代理日志只记到秒,主机历史时钟偏差没有单独测量,也缺少进程执行审计、共同源端口和 stdout 记录。因此,这组关系只能支持强关联,还不足以还原一条完整、确定的执行命令。
两份 SQL 没有设置本地结果输出;如果调用端接收 SSH stdout,结果可能根本没有落盘。本地找不到输出文件,不等于查询没执行或调用者没收到数据。
找到一个密码,还不等于找到管理员密码的来源
密码来源是另一个容易把不同线索混在一起的地方。调度系统的明文数据源、报表系统的可解密配置中,都找到了与攻击脚本完全一致的普通数据库密码。这给普通账号找到了具体的凭据存放位置,管理员密码的来源却还需要另外寻找。
针对管理员旧密码,现存配置检查覆盖了 24,180 行指定字段,加密字段也经过授权解密核对,仍然没有命中。这个结果有明确范围:它比“只搜密文没找到”更充分,但仍覆盖不了事发后已经删除或修改的数据。
下面是本机精确比较的伪代码,不连接数据库,也不输出秘密:
from getpass import getpass
import hmac
expected = getpass("已轮换的事件凭据,仅在本机内存使用: ")
# candidates 来自授权保存的受限证据;不要打印 candidate 值。
for field_id, candidate in candidates:
matched = hmac.compare_digest(
candidate.encode("utf-8"), expected.encode("utf-8")
)
if matched:
print({"field_alias": field_id, "exact_match": True})
这样比较,可以避免在排查输出里再次暴露凭据,但还算不上完整的秘密内存保护方案。旧密码也不适合放进命令行参数,或拼入可能被数据库审计记录的 SQL 文本;输出留下字段代号、匹配布尔值、检查范围和时间就够了。
保存现场时,采集脚本也需要边界
前面的关联能做起来,依赖于日志和现场信息被及时保存。调查中实际用过一个主机型 PostgreSQL/Linux 只读采集器:读取已有日志与配置,采集系统元数据,建立本机只读数据库会话,最后生成证据副本、manifest 和 SHA256。
这里的“只读”也有具体含义:不改业务数据、日志配置和服务状态,但仍然会创建本地证据文件、占用磁盘、建立数据库会话。所以,采集器本身也要有资源上限,并在确认权限后运行。下面两段代码保留了其中比较实用的保护措施。
先给数据库连接加上限制
env = os.environ.copy()
for key in list(env):
if key.startswith("PG"):
env.pop(key)
env.update(
PGCONNECT_TIMEOUT="5",
PGAPPNAME="incident-evidence",
PGPASSFILE="/dev/null",
PGOPTIONS=(
"-c default_transaction_read_only=on "
"-c statement_timeout=5000 "
"-c lock_timeout=1000"
),
)
连接使用本机 Unix socket、psql -X -w,依赖授权的本机认证,不输入截获密码。原始采集器曾保留有限长度 SQL 片段;这些片段可能含秘密,公开整理版只展示元数据。
复制文件也要考虑大小、变化和剩余空间
下面是 Linux 原采集方法的结构节选,省略复制、异常处理和清单写入,不是完整脚本:
os.umask(0o077)
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
with os.fdopen(fd, "rb") as source:
before = os.fstat(source.fileno())
if not stat.S_ISREG(before.st_mode):
record_skip("non_regular_file")
elif before.st_size > remaining_budget:
record_skip("size_or_space_limit")
else:
copy_bounded_and_hash(source, before.st_size)
after = os.fstat(source.fileno())
record_source_changed(
before.st_size != after.st_size
or before.st_mtime_ns != after.st_mtime_ns
)
实际采集器设置了单文件 256 MiB、总量 2 GiB、至少保留 512 MiB 空闲空间等上限。超限、超时、文件变化和读取失败都会写入 manifest。日志按 mtime 选择后可能整份复制,因此复制到的内容不一定全部属于目标时间窗。
拿到采集包以后,还要看原包哈希、文件哈希、成功/跳过数量、时间覆盖,以及是否发生截断或采集中变化。这样才能知道手里的材料到底覆盖了多少。SHA256 可以帮助确认持有的内容是否一致,却无法替已经失陷的主机担保日志从未被修改。
接下来就会碰到 tmp 目录里留下的样本。处理它们时,先静态列出归档成员,检查绝对路径、..、链接和异常体积;确实需要提取,再制定安全路径策略。直接解压后执行,会让分析本身引入新的风险。
tmp 目录留下的脚本,具体做了些什么
主库 tmp 归档包含 526 个普通文件,其中 47 个 Python 文件、86 个 shell 文件。调查采用静态分析、结果文件核对和离线公钥匹配,没有重跑这些脚本,也没有用截获的凭据测试线上接口。
| 样本行为 | 代码/结果显示的作用 | 已确认的影响与限制 |
|---|---|---|
| 身份与目录检查 | 查询运行身份、进程及文件,输出到 tmp | 主库存在 OS postgres 权限执行;不是 root 提权证据 |
| 文件和秘密搜索 | shell、grep、编码工具组合,寻找配置和认证材料 | 数据库日志和进程树证实执行;命中关键词不等于获得全部有效凭据 |
| Redis 配置、键名及任务读取 | 未执行 AUTH 就能读取配置、SCAN 和部分值 | 已取得部分任务字段;日志中的 1,078 万是累计键名返回次数 |
| 内网端口与服务探测 | 网段常见端口、少量主机全端口扫描,检查 SSH 和管理服务 | 开放端口只证明可达;不能当作每台都已入侵 |
| 私钥、JWT 和 SSH 尝试 | 包内存在重复私钥与角色声明为管理用途的候选 JWT | 材料存在已证实;JWT 解码不是验签,具体登录成功另看目标日志 |
| 后台任务 | 通过后台进程运行扫描,结果文件记录结束 | 某次扫描完成已证实,不等于已安装开机持久化 |
| Redis 文件位置修改 | 尝试调整保存目录和文件名 | 留存结果是 protected config 拒绝,不能写成已借此写入系统文件 |
| 元数据、SSRF 和其他管理接口探测 | 尝试不同接口、路径和参数 | 只能逐项判断;例如一次 LOCAL 读取 HTTP URL 实际被当成本地路径并失败,不能算成功 SSRF |
这些样本里,Redis 的结果尤其值得单独看。默认用户配置了 nopass,命令和键权限都很宽,无认证读取也确实拿到了结果。内网可达加上过宽的 Redis 权限,又打开了一条读取任务和配置的路。虽然个别受保护配置拒绝修改,读取仍然能够继续。修复也就需要同时收窄来源、分配业务 ACL,并关闭不必要的管理命令;仅有“只能内网访问”这层限制还不够。
分析样本时,还碰到了同名文件覆盖的问题。归档中的某个 shell 文件在 D1 04:19:19 被修改,内容已经变成主机/SSH banner 探查,与 04:13 数据库日志里的搜索脚本不同。最后留下的文件,未必就是先前执行的版本。这也是为什么要同时对照事发 SQL 中的脚本内容、文件哈希和保存时间。归档中的 UID 同样只保留了属主元数据,还不足以证明每个文件是谁创建的。
这些样本明确存在资源消耗行为,但没有按进程测得完整 I/O 贡献。它们支持故障关联,不足以把整段磁盘满载的每一个字节都归给单个脚本。
到底读走了多少数据
走到这里,入口和执行路径已经比较清楚,影响范围却还得重新按证据计算。能够打开检查的结果文件、只有字节数的网络记录,以及查询返回的表计数,回答的是不同问题。下面把它们分开列出。
先看能逐项核对正文的部分
| 类别 | 数量 | 不能扩大成 |
|---|---|---|
| 主库逻辑变更结果 | 44,504,222 字节,50,124 条变更,27,483 个事务 | 已全部传往外网,或 50,124 名用户 |
| 账户信息 | 172 个不同用户 ID、172 个不同非空邮箱 | 两组相加为 344 人 |
| 密码哈希 | 17 个不同用户 | 17 个明文密码或已破解账号 |
| 刷新令牌 | 202 个不同值,涉及 166 个用户 ID | 202 个当前有效登录或实际盗用 |
| 会话 | 199 个 session ID,涉及 173 个用户 ID | 与账户和令牌人数直接相加 |
| 查询应用数据 | 配置 17 行、用户记忆 10 行、摘要 5 行、业务令牌 3 个 | 相同数量的独立用户或所有有效密钥 |
| DB-AGENT | 7 张表、73 行样本 | 整库导出 |
| Redis | 至少 11 个不同任务 ID 的字段或片段 | 全部键值已导出 |
| 物理备份残片 | 4,000,000 字节 | 完整备份或来源库已确定 |
读这张表时,最容易混淆的是“记录”和“人”。逻辑变更里的 INSERT、UPDATE、DELETE 记录的是被捕获的业务变更类型,并不表示攻击者做了同等数量的篡改。即便两个文件没有完全相同的变更记录,同一个用户或业务实体仍然可能反复出现。
再看只有流量或计数的部分
| 指标 | 正确含义 |
|---|---|
| 从库 55,992,951 字节 / 336 条 TCP 会话 | 已向受控 OPS-1 回传的协议流量,包含错误、开销和可能重复内容 |
| 三笔大响应 54,127,410 字节 | 占上述回传 96.67%,尚缺完整正文 |
| 备份解析器约 3.13 GB | 输入计数,不能证明完整备份被导出 |
| Redis 约 1,078 万 | 键名返回次数,不是独立键数量或 value 数量 |
| 约 3,716 万用户 | 表计数结果,不是导出行数 |
| 两张敏感表各 100 行 | 数据库返回行数,原始明文凭据是否通过脱敏层仍不完整 |
现在可以回头看开篇的两组数字了。44.50 MB 是结果文本,55.99 MB 是网络流量,它们不能相加成“总泄露量”。 前者能逐项检查内容,后者还包含协议开销、错误和可能重复的数据。最终互联网传输尚未查清,但受控节点已经取得数据,这一点有实际结果支持。
统计还需要做减法。一组累计返回 896,984 行的正常报表任务,经人员确认后已从攻击取数中排除。成功查询很多,并不自动意味着这些查询都属于攻击活动;操作者和业务背景同样影响最后的数字。
顺着这条链,还看到了哪些问题
除了已经串进时间线的活动,排查还碰到了其他权限和实现问题。有些留下了实际利用结果,有些只是暴露出风险。放在一起看时,仍然要保留这个区别:
| 问题 | 分类 | 本案是否证实利用 | 处理重点 |
|---|---|---|---|
| 查询 HTTP 入口未做服务端鉴权 | 自建应用访问控制缺陷 | 是 | 各入口统一授权,身份来自可信会话 |
| SELECT 黑名单代替数据授权 | 应用设计与授权缺陷 | 是,文件读取和敏感查询已发生 | 数据集/列/函数白名单与数据库最小权限 |
| 按输出列名脱敏 | 数据保护缺陷 | 是,保存结果能还原令牌 | 前置限制敏感数据,脱敏只作补充 |
| Doris BE 以 root 运行 | 部署权限问题 | 当前已确认 root,历史已有 root 文件读取 | 降权、目录隔离、移除凭据副本 |
| 跨主机共享 root 密钥 | 凭据和横向权限问题 | 是 | 撤销旧授权、单主机身份、短期凭据和入口控制 |
| COPY PROGRAM | 数据库高权限功能被滥用 | 是 | 收回不必要的超级用户/程序执行能力 |
| 复制账号取数 | 高敏感复制权限被滥用 | 是 | 单独审计复制身份、槽、插件和输出范围 |
| Redis 无认证且默认 ACL 过宽 | 服务访问控制与权限问题 | 是,配置及部分任务读取有结果 | 收窄网络可达、业务 ACL、管理命令权限 |
| 初始化脚本 HTTP 下载后执行 | 供应链及传输完整性风险 | 未证实作为本次入口 | HTTPS、签名/可信摘要校验、固定版本 |
| set -x 把临时令牌写到日志 | 敏感日志问题 | 日志泄漏已确认,未证实令牌被用于本次攻击 | 在取密前关闭追踪,限制日志权限和保留 |
| Socket 先 join 后授权 | 对象权限顺序缺陷 | 顺序问题已确认,越权收到聊天正文未确认 | 授权先于订阅,失败清理,明确事件投递边界 |
| Supabase / PostgreSQL / Doris 某个 CVE | 产品漏洞假设 | 未确认 | 必须有版本、受影响条件和现场利用证据 |
云初始化文件里,究竟留下了什么
云初始化文件里有不少看起来敏感的关键词,读到具体内容后,结论却需要拆开。这里核对了当前文件,也回看了两次保留下来的历史返回正文:
- 两次历史实例元数据各 92 行,包含实例、系统信息和公钥;管理员密码字段为空,没有云 AK/SK、数据库密码或私钥。
- 初始化脚本确实通过 HTTP 下载脚本后高权限执行,包装脚本没有签名/哈希校验;但没有证据表明发生下载劫持。
set -x曾把元数据访问令牌打印到权限为 0644 的日志。记录来自更早时间,申请 TTL 为 120 秒,按记录时效已在事件前过期;不是长期云密钥。/proc/1/environ的 stat 大小为 0 不等于没有内容;一次 SQL 返回 0 行也不能证明当时环境为空。- 缓存中的旧公钥不能反推出私钥,也不代表当前
authorized_keys仍授权;云模板是否会重新写入旧授权需要另查。 - 仍缺云控制面角色、云 API 与云助手任务审计,不能据磁盘上没有 AK/SK 就排除实例角色风险。
cloud-init 普通实例数据缓存与敏感数据缓存有不同用途;应根据实际文件内容判断,不能见到 metadata 就认定云凭据泄露。cloud-init instance-data
检查日志泄漏时只返回计数,不把令牌再次打印出来。以下为整理版:
import re
from pathlib import Path
rules = {
"assignment": re.compile(r"\bMETADATA_TOKEN=", re.I),
"header": re.compile(r"X-[A-Za-z0-9-]*metadata-token:", re.I),
}
counts = dict.fromkeys(rules, 0)
with Path("cloud-init-output.log").open(errors="replace") as source:
for line in source:
for name, regex in rules.items():
counts[name] += bool(regex.search(line))
print(counts)
这些是出现次数,赋值与请求头可能重复同一令牌,不能当作不同令牌数。OWASP Logging Cheat Sheet
先加入房间、后检查权限的 5 毫秒
应用侧还有一处很具体的顺序问题:历史日志里,一个 visitor 先加入了别人的基础房间,5 毫秒后业务层才拒绝请求。当前源码中能找到对应逻辑:
socket.join(sessionId);
const result = await handleCreateSession({ sessionId, user });
if (!result.success) {
socket.emit("error", "access denied");
return; // 此分支没有退出刚加入的房间。
}
虽然当前源码未必就是事发时的镜像,历史日志已经确认这个先后顺序确实出现过。接下来还要追消息发往哪里:主要模型消息走的是另一个生命周期房间。因此,第二连接在线期间发生过模型生成,仍不足以证明这个连接收到了聊天正文。
修复示意:先依据服务端身份完成新建/已有会话的对应授权,再订阅;操作失败时只清理本次新加入的订阅,避免破坏既有合法连接。
const session = await createOrAuthorizeSession(principal, input);
const room = session.id;
const joinedByThisOperation = !socket.rooms.has(room);
if (joinedByThisOperation) await socket.join(room);
try {
await handleAuthorizedOperation(session, principal);
} catch (error) {
if (joinedByThisOperation) await socket.leave(room);
throw error;
}
这不是完整修复提交;并发请求、会话生命周期房间和断连清理仍需按真实实现测试。
把重复的排查工作整理成三个离线工具
前面这些排查里,有几项重复工作很适合留成工具:统计网关请求、整理 SSH 成功认证、静态清点归档。这里把它们整理成三个可下载的脚本,均使用 Python 3 标准库,只读取明确指定的本地文件,不联网、不连接数据库、不执行样本。下载内容是整理后的分析工具,不包含原始攻击脚本或事件日志。
| 工具 | 输入与用途 | 下载 |
|---|---|---|
| Nginx 统计 | combined access log 或 .gz;统计状态码、方法、来源代号和固定路径分类 | nginx_summary.py |
| SSH 成功认证统计 | auth 日志或 .gz;聚合 Accepted 记录,替换来源、非 root 用户和指纹 | ssh_summary.py |
| 归档静态清点 | tar 及支持的压缩格式;统计成员类型、逻辑大小和可疑路径 | archive_inventory.py |
下载到当前目录后,可对本地证据副本运行,以下文件名均为示例:
python3 nginx_summary.py evidence/access.log evidence/access.log.1.gz --include-times
python3 ssh_summary.py evidence/auth.log evidence/auth.log.1.gz --include-times
python3 archive_inventory.py evidence/sample.tar.gz --max-members 10000
需要分析时间分布时,给 Nginx 或 SSH 工具加上 --include-times,输出 UTC 小时分布和可用的时间样本;不加时只输出聚合统计。来源 IP、用户名和密钥指纹使用运行内代号,原始 URL、查询参数和 UA 不进入输出。
Nginx 工具还支持带时区的 --since 和 --until,按左闭右开的区间过滤。SSH 工具不会猜测缺失的年份和时区,这类记录会计入时间质量统计,但不进入 UTC 小时分布。
跑完以后,读结果时还要带着几个限制:
- 来源代号只在一次运行内有效,两个工具里的
SOURCE_001不保证是同一来源;真实客户端 IP 还取决于可信代理配置。 - 重复行、重叠轮转文件不会自动去重。读取完成不代表每行都解析成功,要一起看解析失败和未匹配计数。
- UA 只是自动化特征提示,Accepted 也不能独立判断操作者。脚本不是攻击归因工具。
- 归档工具不提取成员、不输出成员名和正文。成员上限不等于解压字节或耗时上限;元数据遍历也不是完整归档校验或安全解压保证。
下一次,怎样更早看到这些信号
如果希望下一次更早看到这些活动,网关日志是一个起点。但单个 curl UA 识别不了攻击者,正常自动化、监控和失效会话也会产生 401/403。在敏感期,可以先用低阈值把信号收进来,再结合管理路径、来源身份,以及失败后是否转为成功来判断。
按源 IP 统计 5 分钟的 401/403,以下标签均为示例。原始日志应为标准 Nginx combined 格式,行首已经是经过可信代理规则还原的客户 IP:
sum by (client_ip) (
count_over_time(
{namespace="example", app="gateway"}
| regexp `^(?P<client_ip>\S+) \S+ \S+ \[[^]]+\] "[A-Z]+ /[^ ]* HTTP/[0-9.]+" (?P<status>[0-9]{3}) [0-9]+ `
| client_ip!=""
| status=~"401|403"
| __error__=""
[5m]
)
) >= 10
按自动化 UA 统计,不直接赋予“恶意”标签:
sum by (client_ip) (
count_over_time(
{namespace="example", app="gateway"}
| regexp `^(?P<client_ip>\S+) \S+ \S+ \[[^]]+\] "(?P<method>[A-Z]+) [^ ]+ HTTP/[0-9.]+" [0-9]{3} [0-9]+ "[^"]*" "(?P<ua>[^"]*)"`
| client_ip!=""
| method!="OPTIONS"
| ua=~`(?i).*(python-urllib|python-requests|curl/|wget/|sqlmap|go-http-client|libwww-perl|aiohttp|httpx).*`
| __error__=""
[5m]
)
) >= 10
这些是查询表达式,不是已导入、绑定通知和验证触发的 N9E 配置。按主机分开统计可改为 sum by (host_name, client_ip)。IP、UA 适合查询时提取;不要未经评估把高基数字段做成 Loki 写入索引标签。查询时聚合也需要评估结果序列和告警实例数量。LogQL 解析字段、Loki 高基数说明
沿着这次入侵链往后看,还有更值得关注的信号:管理路径失败后出现成功、数据库创建异常复制槽、数据库派生 shell,以及同一 SSH 密钥在短时间内跨多个节点 root 登录。把这些信号和已知正常任务清单放在一起,告警才更容易指向需要排查的活动;否则连调查人员自己的操作也会被算进去。
进程停了,还有哪些事情要继续查
到这篇文章整理时,已经能核实的处置包括:限制部分管理入口、轮换数据库和 API 凭据、终止一批异常数据库后端、清理部分主机旧 SSH 授权,以及停用 QueryConsole 并移除自动恢复条目。
这些动作也都有各自的范围。安全组曾因后续调查重新开放,一次“已关闭”的记录无法代表此后的状态。已经发生 root 或数据库 OS 权限执行的主机,轮换密码后仍要继续保全证据、检查持久化和配置变更、撤销凭据副本,并评估是否需要可信重建。
还有几件事,现有材料暂时回答不了:
- 管理员数据库密码和管理 API Key 最初从哪里取得。
- 两次早期大响应的具体正文,以及是否含管理员凭据。
- 从受控节点向外部实际传输了哪些数据、多少数据。
- 全部业务表涉及的用户并集及令牌后续使用情况。
- 所有受控主机的残留清理、云控制面影响和业务恢复验收。
要继续往前走,最有用的是能补上这些缺口的新材料:调用端输出、事发配置版本、完整执行审计、网络传输记录,以及云控制面操作。反复扫描同一批当前文件,无法让缺失的历史自己出现。
回看最初的 I/O 告警,真正让这条链逐渐清楚的,是把一条记录放回它所在的上下文:哪个入口发起请求、用了谁的身份、被哪一层拒绝、又在哪一层留下了结果。日志里的一个“成功”或“失败”,往往只讲完了其中一小段。把这些小段接起来,才能知道问题已经走了多远,也才能看清接下来还缺什么。
附:这些结论分别来自哪些材料
最后把前文依据的材料列在这里,方便回看每类结论来自哪里。公开版只保留证据编号,不链接含真实地址、凭据或内部项目名的原始文件;内部仍需保存编号到原文件、SHA256、采集时间、权限和覆盖范围的对应关系。
| 编号 | 证据类别 | 支持的结论 |
|---|---|---|
| E01 | 网关日志、应用源码与部署产物 | 入口发现、遗漏鉴权、查询与脱敏实现 |
| E02 | Doris 审计及已保存查询结果 | 数据/文件读取、返回行数、具体可见内容 |
| E03 | 六台主机认证日志 | root 登录、共享指纹、横向时间关系 |
| E04 | 主从库原生日志与采集 manifest | 已认证 SQL、逻辑槽、COPY 执行、终止事件 |
| E05 | 从库 TCP 代理日志 | 网络回传量、来源、连接结束时间 |
| E06 | 受控节点上的脚本与输出静态分析 | 计划行为、实际结果、mtime 辅助关系 |
| E07 | Cloudflare、应用日志与当前 Socket 代码 | 测试业务调用、授权顺序、未证实的接收范围 |
| E08 | 云初始化文件与两次历史元数据结果 | 实际泄漏内容、初始化配置风险及边界 |
| E09 | 人员确认和后续认证复核 | 排除正常报表、维护和内部 SSH 活动 |