现代 Web 框架共享原生图片处理链的攻击面拆解

· 2026-09-19 10:10 · 0 阅读

原创 p4nda 2026-09-19 10:10 浙江

以 CVE-2026-84383 为例,拆解 Next.js、Nuxt 与 Astro 共享的 Sharp、libvips、libheif 原生图片处理链,以及底层内存破坏如何获得 Web 远程可达性。

0x01 现代 Web 框架背后的原生解析链路

众所周知,图片在现代 Web 框架(next.js/nuxt.js/Astro等)里早已不是纯静态资源,为了解决尺寸自适应、首屏占位图以及动态转码(WebP/AVIF)的问题,框架普遍直接在服务端内置了一整套实时的图片处理服务。

图片处理服务的逻辑很直观:数据进入服务端后,先读文件头魔数嗅探出真实格式并拿取宽高,接着调对应的 Loader 把压缩流解开成原始像素,然后在内存里跑完缩放、旋转或色彩转换(如 YUV 转 RGBA),最后再重新编码并缓存或返回。

不过在业务层,开发者面对的往往只有一行极简的组件或链式调用:

                  // 前端组件声明
<Image src={image} width={800} height={600} />
// 或者后端的简单调用
sharp(input)
  .resize(800, 600)
  .toBuffer();

但无论是 Next.js、Nuxt 还是 Astro,当这些   组件触发服务端处理时,背后实际调用的核心引擎几乎都是 Sharp(即经典的 sharp(input).resize().toBuffer() 流水线)。

但Sharp 本身没有格式解码能力,它的作用仅限于通过 Node-API 将 JS 逻辑桥接到 libvips。

一旦执行 toBuffer(),未受信任的二进制数据便穿过 Node-API 直达 libvips,然后 libvips 依据内容嗅探匹配对应的 Foreign Loader,最终调度底层的 C/C++ 库完成内存分配与像素写入。

这短短几毫秒的调用,实际上直接贯穿了 JS 运行时、Node 插件、图像引擎与原生解码库四层体系。

数据进入 libvips 之后,具体的底层执行路径完全由输入的实际数据格式决定,并分化为复杂度与调用深度迥异的三条主要原生链:

  • • AVIF / HEIF 链:执行路径最深。由 heifload 调度 libheif,进而调用 dav1d 或 libaom 完成 AV1 解码,随后处理通道变换并完成 YUV 到 RGBA 的色彩空间转换。

  • • JPEG 链:执行路径最短。由 jpegload 直通底层的 libjpeg-turbo 完成反量化与色彩空间变换。

  • • TIFF 链:作为多层嵌套容器,TIFF 内部的条带(Strip)可独立嵌入 JPEG、Deflate、WebP 或 Zstd 解码器,形成容器内部嵌套解码器的复杂结构。

同一个入口,格式不同,落到的原生 codec 就完全不同,链的深浅本身就是攻击面差异。

因此,不可信的图片输入不会被限制在 JavaScript 沙箱内,它会依次穿过 JavaScript、C、C++、Rust 以及手写汇编代码,甚至跨越多个底层线程池,上层框架所封装的“图片优化”抽象之下,直接暴露着一整套由系统级原生库构成的依赖体系。

从 Sharp 到 Codec,这一整段都是原生代码,数十万行、跨多个项目、跑在多个线程池上。

开发者写下一行 <Image />,框架就把攻击者控制的二进制透明地喂给了这一整排 Native Parser。


0x02 从底层解析 Bug 到框架级远程攻击面

服务端图片处理的安全风险不是新的攻击面,从 ImageMagick 委托机制引发的远程命令执行(ImageTragick),到 Ghostscript、SVG 实体解析以及字体引擎中的内存破坏、多媒体解析,长期是漏洞挖掘的重要方向。

不过,这类缺陷在今天面临着完全不同的暴露面。早年的图片处理多半是业务层偶尔调用的独立 CLI 或离线脚本,而在现代全栈框架里,动态优化已经被做成了随服务默认启动的内置路由:

  • • Next.js:/_next/image

  • • Nuxt.js(基于 IPX):/_ipx/...

  • • Astro:/_image

一旦命中这些路由,框架底层会自动完成拉流、建 Buffer、嗅探格式、调 Sharp 转码并写入缓存的整个流程,且业务层对此毫无知觉。

这种设计就彻底打通了攻击链路:在攻防视角下,一个解析器缺陷能否形成 critical 威胁,往往不只看内存破坏本身,而是在于“怎么传入恶意数据”。现代框架的自动化处理流程,刚好替攻击者解决了原本极高的参数传递的门槛。

  • • 本地CLI场景:若某个缺陷仅能通过本地 CLI 触发(例如执行 heif-dec input.heic output.png),该问题受限于攻击者在宿主机上的前置执行能力,属于依赖本地环境的底层 Bug。

  • • 框架场景:框架把接收外部 URL 并全量解码做成了公开接口,这样一来,底层同一处内存漏洞就获得了免认证的远程触发通路,直接蜕变成了远程代码执行。

同时,由于 Sharp 与 libvips 属于跨框架共用的底层基建,针对该管线的漏洞往往能够影响一整类技术栈。


0x03 CVE-2026-84383 机制与框架利用路径

今年6月,在做新攻击面研究的时候,发现了这个漏洞 CVE-2026-84383(GHSA-g89c-p67h-r497),可惜漏洞还没有捂热,刚提交的时候,就发现已经被 hacktron 团队提交了。

不过这个 case 依旧是比较经典的一个 case。就以这个漏洞为例,该漏洞存在于 libheif 1.22.0 至 1.23.1 版本中,修复发布于 1.23.2 [1] [2]。

这个洞的逻辑并不复杂,攻击者可以利用嵌套的 iden 与 auxl Item 制造状态异常,导致 HeifPixelImage::scale_nearest_neighbor() 缩放时发生位深错配,直接把 16-bit 像素写进只按 8-bit 分配的 Alpha 内存里,触发 Heap Buffer Overflow。

下面来简单分析下这个漏洞。

1. HEIF Item 拓扑与通道唯一性破坏

HEIF 基于 ISOBMFF 的 Item 结构设计[3],天然支持复杂的引用关系:例如用 dimg 声明图像派生,用 iden 做恒等变换,以及用 auxl 挂载 Alpha 通道或深度图等辅助数据。

这套机制允许 Alpha 通道脱离主颜色数据独立存在,仅靠一条 auxl 关系绑定到宿主图像。

libheif 在完成解码后,会通过 HeifPixelImage 对象管理解出来的像素平面,所有通道分量全都被塞进底层的 m_storage 向量里:

                  HeifPixelImage {
    std::vector<ComponentStorage> m_storage = {
        Y,
        Cb,
        Cr,
        Alpha
    };
};

下游的处理逻辑默认一种通道只能有一块内存。

但 transfer_channel_from_image_as() 偏偏没做通道查重[4](源码甚至还留着 // TODO 没实现):   

                  // TODO: check that dst_channel does not exist yet
plane.m_channel = dst_channel;
m_storage.push_back(plane);

新平面被无脑 push 进 m_storage,一旦先塞 8-bit Alpha 再塞 16-bit Alpha,内存里就会活生生卡进两个同名通道:

                  [Y, Cb, Cr, Alpha(8-bit), Alpha(16-bit)]

2. 视图分裂引发堆越界写

当对象内部包含多个同名通道时,libheif 内部的两套逻辑产生视图分裂:

  • • 元数据查询(首项即止):get_bits_per_pixel() 等接口依赖 find_storage_for_channel() 按名寻址,扫到第一个同名平面便直接返回,因此误判 Alpha 通道位深仅为 8-bit。   

  • • 像素缩放(全量循环):真正处理像素的 scale_nearest_neighbor() 却脱离了通道名映射,直接遍历整个 m_storage 向量(for (const auto& component : m_storage)),对向量中的所有通道照单全收。

在缩放流程启动时,代码先为输出图像分配内存:

                  out_img->add_channel(
    heif_channel_Alpha,
    width,
    height,
    get_bits_per_pixel
(heif_channel_Alpha),
    limits
);

前面提到 get_bits_per_pixel() 遇到第一个同名通道就交差,因此按 8-bit 返回,输出的 Alpha 缓冲区也严格按单样本 1 字节(1  byte/sample )分配。   

紧接着,循环开始挨个处理各个通道:

  1. 1. 扫到第一个 8-bit Alpha,走 SDR 分支,按 uint8_t* 规规矩矩写入;   

  2. 2. 轮到第二个 16-bit Alpha,因 m_bit_depth > 8,控制流直接拐进 HDR Planar 分支:

                  const uint16_t* in_data =
    static_cast
<const uint16_t*>(plane.mem);
uint16_t
* out_data =
    out_img->get_channel_memory<uint16_t>(
        heif_channel_Alpha,
        &out_stride
    );

致命的类型错配在这里产生:out_img 只给 Alpha 分配过一次内存,get_channel_memory<uint16_t>() 拿到的其实就是此前按 8-bit 申请的地址,随后被粗暴地强转成了 uint16_t*。紧随其后的像素写入:

                  out_data[y * out_stride + x] =
    in_data[iy * in_stride + ix];

单个样本的写入跨度瞬间翻倍为 2 字节。在  128 x 128 分辨率下: 

  • • 分配大小:128 x 128 x 1  byte = 16384  bytes

  • • 实际写入:128 x 128 x 2  byte = = 32768  bytes

写入体积直接拉满到分配空间的 2 倍,顺着堆块边界一口气向后覆盖整整 16 KB。

ASan 日志明确捕获到了崩溃点:scale_nearest_neighbor() 里的 HDR 分支触发 WRITE of size 2,破坏目标正是此前 add_channel() 申请的堆块。这处堆越界写没有任何随机性,每次解码稳定复现。因此,在特定堆布局下,能在覆盖相邻的存活像素内存后正常退栈,达成OOB。 

3. PoC 状态构造:嵌套 Item 派生链

漏洞的触发依赖于一套精心设计的多层引用结构:                                                    


Item

类型

几何规格

拓扑作用说明

1

mski

16×16 / 8-bit

基础图像(base),通过 auxl 挂载 Item 2

2

mski

16×16 / 8-bit

Item 1 绑定的 Alpha 通道

3

iden

16×16

恒等派生自 Item 1,自身额外通过 auxl 挂载 Item 4

4

mski

16×16 / 16-bit

Item 3 绑定的 Alpha 通道

5

mski

128×128 / 8-bit

主图像(Primary),其 Alpha 引用指向 Item 3

整个构造最巧妙的细节,在于完全避开了对 HEVC/AV1 视频编解码器的依赖。PoC 全程借用 HEIF 规范内置的掩码图像(mski)承载像素。   

传入的数据包虽然声称自己是 AVIF(ftyp=avif),但底层从解包到 Alpha 合并完全由 libheif 自闭环完成,绕过了外部的 dav1d/libaom 插件,只要运行时进入通用的 heif_decode_image() 入口,哪怕是个“阉割版”的 libheif 也存在这个利用点。 所以真实的攻击面可能比想象中的更广泛。

实际解码时的流转链路如下:

  1. 1. 解码器从主图 Item 5 切入,优先解析其通过 auxl 引用的 Alpha 项 Item 3。   

  2. 2. Item 3 作为 iden 节点,其解码入口在解析关联的 Item 1 时,直接拉起完整解码流程:return imgitem->decode_image(...)。该动作顺带把 Item 1 的 8-bit Alpha(Item 2)一并解包并组装进了 HeifPixelImage。   

  3. 3. 随后控制流退回 Item 3,逻辑检测到自身还挂载了 16-bit Alpha(Item 4),继续调用 transfer_channel_from_image_as() 合并。去重校验的缺席,让对象内部不可逆地并存了两套 Alpha 平面。   

  4. 4. 此时 Item 3 尺寸()与主图 Item 5()存在差异,系统强制触发 scale_nearest_neighbor() 缩放。类型混淆的平面随之被喂进缩放循环,引发堆溢出。

漏洞本质是一起严重的对象内部表示不变量破坏(Representation Invariant Violation)。官方在 1.23.2 里的修复的非常干脆,直接在 transfer_channel_from_image_as() 入口卡死,检测到已有同名通道就果断拒收,守住每个逻辑通道只占单一存储平面的底线,同时拔掉了 iden 节点过宽的尺寸校验,彻底杜绝了 ispe 声明与真实像素几何脱节的隐患。  

4. 框架调用链与 Loader 可达性

这个堆溢出的漏洞能转换为 Web 远程攻击面的核心是 Sharp [5] [6],整条链能不能走通,关键点就在于攻击者控制的数据表能否传入VipsForeignLoadHeif 这个 Loader。无论是 URL 后缀名还是 HTTP Content-Type 均可伪造,而底层的 libvips 不管这个,它只通过文件的内容来判断,因而直接把恶意数据传入了 HEIF 加载器。

Next.js (/_next/image)

Next.js 的典型请求结构如下[7]:

                  GET /_next/image?url=<source>&w=1920&q=75

服务端在通过 remotePatterns 校验来源后拉取数据并转换为 Buffer,随后交由 imageOptimizer() 启动 Sharp 流水线:

                  sharp(buffer, {
  limitInputPixels,
  sequentialRead
})
  .timeout(...)
  .rotate()
  .resize(...)
  .toBuffer();

需要注意的是,这里的参数是表面现象,只要传入的内容是 AVIF/HEIF 文件,即使是其他后缀名,也一样可以走进 VipsForeignLoadHeif。

Next.js 官方针对该漏洞发布了安全通告[8](影响版本可在 15.5.24 与 16.3.3 中修复)。其修复机制采用了纵深防御策略:在框架层跳过对 AVIF 的在线优化,并在 Sharp 初始化时直接通过底层 API 封禁 HEIF 加载器:

                  sharp.block({
  operation
: ['VipsForeignLoad']
});

随后仅显式放行确定需要的格式(JPEG、GIF、PNG、SVG、TIFF、WebP),从原生可达性上彻底移除了 VipsForeignLoadHeif。[9]

Nuxt.js (/_ipx/...)

Nuxt Image 的自托管模式基于 IPX,其底层依赖依然为 Sharp。客户端在组件中声明变换参数后,Nuxt 会将其映射为带有修饰符的 URL:

                  /_ipx/w_800,q_80,f_webp/<source>

Nitro 服务端接收到请求后,通过 IPX Storage 后端读取数据并封装为 Buffer:

                  const sourceData = await storage.getData(id, opts);
return
 Buffer.from(sourceData);

随后完成 Sharp 实例化并映射变换算子:

                  const Sharp = await getSharp();
let
 sharp = Sharp(sourceData, { animated, ...options.sharpOptions });
// 映射 resize / rotate / format 等
processedImage = await sharp.toBuffer();

该链路在 Source-to-Sink 的拓扑结构上与 Next.js 完全同构,只要运行时中 IPX 依赖的 libheif 落在受影响版本区间内,同样存在漏洞利用点。[10]

Astro (/_image)

Astro 的运行时图片端点接收形如 /_image?href=...&w=800 的请求[11]。

Astro 在把外部数据暂存至 inputBuffer 后,交由内置的 Sharp 模块处理: 

                  sharp(inputBuffer, {
  failOn
: 'none',
  pages
: -1,
  limitInputPixels
: ...
});

其中的 failOn: 'none' 极大地放宽了校验门槛,使得精心构造的畸形流不会被提前拦截,而后续紧跟的 rotate() 与 toBuffer() 调用,则不可避免地诱导 libvips 展开完整的底层像素解码,从而默认存在利用点。

这个漏洞提交给了官方[12],现在 Astro 官方于 7.2.8 修复了该问题,并将 Sharp 最低依赖版本提升至包含安全更新的 0.35.4。


0x04 从攻击面类型到安全边界划分

实际上这类风险的影响面不局限于现代 Web 框架,Hacktron 披露的《Hacking OpenAI》[13] 实战研究,就是一个典型的案例。

在该案例中(涉及 Discourse 与其底层的 libheif 1.19.x 版本及系统包补丁缺失,属于独立历史缺陷),Discourse 原本使用 FastImage 进行常规图像检查,但因其无法处理 HEIF 格式,转换逻辑自动 fallback 进了 ImageMagick 的转换流程,最终调度到底层的 libheif,形成一条跨越应用层与原生库的代码执行链路:

                  用户上传图片 -> Discourse -> ImageMagick -> libheif -> 原生内存破坏 -> Discourse 主机 RCE

且由于 Discourse 是容器化部署,因而 ALSR 也不是问题了。

这个案例也证明了一件事,在实际场景下,单纯通过判定业务组件是否使用了 Sharp ,不能完全判断是否存在这个漏洞。更应该做的是,判断不可信的数据流是否会自动传入原生解析器。

顺着这一视角,可以把攻击面可归纳为三类安全边界:

1. 运行时动态转换(Web 主进程 RCE)

  • • 典型场景:Next.js、Nuxt/IPX、Directus 等实时图片服务。

  • • 边界分析:很多业务会在 remotePatterns 里把自己的 CDN 设为白名单,攻击者只要能在该 CDN 上传资源,就能引诱服务端主动回源拉取。在缓存未命中时,框架会立刻调用 Sharp 启动全量解码,如果图片处理没有和主进程剥离,攻击者就有可能获取 Web 服务权限。

2. 媒体内容上传(权限提升至主机控制)

  • • 典型场景:Discourse、Strapi 等 CMS 或论坛系统。

  • • 边界分析:在 CMS 或论坛系统中,攻击面主要潜伏在异步流转链路中,低权限用户提交常规图片后,系统后台的任务队列会自动触发转码逻辑,就有可能将恶意数据传入漏洞点,实现RCE。

3. 构建期静态生成(CI/CD 供应链投毒)

  • • 典型场景:Astro、Gatsby 等静态站点生成器。

  • • 边界分析:恶意图片甚至都不需要直接投递给线上接口,提一个外部 PR、改一下 Markdown 依赖或者借由 Headless CMS 就能混进代码库。持续集成跑 astro build 时,构建脚本会在 Build Runner 宿主机上批量执行图片解析与预渲染,如果解析器存在漏洞,那么就有可能实现RCE,由于 CI 节点通常挂载着生产发布 Token、代码签名证书及镜像仓库密钥,拿到执行权便能直接对整条发布供应链进行投毒。


0x05 总结

回头看 Web 安全的发展,可以发现攻击面并不是突然出现的,其往往跟着新的基础能力一起进入应用。

数据库被大规模接入 Web 之后,SQL 注入成为长期存在的问题;动态页面和浏览器能力不断增强之后,XSS、CSRF 等攻击逐渐成为 Web 安全的基本课题;Java 等语言把对象序列化做成通用基础设施之后,反序列化又打开了一批新的代码执行入口。

这些漏洞表面上差异很大,但背后有一个相似的规律:每当一种复杂能力被封装得足够简单,并开始被大量应用默认使用,它背后的信任边界就会随之扩大。

对开发者来说,看到的是更方便的 API、更便捷的功能设计,而攻击者看到的却是一块从输入到复杂执行逻辑的新入口。

现在正在变化的是,越来越多在过去属于“本地能力”的处理逻辑,被框架重新包装成了 Web 基础设施。

图片缩放、文档预览、字体渲染、音视频转码、SVG 栅格化、压缩包解包,甚至模型文件加载,都开始由框架或平台自动完成。

业务代码看到的可能只是一个组件、一个上传接口或者一个 Preview API,但不可信数据已经在进入更底层的原生解析器。

libheif 只是这次暴露出来的一块区域。 随着越来越多“重处理能力”被做成默认 Web 功能,字体、PDF、音视频、压缩归档、Office 文档、SVG、模型文件等原生处理链都会出现类似的问题。

过去需要本地文件和本地程序才能触发的二进制漏洞,正在越来越多地获得 HTTP、上传接口、CMS 内容和 CI 构建流程提供的远程可达性。

从这个角度看,值得继续研究的并不是某一个底层组件的 CVE,而是一个更大的交叉区域:

Web Reachability × Native Attack Surface

阅读原文

跳转微信打开