PostgreSQL 最近有一个编号为 CVE-2025-1094 的漏洞,补丁发出之后,我在复盘排查时发现不少同行对这个漏洞的理解还停留在“知道名字但说不出细节”的程度。这个漏洞很不寻常:它打破了一个很多人默认的安全假设——只要我们用了官方提供的转义函数,把用户输入老老实实转义一遍,SQL 注入就会离我们很远。但这一次,转义函数自己先“失灵”了,而且最终还能一路打到 psql 元命令执行,危险程度直接拉满。
这篇文章会把 CVE-2025-1094 从零开始讲透:先补 SQL 注入和 psql 元命令的基础,再拆漏洞的根因和触发链路,最后给出自查方法、修复方案和防御加固建议。无论你是刚接触数据库的新手,还是已经在生产环境里摸爬滚打多年的老手,这篇都值得花 10 分钟认真过一遍,因为这次的坑,真的很容易踩。
1. 先搞清楚:这个漏洞到底有多大
1.1 漏洞卡片:CVE-2025-1094 基本信息
先把最关键的档案信息列出来,方便你转发给团队或者快速归档:
| 项目 | 内容 |
|---|---|
| 漏洞编号 | CVE-2025-1094 |
| 受影响组件 | PostgreSQL 的 libpq 客户端库 |
| 关键函数 | PQescapeLiteral()、PQescapeIdentifier()、PQescapeString() |
| 漏洞类型 | SQL 注入、转义绕过 |
| 最高危害 | 任意 SQL 注入、psql 元命令执行,特定配置下可能到操作系统命令执行 |
| CVSS 评分 | 8.4(高危) |
| 受影响版本 | 17.0 ~ 17.2、16.0 ~ 16.6、15.0 ~ 15.10、14.0 ~ 14.15、13.0 ~ 13.18 |
| 修复版本 | 17.3、16.7、15.11、14.16、13.19 |
这几个函数的定位是“官方钦定的转义函数”,很多代码审计工具也会建议开发者用它们来处理动态 SQL。所以这次不是某个小众第三方组件的锅,而是 PostgreSQL 自带客户端库核心函数出了问题,影响面非常广。
1.2 一句话概括这个漏洞的本质
CVE-2025-1094 的核心问题可以压缩成一句话:当输入中包含特定格式的无效字节序列时,libpq 的转义函数在客户端看似正确转义了,但数据到了服务端,因为编码解码规则不一致,某些字节会被服务端忽略或重组,导致转义效果被抵消,单引号等危险字符重新“裸奔”。
这个“两端认知不一致”的漏洞模式,比普通注入更阴险。以前我们教新人防注入,会说“拼 SQL 时一定要用转义函数”,这个经验本身没错,但这次连转义函数都防不住,对很多团队的信任体系是一次不小的冲击。
2. 零基础补课:SQL 注入和 psql 元命令到底是什么
2.1 SQL 注入的本质:把“数据”当“代码”执行
用大白话讲,SQL 注入就是在应用程序拼 SQL 语句时,用户输入没有老老实实当数据,而是被数据库当成 SQL 命令的一部分执行了。
举个最简单的例子。你的程序写了一句:
SELECT * FROM users WHERE name = '张三';如果这个“张三”是用户填的,攻击者填成:
张三' OR '1'='1拼接后就成了:
SELECT * FROM users WHERE name = '张三' OR '1'='1';这条语句会返回全表数据,登录验证直接被打穿。更严重的,攻击者还可以塞'; DROP TABLE users; --这类操作。为了方便理解,你可以把正常的 SQL 拼接想象成一份打印好的表格,表格里的固定文字是“代码”,用户填的内容是“数据”。注入攻击干的事情就是:把用户输入里的特殊字符(主要是单引号)当作句法符号,让数据库误以为“这段数据应该被当成代码处理”,于是原本隔离的两层边界就被击穿了。
2.2 psql 元命令:从反斜杠开始的“快捷通道”
psql 是 PostgreSQL 自带的命令行工具,除了能执行普通 SQL,还支持一批以反斜杠\开头的“元命令”。这些元命令不是 SQL 标准的一部分,而是 psql 提供的交互快捷方式。
常用的包括:
\set:定义 psql 内部变量\gset:把查询结果的列值存入 psql 变量\!:在操作系统 shell 中执行一条命令\o:把查询结果输出到文件\copy:执行客户端侧的数据拷贝
问题在于,psql 元命令拥有“脱离 SQL 语法”的执行能力,尤其是\!,它直接调用操作系统命令。如果一个场景里,攻击者构造的数据能渗透到 psql 变量,再被元命令消费,危害就会从“数据库被注入”升级到“数据库所在机器被控制”。CVE-2025-1094 的官方描述里明确提到了 psql 元命令执行,意味着攻击链可以走得很深。
2.3 转义函数存在的意义:给输入穿上“防护服”
开发者在写动态 SQL 时,不能直接把用户输入塞进去,否则就是给别人递刀子。转义函数的职责,就是把输入字符串里的危险字符转成无害的“字面文本”。
PQescapeLiteral() 是 libpq 提供的标准做法,它会把字符串用单引号包裹起来,并对内部的单引号、反斜杠等特殊字符做转义。比如用户输入:
abc' OR 1=1 --PQescapeLiteral() 输出大致是:
'abc'' OR 1=1 --'也就是把单引号双写,让数据库认为这是数据里的一个普通单引号,而不是字符串边界。这层“防护服”在过去几十年是无数应用的安全底座,也是为什么这次漏洞能引起这么大震动的原因——原来是铁布衫,现在告诉你有几针没缝紧,而且很难看出来。
3. 漏洞根因:转义逻辑被“无效字节”钻了空子
3.1 两端编码规则不一致,是绕过的关键
CVE-2025-1094 的根因,出在 libpq 的转义函数在处理“无效字节序列”时的逻辑缺陷。这里的无效字节序列,指的是不符合当前字符集编码规则的字节组合。为了兼容历史上各种不规范的客户端,PostgreSQL 服务端在解析某些无效多字节字符时,并不会直接报错,而是选择把无法识别的字节当作“零宽度”处理,也就是干脆忽略掉,或者把序列重新组合成另一个合法字符。
问题就出在这里:客户端 libpq 在转义时,以字节为单位检查有没有'、\等特殊字符;服务端在解析时,以“字符”为单位进行编码重组。两边的解析粒度不一致。如果攻击者构造一个特殊字节序列,里面夹带单引号,客户端转义时看到的是“普通字节+单引号”,会规规矩矩把单引号双写;但服务端解析时,那个特殊字节序列的“存在感”被抹掉,单引号的语义就变了,原本的双写单引号重新暴露成可以被利用的语法符号。
这个感觉有点像加密传输两端用了不同的解密规则:发送方加了密,但接收方解密时某些字符被静默丢弃,结果密文反而被还原成了明文。客户端以为自己在转义,服务端却以为根本没转义,两边对不上账,危险就来了。
3.2 从转义失败到注入的完整链路
把整条攻击链路拆开看,大致是四步:
- 攻击者构造一个包含特定无效字节序列的字符串,里面夹带单引号、分号等 SQL 关键字符。
- 业务代码调用 PQescapeLiteral() 或 PQescapeIdentifier() 对这个字符串做转义,函数返回的结果从客户端视角看是“已经安全处理过”的。
- 转义后的字符串进入 SQL 语句,发送到服务端,服务端在编码解码过程中,按照自己的规则把无效字节剥离或重组,导致原本双写的单引号恢复成可用的句法符号。
- 攻击者输入中的 SQL 片段被服务端正常解析执行,造成注入。注入成功后,如果这个环境还能通过某个通道把数据交给 psql 元命令处理(比如
\gset把查询结果列值存入变量,再在后续通过\set拼接构造新命令),就可能进一步演变成元命令执行甚至操作系统命令执行。
值得注意的是,这个漏洞不仅影响应用代码中的动态 SQL,也影响 psql 自身的一些交互场景。比如你从某个不可信数据源拿到一份 SQL 脚本文件,用 psql 去执行它,文件内容里可能含有精心构造的字节序列,最终在\gset、\set这类元命令交互中被“激活”。
3.3 为什么这个漏洞容易被忽略
我在复盘时,发现这个漏洞有几个“天生隐蔽”的属性。
复现条件苛刻。它依赖特定的无效字节序列,普通场景下很难碰到,开发者日常测试很少会拿畸形编码去挑战转义函数,所以线上即便存在风险,也不容易暴露。
静态代码扫描难以覆盖。像PQescapeLiteral()这种函数,在代码里看起来就是标准的安全写法,常规代码审计工具不会标记它们是风险点,反而会认为“这里做了转义,安全”。漏洞不在函数用没用上,而在函数内部的边界情况处理,这超出了大多数白盒扫描工具的能力范围。
修复牵涉面广。libpq 是客户端库,服务端和客户端都可能受影响。数据库服务器升级了还不够,如果应用服务器用的还是旧版 libpq,漏洞照样存在。这个问题在大型系统里尤其容易漏掉。
4. 自查与修复:三步确认你的环境是否安全
4.1 第一步:确认 PostgreSQL 版本,升级到修复版本
先别急着猜,直接查一下版本:
SELECT version();如果版本落在受影响区间,立刻规划升级。修复版本我已列在开头的表格里:17.3、16.7、15.11、14.16、13.19,对应的分支升级上去就能堵住漏洞。
升级时的几个关键点要注意:
libpq是客户端库,服务端升级不代表客户端自动安全。应用服务器、ETL 工具、脚本环境里装了什么版本的 libpq,要一并排查,使用pg_config --version或psql --version查看。- 升级前务必先做兼容性测试。这次修复涉及转义函数和编码处理逻辑,极端情况下可能影响某些特殊的字符处理行为,先在预发环境跑一遍完整回归。
- 如果业务依赖某些老版本特有的行为,升级后需要重点验证中文、表情符号等非 ASCII 字符的读写。
提示:版本检查不止看服务端,客户端机器上的
psql --version输出也要看。CVE-2025-1094 同时影响服务端和客户端,两边都要达标。
4.2 第二步:扫描代码中的转义函数调用,评估暴露面
即使版本暂时无法立即升级,也要先搞清楚自己的代码里到底哪些地方调用了这些函数。在项目目录里搜一下:
grep -rn "PQescapeLiteral\|PQescapeIdentifier\|PQescapeString" --include="*.c" --include="*.cpp" --include="*.h" .把搜出来的调用点逐个人工确认:
- 输入来源是谁?是终端用户输入、API 参数、配置文件,还是外部文件读取?
- 输入流经这些函数后,拼到了 SQL 语句的什么位置?
- 有没有可能被二次读取、写入日志、存到 psql 变量再被元命令消费?
如果输入源完全受控,那风险相对低;如果输入源面向不可信用户,且拼进 SQL,那就属于高风险路径,要在升级前临时加固。
4.3 第三步:收紧运行时权限,减少“最后一击”的伤害
万一被注入,能做到什么程度,很大程度上取决于数据库账号的权限。我在不少项目里见过用超级用户跑业务查询的情况,一旦注入成功就是灾难。最小权限原则不只是一句口号,具体落地至少要做到:
- 业务应用使用的数据库账号,只授予业务所必需的库、表、函数权限,撤销超级权限。
- 禁止业务账号拥有
COPY ... PROGRAM、CREATE EXTENSION这类高危权限,尤其在无法确认客户端 libpq 版本时。 - 开启数据库日志记录所有语句,至少临时开启一段时间,方便发现异常模式:
log_statement = 'all' log_line_prefix = '%m [%p] %q%u@%d '日志会带来额外 IO 和存储开销,生产环境可以只开一小段时间,或者在低峰期开启,等排查结束后改回更严格的配置。
5. 升级之外的防御加固与避坑经验
5.1 参数化查询才是最终解法
这次漏洞最值得反思的一点是:转义函数本身只是“第二道防线”,参数化查询(预处理语句)才是根除 SQL 注入的根本办法。参数化查询把 SQL 结构固定下来,用户输入只作为参数绑定,不参与语句结构的解析,根本不给“转义失效”留机会。
拿 C 语言配合 libpq 举例,不用字符串拼接 SQL,而是用 PQprepare 加 PQexecPrepared:
PGresult *res = PQprepare(conn, "my_stmt", "SELECT * FROM users WHERE name = $1", 1, NULL); const char *paramValues[1] = { user_input }; PGresult *res2 = PQexecPrepared(conn, "my_stmt", 1, paramValues, NULL, NULL, 0);这样写,哪怕 user_input 里塞满无效字节序列、单引号和分号,也只是一堆“值”,绝不会被解释成 SQL 结构。很多高级语言驱动里的参数化查询也是同理,Python 的 psycopg2 用%s占位符,Java 的 JDBC 用?占位符,效果一样。
转义函数并没有失去存在价值,但这次漏洞应该促使团队调整开发规范:动态拼接 SQL 只能用于少量内部场景,面向不可信输入的业务逻辑,一律走参数化查询。
5.2 升级过程中的兼容性避坑
如果你现在已经在准备升级,提醒几个我实际踩过的坑。
第一个坑:服务端升级后,旧客户端连不上。PostgreSQL 的主版本升级通常要求客户端版本不低于某个基线,如果一个业务集群里有的应用服务器长期没更新 libpq,可能出现认证协议不兼容的问题。升级前把客户端的 libpq 版本一起纳入排期,最好统一升级,避免“服务端安全但客户端漏风”的尴尬局面。
第二个坑:转义行为差异。修复后的转义函数遇到无效字节序列,可能会报错或拒绝处理。如果业务里有人拿一堆历史遗留的脏数据做入库,升级后可能发现之前能跑通的流程突然报“invalid byte sequence”。这其实是一种“积极失败”,是安全机制在起作用。把脏数据挡在门外,总比让它们在库里埋雷强。
第三个坑:主从切换。PostgreSQL 高可用集群升级时要特别注意先升级备节点,再切换主备关系,否则一次无意的主从切换会把未修复的旧节点重新拉回主节点,等于治标不治本。
5.3 几个模拟排查案例,帮你理解现象
根据真实高频提问,整理了几个模拟场景:
| 场景 | 现象 | 原因分析 | 处理建议 |
|---|---|---|---|
| 升级前业务一切正常 | 升级后某条包含特殊编码的 SQL 报错 “invalid byte sequence for encoding UTF8” | 新版本对无效字节序列的容忍度降低,主动报错拦截 | 清理源头数据,改用参数化绑定 |
| 代码里用了 PQescapeLiteral | 注入仍然成功,日志里出现异常 SQL 片段 | 输入包含特定无效字节序列,转义被服务端解码抵消 | 升级 libpq,改用参数化查询 |
| psql 执行脚本 | 脚本被注入后执行了\!shell 命令 | 脚本数据被元命令消费,触发命令执行 | 禁止执行来源不可信的 psql 脚本,升级版本 |
| 监控发现大量异常登录查询 | 业务日志中单引号被反复双写但仍报语法错误 | 有自动化扫描器正在探测端点,但载荷未生效 | 确认版本已修复,检查访问控制,启用日志记录 |
这些场景我在不同的群里都见过类似讨论,核心还是要回到版本和安全架构两个基本面:版本修好,参数化铺开,心理上才会彻底踏实。
5.4 长期防御的三个习惯
最后聊点“结硬寨”的打法。单靠一个 CVE 的补丁,防不住下一个未知漏洞。这次事件让我把几个习惯刻进了项目规范里:
第一个习惯:构建独立的 SQL 模板层。所有 SQL 语句集中管理,业务代码拿到的都是函数接口,不是可拼接的字符串。这样审计起来非常轻松,出问题时也能快速定位边界。
第二个习惯:把编码校验做在入口。既然问题出在无效字节序列,那就在程序入口统一做字符集校验,只接受合法 UTF-8,连畸形数据进系统的机会都掐掉。可以用utf8proc或其他编码校验库,也可以简单用语言自带的能力判断。
第三个习惯:订阅安全通告。PostgreSQL 官方发布安全通告时,认真读一遍,确认自己环境涉及的版本范围。很多时候补丁当天就出了,但真正完成一轮全链路修复和验证,可能要好几周。知道“哪里是弱点”比“知道要打补丁”更关键。
按我个人经验,每家公司都有几套数据库环境,分布在不同的业务线上,最大难点从来不是补丁包本身,而是把“所有该升级的地方都找到”这件事做到位。这次 CVE-2025-1094 的教训就是:别过分相信转义函数,也别只盯着服务端版本,客户端 libpq、psql 版本、应用代码路径,全部都要纳入安全检查范围。
最后再分享一个实战小技巧:如果你不确定当前环境是否已经被扫描器盯上,可以在备库临时开启一个不存在的账号,观察是否有来源 IP 尝试登录并发送畸形 SQL。这类“蜜罐式”监测能帮你判断自己是否是攻击目标,也能提前确认日志采集是否正常运转。安全这件事,多做一步,晚上就能少醒几次。