跳到主要内容

从一次 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:31193 个 API 路径按字母顺序被 POST,命中 /api/query连续日志支持字典式路径枚举
D0 18:32:13查询接口 200,Doris 同秒返回 100 行表结构未授权查询已经执行
D0 18:34两个敏感表各返回 100 行数据库返回已确认,明文凭据数量未确认
D1 01:33:47LOCAL 读取 ANALYTICS-1 的 root 目录 PEM,27 行、1,675 字节文件读取已确认
D1 01:59:32EXTERNAL-A 以 KEY-OLD 登录 OPS-1 的 root已确认主机控制
D1 02:15:54–55OPS-1 用 KEY-OLD 登录另外五台 root已确认横向登录
D1 02:27:04DB-PRIMARY 出现 cdc_reader 已认证 SQL普通表 SELECT、文件读取部分失败,但账号已进入数据库
D1 02:30–02:36DB-AUX 两个账号已认证;DB-REPLICA 的 db_reader 已认证存在多个数据库访问分支
D1 02:44:58〔mtime〕DB-AGENT 查询结果保存到 OPS-17 张表、73 行实际样本
D1 02:53:39 / 02:54:02DB-REPLICA 向 OPS-1 回传约 18.35 MB / 17.89 MB与 SQL 文件、SSH 会话强关联,正文未保存
D1 03:01:38db_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-144.50 MB、50,124 条变更
D1 03:38:59–03:40生产管理接口出现 200;主库出现恶意跨表搜索HTTP 与 SQL 日志可相互印证,部分没有共享请求 ID
D1 03:44从库经生产管理接口进入主库不同于被 HBA 拒绝的管理员直连路线
D1 04:12:55–04:13:06COPY 错误跨层对应,主库开始长时间系统文件扫描与告警观察点对齐
D1 06:12:37主库六个异常后端终止处置点,不是攻击结束点
D1 11:05–11:22从库仍有管理员 SQL、系统程序调用攻击相关活动持续存在
D1 12:50另一运维节点仍有 KEY-OLD 外部 root 登录不能以单次数据库处置结束整个事件
D2 09:45QueryConsole 无进程、端口无监听,恢复项已清理应用停用验证通过,不等于所有主机已清理

查询入口为什么没有挡住请求​

请求借用了服务端的查询身份​

先从入口说起。数据库审计里有个很容易把人带偏的线索:查询账号看起来像一位真实员工的姓名。核对应用后才发现,它只是服务端预置的查询账号。审计记下了应用使用谁的身份连接数据库,却没有因此证明是谁发起了请求。

实际调用链是:

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 的?把几处独立记录放在一起,就能看到下面这个顺序:

  1. Doris 审计显示在 ANALYTICS-1 读取 root 目录的 PEM。
  2. 返回 27 行、扫描 1,675 字节。
  3. 当前对应旧私钥的大小、行数相同,公钥指纹与后续登录使用的旧密钥一致,统一记为 KEY-OLD。
  4. 25 分 45 秒后,EXTERNAL-A 用 KEY-OLD 成功 root 登录 OPS-1。
  5. 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,再把下面几项逐一对上:

  1. Nginx 虚拟主机和反向代理目标。
  2. 应用进程、部署目录与构建文件。
  3. API 路由是否调用授权逻辑。
  4. 数据库连接配置是否使用该审计账号。
  5. 保存的查询结果是否与审计时间和行数对应。

入口日志显示先批量枚举路径,再对 /api/query 调整方法和参数,最终成功执行查询。公开 JS 中找到了默认用户名,却没有找到该查询路径字面量;因此更支持字典枚举发现接口,而不是从 JS 直接抄出接口。

正文没留下,还能从时间关系里找到什么​

这条链里最难补齐的一段,是两笔没有保存正文的从库大响应。好在 SQL 文件、SSH 认证记录和代理会话结束记录还在,把它们并排摆开,能看到这样的时间关系:

文件SSH 接受文件 mtime从库响应结束SSH 关闭
query_01.sql02:53:33.01702:53:33.54002:53:3902:53:39.969
query_03.sql02:53:53.78902:53:54.31002:54:0202: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 个用户 ID202 个当前有效登录或实际盗用
会话199 个 session ID,涉及 173 个用户 ID与账户和令牌人数直接相加
查询应用数据配置 17 行、用户记忆 10 行、摘要 5 行、业务令牌 3 个相同数量的独立用户或所有有效密钥
DB-AGENT7 张表、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 权限执行的主机,轮换密码后仍要继续保全证据、检查持久化和配置变更、撤销凭据副本,并评估是否需要可信重建。

还有几件事,现有材料暂时回答不了:

  1. 管理员数据库密码和管理 API Key 最初从哪里取得。
  2. 两次早期大响应的具体正文,以及是否含管理员凭据。
  3. 从受控节点向外部实际传输了哪些数据、多少数据。
  4. 全部业务表涉及的用户并集及令牌后续使用情况。
  5. 所有受控主机的残留清理、云控制面影响和业务恢复验收。

要继续往前走,最有用的是能补上这些缺口的新材料:调用端输出、事发配置版本、完整执行审计、网络传输记录,以及云控制面操作。反复扫描同一批当前文件,无法让缺失的历史自己出现。

回看最初的 I/O 告警,真正让这条链逐渐清楚的,是把一条记录放回它所在的上下文:哪个入口发起请求、用了谁的身份、被哪一层拒绝、又在哪一层留下了结果。日志里的一个“成功”或“失败”,往往只讲完了其中一小段。把这些小段接起来,才能知道问题已经走了多远,也才能看清接下来还缺什么。

附:这些结论分别来自哪些材料​

最后把前文依据的材料列在这里,方便回看每类结论来自哪里。公开版只保留证据编号,不链接含真实地址、凭据或内部项目名的原始文件;内部仍需保存编号到原文件、SHA256、采集时间、权限和覆盖范围的对应关系。

编号证据类别支持的结论
E01网关日志、应用源码与部署产物入口发现、遗漏鉴权、查询与脱敏实现
E02Doris 审计及已保存查询结果数据/文件读取、返回行数、具体可见内容
E03六台主机认证日志root 登录、共享指纹、横向时间关系
E04主从库原生日志与采集 manifest已认证 SQL、逻辑槽、COPY 执行、终止事件
E05从库 TCP 代理日志网络回传量、来源、连接结束时间
E06受控节点上的脚本与输出静态分析计划行为、实际结果、mtime 辅助关系
E07Cloudflare、应用日志与当前 Socket 代码测试业务调用、授权顺序、未证实的接收范围
E08云初始化文件与两次历史元数据结果实际泄漏内容、初始化配置风险及边界
E09人员确认和后续认证复核排除正常报表、维护和内部 SSH 活动