文章总结: 本文深度剖析CVE-2026-32740,一个libheif堆缓冲区溢出漏洞,可经恶意HEIF图片在Next.js+sharp环境中触发远程代码执行。作者完整展示了从漏洞原理、利用链构建到绕过ASLR的细节,并提供了可复现的POC环境与修复建议。 综合评分: 92 文章分类: 漏洞分析,恶意软件,渗透测试,web安全,二进制安全
CVE-2026-32740 深度剖析:一张 HEIF 图片如何击穿 Next.js,触发 libheif 堆溢出 RCE
Ots安全
2026年9月29日 13:20 广东
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
威胁简报
恶意软件
漏洞攻击
一、背景与动机
把一个内存安全漏洞塞进“普通 Web 上传”的身后,这件事一直很有吸引力。FortBridge 团队此前受 Hacktron 关于“攻破 OpenAI”的研究启发,决定把 libheif 的利用链路啃透——后者展示了一个图像解码器的内存漏洞如何藏在一处普通的 Web 文件上传之后。Hacktron 的文章讲清了更宏观的攻击链,却没有公开可复现的低层细节;而 FortBridge 这次瞄准的是 CVE-2026-32740,在一个刻意构造的 Next.js + sharp 实验环境里把它跑通。
OWASP Top 10 仍然是很有用的基线,但当一条 Web 路由能把攻击者控制的文件送进一大串原生代码依赖时,应用安全的边界就不能只停在 OWASP 了。这条利用链把几个常被分开对待的领域拧到了一起:Web 应用安全、内存破坏模糊测试、GDB 调试、分配器行为,以及二进制利用。
团队先在 Ubuntu 上把利用打出来,随后又在 Debian 上重做了一遍,用来检验假设、让 PoC 在不同原生栈上更稳健。
二、漏洞概述(CVE-2026-32740)
CVE-2026-32740 是 HeifPixelImage::copy_image_to() 中的一个堆缓冲区溢出(写)——这是 libheif 把解码后的 HEIF / AVIF 分块(tile)拼到网格(grid)图像里时调用的函数。libheif 把图像拆成多个平面(plane)存储:Y 平面以全分辨率保存亮度采样,Cb / Cr 平面保存蓝、红的差值色度采样。在 4:2:0 格式下,Cb 与 Cr 的列数和行数都只有 Y 的一半,奇数则向上取整。
真实性备注:该漏洞已在多个权威来源得到印证——NVD / CIRCL / Ubuntu 安全公告一致记录其为 libheif ≤ 1.21.2 中网格分块色度合成的堆溢出写(CWE-787),CVSS 3.1 评分 8.8(High),攻击向量为网络、无需权限、需用户交互,官方修复版本为 libheif 1.22.0,对应厂商公告 GHSA-frfr-f3vg-2g6j。本文实验所用的 libheif 1.20.2 正处于受影响区间。
那个有问题的取整
漏洞代码把“色度目标行号”和“复制高度”分开计算,两者都向上取整,但复制高度的检查并没有考虑已经取整过的目标行号:
过的目标行号:
copy_height = min(src_height,
channel_height(h - y0, chroma, channel));
ys = channel_height(y0, chroma, channel);
for (py = 0; py < copy_height; py++)
memcpy(out_data + (ys + py) * out_stride, ...);
作者构造的载荷把四个分块(每个 33 像素高)堆进一张 132 像素高的画布。完整的 Cb / Cr 平面有 ceil(132 / 2) = 66 行(编号 0–65)。第四个分块从 Y 行 99 开始,于是 libheif 算出 ys = ceil(99 / 2) = 50,又独立算出 copy_height = ceil(33 / 2) = 17。循环会写出色度行 50 到 66,而第 66 行已经越过了分配边界。
画布宽 116 像素。一个 8 位 4:2:0 色度行因此是 ceil(116 / 2) = 58 字节。于是这个畸形的第四个分块恰好填满最后一行,让攻击者获得一次受控的、58 字节的越界写,落在 Cb 或 Cr 分配的末尾。
三、从“多一行”到代码执行
光有崩溃,并不等于这个漏洞能远程利用。要把脆弱性变成可重复的 RCE,完整的链条还需要三项额外能力:
- 一个独立的像素回显信息泄露,用于远程恢复随机化的 libvips 基址;
- 一个受约束的 write-what-where 原语,让利用既能选 58 字节内容、又能选写入目标;
- 一个控制流目标,必须在另一个 worker 扰乱被破坏状态之前就执行。
这个 write-what-where 之所以“受约束”,是因为行长度、对象布局、地址都和被测构建强相关。它依然是 write-what-where:Cr 像素提供字节,libheif 平面映射里一处伪造的条目提供目标地址。该次选定的地址拷贝覆写了 memcpy@GOT;紧接着的一行进入一个已经定位到的 libvips 镜像内部的原生库加载器,加载通过图片上传路由提交的一个共享对象。
四、测试环境与依赖栈
PoC 瞄准的是一个刻意构造的、存在漏洞的 Next.js 实验环境,它提供两条路由:POST /api/upload 把上传文件保存到应用的 uploads/ 目录;GET /api/optimize?file=NAME 用 sharp 打开已保存文件、转成 PNG 并返回。AVIF 解码发生在 sharp 捆绑的 libvips 与 libheif 内部。
- Next.js 15.5.23
- sharp 0.34.4
- 捆绑 libvips 8.17.2
- 捆绑 libheif 1.20.2
- Ubuntu 配置:Node.js 25.8.1,PIE(
ET_DYN),glibc 2.43 - Debian 13 配置:APT 版 Node.js 20.19.2,PIE(
ET_DYN),glibc 2.41 - Linux x86-64,启用 ASLR 与 NX
Node 可执行文件本身是地址无关的,而 sharp 及其捆绑的原生库是该平台普通的预编译包。利用之所以不需要随机化的 Node 基址,是因为它的控制流目标落在 libvips 内部。
需要特别说明的是:这条应用路由本身被刻意做得“宽松”——它允许把上传的 ELF 共享对象以看似图片的文件名 x.jpg 保存下来。一个真正严格的生产部署(强 magic 校验、服务端生成存储名、对象存储隔离)即便解码器仍有漏洞,也很可能挡住这一“ staging ”步骤。
五、为什么仅有崩溃还不够
网格 bug 立刻给了我们紧挨色度缓冲区之后的一次受控 58 字节写,但单凭它通常只会崩溃。把它变成可重复的远程代码执行,有四个关键进展:
-
定位当前进程
:另一张 AVIF 让优化路由在 PNG 像素里回显 libvips 指针,从而暴露随机化的 libvips 基址。
-
选择目标地址
:58 字节溢出保留了相邻的 glibc chunk 头,并把 libheif 的 Cr 平面查找重定向到一个伪造的映射条目,从而构造出受约束的 write-what-where。
-
立刻触发
:选定的写操作在图像拷贝循环里替换了
memcpy@GOT,紧接着的一行就用上了被改写的 GOT 条目,从破坏到控制流劫持几乎没有延迟。 -
复用兼容的原生加载器
:捆绑的 libvips 镜像内部自带一份 GLib 的
g_module_open_full,这个 GModule 例程封装了dlopen,能从文件路径加载原生共享库。被劫持的那次调用已经把受控路径uploads/x.jpg被放入RDI寄存器;加载器打开该文件时,共享对象的构造函数便直接执行——无需 ROP 链,也无需 Node 代码地址。
g_module_open_full 是这批“诀窍”里关键的一个:光有一个随机代码地址并不够,我们需要一段所处模块随机基址已被泄露、且能容忍劫持调用点寄存器状态的代码。该例程在确切的捆绑 libvips 构件里位于偏移 0x3e995e。利用把这个经 profile 校验过的偏移加到恢复出的 libvips 基址上,于是最后控制流步骤里用到的每一个运行时代码地址都来自同一个被泄露的模块。
六、完整利用链
1. 上传一个恶意原生库
攻击机上,利用脚本编译出一个很小的共享对象,其构造函数在目标上运行固定的验证命令 /usr/bin/id 并把输出回传给监听端;目标本身不进行任何编译。
应用通过普通的上传路由,以文件名 x.jpg、媒体类型 image/jpeg 接收这个 ELF,并把它存为 uploads/x.jpg。文件名要短是有讲究的——后续载荷只有 16 字节来容纳完整的、以 null 结尾的库路径。
2. 用回显像素绕过 ASLR
ASLR 让 libvips 每次目标启动都拿到一个新基址。利用按顺序使用这两条实验路由:
-
POST /api/upload把一个特制的“ASLR 泄露 AVIF”保存到随机文件名下;
-
GET /api/optimize?file=NAME让 sharp 解码该 AVIF 并转成 PNG;
-
优化路由把 PNG 字节放进 HTTP 响应返回。在这个图像布局里,部分 alpha 通道字节来自未初始化的原生内存,包含了指向已加载 libvips 库的指针。
这份泄露 AVIF 与最后触发网格溢出的 AVIF 是分开的。利用反复上传、优化,直到某次返回的 PNG 恰好只支撑一个 profile 和一个 libvips 基址。之后,它再通过同一条上传路由上传最终的 AVIF,并再次调用同一条优化路由来触发溢出。
一个真实响应里出现过这样三组共享值,每组指针都对应捆绑 libvips 构建里的已知槽位,减去其文件偏移就能恢复出同一个页对齐基址:
0x7f6be25c3120 - 0x1c3120 = 0x7f6be2400000
0x7f6be25be1b0 - 0x1be1b0 = 0x7f6be2400000
0x7f6be25c3130 - 0x1c3130 = 0x7f6be2400000
Ubuntu 与 Debian 两个 profile 用的是同一个捆绑 libvips 二进制;在两种环境里,回显像素都会暴露位于 libvips_base + 0x1be1b0、+ 0x1c3120、+ 0x1c3130 的指针。减去任意对应偏移即可推出候选的随机库基址。由于这三个锚点是共享的,它们能确认 libvips 二进制并恢复其基址,却无法区分 Ubuntu 与 Debian 的原生栈。
要区分这两个被测 profile,利用还需要再多泄露一个指针:Ubuntu 响应里稳定包含 libvips_base + 0x33cd0c 处的指针,Debian 响应里则是 libvips_base + 0x1de9c0 处的指针(这些都是相对当前 libvips 基址的偏移,而非固定运行时地址)。只有当全部四个指针都解析到同一基址时,才会选定一个 profile;否则利用会停下,而不是带着不确定的 profile 继续。
下面这段简化后的核心计算,从回显的 alpha 像素里抽取 8 字节值,减去每个声明的锚点偏移,只统计对齐且达到重复阈值的候选:
alpha = image.getchannel("A").tobytes()
values = {
int.from_bytes(alpha[offset:offset + 8], "little")
for offset inrange(0, len(alpha) - 7, 8)
}
for value in pointer_values:
for anchor in profile.libvips.leak.anchors:
candidate = value - anchor.offset
alignment = profile.libvips.leak.base_alignment
if candidate > 0andnot (candidate & (alignment - 1)):
repetitions[candidate, anchor.offset] += 1
anchors_by_base = defaultdict(set)
for (base, anchor_offset), hits in repetitions.items():
anchor = next(
item for item in profile.libvips.leak.anchors
if item.offset == anchor_offset
)
if hits >= anchor.minimum_repetitions:
anchors_by_base[base].add(anchor_offset)
supported = [
base for base, matched_anchors in anchors_by_base.items()
iflen(matched_anchors) >= profile.libvips.leak.required_anchors
]
利用只有在完整清单恰好产出“一个支持的 profile + 一个基址”组合时才继续,随后把该 profile 的 memcpy@GOT 偏移与内部 GModule 加载器偏移加到这个基址上。这正是 PIE 版 Node 可执行文件被绕过的诀窍:它从来不需要一个 Node 地址——覆盖用到的每一个运行时代码地址都属于 libvips,且都由恢复出的 libvips 基址推算而来。
哪些值是硬编码的? 这个利用依赖若干硬编码的 profile 值,光有 Linux 发行版和 glibc 版本并不够,它们取决于完整的原生栈。这些值被收进带版本的“原生栈 profile”里,而不是散落在利用代码各处:
-
libvips 泄露 profile 声明了共享锚点
0x1be1b0、0x1c3120、0x1c3130;第四个锚点用来识别周边栈:0x33cd0c(Ubuntu)与0x1de9c0(Debian)。 -
libvips.memcpy_got.offset = 0xf83098是该 libvips 二进制里
memcpy的 GOT 偏移,运行时地址即泄露基址加上该偏移。 -
libvips.control_target.offset = 0x3e995e定位同一 libvips 镜像内部的
g_module_open_full例程;运行时目标为泄露基址加上该偏移。由于该内部例程未作为动态符号导出,profile 还存了它的前 32 字节,离线校验器会对这些字节逐一核对。 -
heap_calibration.anchor_page_low16 = 0x6000是两个被测 PIE 布局共享的、经校验的分配器页道。
-
heap_calibration.fake_node_delta_from_anchor_page:Ubuntu 为
-0x690,Debian 为-0x3a0,分别产出0x5970与0x5c60的部分指针选择器。
随机化的 libvips 基址与运行时加载器地址并不硬编码:利用从每个进程回显的像素里恢复基址,再把加载器算作 libvips 基址 + 0x3e995e。两字节的堆选择器是确切 PIE profile 里一个独立的分配器布局不变量。
堆选择器是怎么推出来的? 这正是两个 PIE profile 的分歧点。两种被测布局都把相关分配器页放在 low-16 道 0x6000,但伪造节点距该页的偏移不同:
page_lane = 0x6000
ubuntu_selector = (page_lane - 0x690) & 0xffff# 0x5970
debian_selector = (page_lane - 0x3a0) & 0xffff# 0x5c60
回显 PNG 里的 profile 标记选定了原生栈,该 profile 再提供匹配的“页到节点”关系。Debian 的关系在正式写入清单前,跨 5 次全新的诊断进程生命周期都被测出未变。因此选择器是“一个确切、经校验的原生栈”的属性,而不是仅凭操作系统名推断出来的值。
3. 把溢出变成 GOT 覆写
libheif 如何存储图像平面:heif_channel 是一个枚举,给图像分量命名(本构建中 heif_channel_Y = 0、Cb = 1、Cr = 2)。libheif 以该枚举为键,维护 std::map<heif_channel, ImagePlane> m_planes。ImagePlane 记录某一分量像素缓冲区的尺寸、位深、分配细节、mem 与 stride;mem 指向可用像素缓冲区的对齐起始,stride 是“从一行起始到下一行起始”的字节数(可能因对齐填充而大于可见行宽)。
row_address = mem + (row_number * stride)
sample_address = row_address + (column * bytes_per_sample)
std::map 是有序键值容器;GNU libstdc++ 在本构建里把它实现为红黑树(std::_Rb_tree)。每个节点保存一个键值对、一个颜色位,以及指向父、左子、右子的链接。当文中提到“节点的右子指针”时,指的是 GNU 树节点里的右链接,与 Node.js 运行时内部无关。破坏这条链接,会改变下一次 get_plane(Cr) 查找所访问的键值条目。
那 58 字节越界写:在测得的堆布局里,发生溢出的 Cb 分配紧挨着树的活跃根节点。这 58 个受控字节的分工如下:
overflow +0x00..0x0f 分配尾部,保持为 0
overflow +0x10..0x1f 下一个 glibc chunk 头,prev_size=0,size=0x75
overflow +0x20..0x37 树节点前 24 字节,保持为 0
overflow +0x38..0x39 profile 选择器,Ubuntu 为 0x5970,Debian 为 0x5c60
为避免分配器立刻拒绝,载荷用预期的 0x75(含 PREV_INUSE 位)重建 glibc 头,并还原树的前几个字段;只有最后两个字节改变了根节点的右子指针。保留高 6 个指针字节,就保住了随机化的堆前缀;只改低 16 位,则选中同一 64 KiB 堆区里的一个邻近地址。
伪造的平面映射节点:那个邻近地址并非偶然——更早的 Cb 行已经把攻击者控制的像素拷贝到了那里。利用把这些字节排布成一个伪造的 std::map 节点,键为 heif_channel_Cr,其内嵌 ImagePlane 宽 58 字节、目标指针为 memcpy@GOT - 16、行跨度为 0:
struct.pack_into("<I", node, 32, 2) # key: Cr
struct.pack_into("<II", node, 48, 58, 66) # plane dimensions
struct.pack_into("<Q", node, 64, write_target) # ImagePlane::mem
struct.pack_into("<I", node, 88, 0) # ImagePlane::stride
当 libheif 再次调用 get_plane(Cr) 时,被破坏的右子指针会把树查找引向这个伪造节点;匹配的 Cr 键使查找返回伪造的 mem 与 stride。
为什么它是 write-what-where:Cr 拷贝循环现在把 memcpy@GOT - 16 当作输出平面。由于伪造跨度为 0,每一输出行都从同一地址开始——于是像素是“what”、ImagePlane::mem 是“where”,这就是利用用到的受约束 write-what-where 原语。全局偏移表(GOT)保存着导入函数的运行时目标;经过 memcpy@GOT 的调用通常解析到 glibc 的 memcpy,替换该条目就改变了同一拷贝点下一次间接调用的去向。
4. 加载上传的原生库
初始受控行:每个受控 Cr 行布局很简单——字节 0–15 是 null 结尾的库路径 uploads/x.jpg;随后 8 个字节是运行时地址 libvips 基址 + 0x3e995e;字节 24–57 全为 0。由于目标地址比 GOT 条目靠前 16 字节,这次拷贝把路径紧挨槽位放置,并把 GModule 加载器地址写进 memcpy@GOT。
为什么这个辅助函数契合调用点:选中的例程是 libvips 内部的 g_module_open_full 副本,概念上它接收一个库路径、加载器标志与一个可选的 GError **,检查命名文件后调用 dlopen——加载一成功,共享对象的构造函数立即执行。
在 x86-64 上,被劫持的 memcpy(dest, src, len) 调用里 dest 在 RDI、src 在 RSI、len 在 RDX。这次受控拷贝后,RDI 指向 memcpy@GOT - 16,其内容正是 uploads/x.jpg——这恰好是 GModule 例程期望路径的位置。
另外两个寄存器并不持有正常的 GModule 参数:RSI 仍指向一个图像行指针,RDX 仍是 58 字节的拷贝长度。这个确切的加载器实现会把 ESI 掩到受支持的 GModule 标志位;在成功路径上,它从不把 RDX 当作错误指针解引用。团队在把它放进远程链条前,已针对被测 libvips 二进制校验过这条成功路径。
这次 memcpy 在替换掉自己的 GOT 条目后结束;循环随后通过同一个间接调用点处理下一行 Cr。这一次执行进入了 GModule 加载器,RDI 指向已保存的路径。加载器打开 uploads/x.jpg,其构造函数运行 /usr/bin/id,结果被发回监听端。
加载器入口:
RIP = libvips + 0x3e995e,RDI指向uploads/x.jpg;dlopen收到uploads/x.jpg,栈帧 1 返回进 libvips 的 GModule 加载器。
,本文仅复现其公开披露的逻辑片段,不构成可独立运行的武器化利用。</p>
</blockquote>
<p><img decoding=)
十、部署条件与局限
这篇 PoC 证明的是“影响”,而非“普适可利用性”。一个目标必须同时暴露:一条兼容的、存在漏洞的图像处理路径,以及一种能把攻击者控制的共享对象以图片形式、按原生加载器可达的短路径保存下来的手段。
libvips 加载器偏移、重定位偏移、分配器页道与部分指针关系,都特定于某个被测原生栈。加载器目标虽不依赖 Node 可执行文件地址,但不同的 Node 或分配器布局可能改变伪造节点选择器所用的关系——这类部署需要自己的、经校验的 profile。
十一、缓解与修复 CVE-2026-32740
-
升级到 libheif 1.22.0 或更高版本
,然后重建或替换每一个捆绑了该漏洞库的 sharp / libvips 包。
-
确认原生库在生产环境确实已加载
:只更新顶层 JavaScript 包,未必能替换掉捆绑的二进制。
-
在已部署的原生依赖链被核验之前,禁用或隔离 AVIF / HEIF 处理。
-
校验文件 magic、由服务端生成文件名
,并尽可能把上传内容放到应用工作目录之外、放在
noexec文件系统上。 -
让图像解码器运行在一次性、最小权限的 worker 中,不携带应用密钥、不开放无限制的出站网络访问。
-
对原生图像 worker 崩溃、异常的模块加载、或从上传目录打开可执行内容等行为告警。
十二、结论
对团队而言,有意思的地方在于这些原语如何咬合在一起:CVE-2026-32740 提供了相邻写,一份独立的像素回显击败了 ASLR,伪造的平面映射节点把破坏变成了受约束的 write-what-where,而紧接的 memcpy@GOT 切换在另一个 worker 介入前就抵达了一个 libvips 相对的原生加载器。
在两个被测 PIE 实验环境里,这条链在 20 个全新进程中产出了 20 次回调,对应 20 个不同的随机 libvips 基址,每一次回调都回传了 /usr/bin/id 的输出。这远不止一次解码器崩溃:在文档化的应用条件下,一次未认证的图像上传,即便 Node 可执行文件是位置无关的,也能抵达原生命令执行。
真实性核实
-
漏洞真实性
:CVE-2026-32740 经 NVD / CIRCL / Ubuntu 安全公告 / Vulners 多方印证,确认为 libheif ≤ 1.21.2 中网格分块色度合成的堆溢出写(CWE-787),CVSS 3.1 评分 8.8(High),官方修复版本 libheif 1.22.0,厂商公告 GHSA-frfr-f3vg-2g6j(strukturag/libheif)。本文实验所用 libheif 1.20.2 属于受影响区间,与上游记录一致。
-
利用细节真实性
:文中偏移、profile 锚点、堆选择器与运行结果均来自 FortBridge 公开研究(作者 Adrian Tiron,2026-09-28);其完整利用位于私有仓库,本文仅复现公开披露的逻辑骨架,未做无依据增补。
-
限制说明
:该利用高度依赖特定原生栈(Node / sharp / libvips / libheif / glibc / libstdc++ 版本与分配器布局),并非对任意 sharp 部署通用;实际影响应结合部署条件与缓解措施评估。
参考地址
-
FortBridge 原文
:https://fortbridge.co.uk/research/cve-2026-32740-nextjs-sharp-libheif-rce/
-
libheif 安全公告 GHSA-frfr-f3vg-2g6j
:https://github.com/strukturag/libheif/security/advisories/GHSA-frfr-f3vg-2g6j
-
libheif 修复版本 v1.22.0
:https://github.com/strukturag/libheif/releases/tag/v1.22.0
-
FortBridge 私有利用仓库(需授权)
:https://github.com/FORTBRIDGE-UK/libheif-grid-nextjs-rce
-
CVE-2026-32740(CIRCL 漏洞库)
:https://vulnerability.circl.lu/vuln/CVE-2026-32740
-
Ubuntu 安全公告 CVE-2026-32740
:https://ubuntu.com/security/CVE-2026-32740
-
Hacktron《Hacking OpenAI》研究(动机来源)
:https://www.hacktron.ai/blog/hacking-openai
-
sharp 项目仓库
:https://github.com/lovell/sharp
-
GLib GModule 文档
:https://docs.gtk.org/gmodule/
-
GNU libstdc++
std::map实现:https://gcc.gnu.org/onlinedocs/gcc-14.1.0/libstdc%2B%2B/api/a00728_source.html
END
公众号内容都来自国外等平台- 搜索的内容通过结合编写 –
公众号 | AnQuan7 (Ots安全)
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:Ots安全 《CVE-2026-32740 深度剖析:一张 HEIF 图片如何击穿 Next.js,触发 libheif 堆溢出 RCE》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。











评论