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

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

Web 安全远程代码执行Next.jsSharplibheif

Beyond the Pixel — 探讨攻击者控制的图片输入如何穿透现代 Web 框架抽象层,最终在底层原生编解码器中转化为服务端代码执行。以 Next.js、Nuxt.js 与 Astro 等举例,说明如何从 libheif 追踪到服务端图片处理链上的 RCE。

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

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

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

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

JS
// 前端组件声明
<Image src={image} width={800} height={600} />

// 或者后端的简单调用
sharp(input)
  .resize(800, 600)
  .toBuffer();

但无论是 Next.js、Nuxt 还是 Astro,当这些 <Image /> 组件触发服务端处理时,背后实际调用的核心引擎几乎都是 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 解码器,形成容器内部嵌套解码器的复杂结构。
进入 libvips 后,输入格式决定下探到哪条原生解码链:AVIF 最长、JPEG 最短、TIFF 会容器套容器
进入 libvips 后,输入格式决定下探到哪条原生解码链:AVIF 最长、JPEG 最短、TIFF 会容器套容器

同一个入口,格式不同,落到的原生 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 团队抢先提交。

CVE-2026-84383 漏洞提交记录截图
CVE-2026-84383 漏洞提交记录截图

不过这个 case 依旧是比较经典的一个 case。就以这个漏洞为例,该漏洞存在于 libheif 1.22.01.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 结构设计,天然支持复杂的引用关系:例如用 dimg 声明图像派生,用 iden 做恒等变换,以及用 auxl 挂载 Alpha 通道或深度图等辅助数据。[3]

Alpha 通道独立挂载以及重复通道进入 m_storage 的过程
Alpha 通道独立挂载以及重复通道进入 m_storage 的过程

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

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

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

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

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

CPP
// 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,内存里就会活生生卡进两个同名通道:

TEXT
[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)),对向量中的所有通道照单全收。

同一个 HeifPixelImage 对象在属性查询与像素遍历下呈现两种不一致的视图
同一个 HeifPixelImage 对象在属性查询与像素遍历下呈现两种不一致的视图

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

CPP
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. 扫到第一个 8-bit Alpha,走 SDR 分支,按 uint8_t* 规规矩矩写入;
  2. 轮到第二个 16-bit Alpha,因 m_bit_depth > 8,控制流直接拐进 HDR Planar 分支:
CPP
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*。紧随其后的像素写入:

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

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

  • 分配大小:128 × 128 × 1 byte = 16384 bytes
  • 实际写入:128 × 128 × 2 byte = 32768 bytes

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

按 8-bit 分配、按 16-bit 写入所导致的堆越界示意
按 8-bit 分配、按 16-bit 写入所导致的堆越界示意

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 也存在这个利用点。所以真实的攻击面可能比想象中的更广泛。

官方 PoC 的五个 HEIF Item 之间的派生与 Alpha 引用关系
官方 PoC 的五个 HEIF Item 之间的派生与 Alpha 引用关系

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

  1. 解码器从主图 Item 5 切入,优先解析其通过 auxl 引用的 Alpha 项 Item 3。
  2. Item 3 作为 iden 节点,其解码入口在解析关联的 Item 1 时,直接拉起完整解码流程:return imgitem->decode_image(...)。该动作顺带把 Item 1 的 8-bit Alpha(Item 2)一并解包并组装进了 HeifPixelImage
  3. 随后控制流退回 Item 3,逻辑检测到自身还挂载了 16-bit Alpha(Item 4),继续调用 transfer_channel_from_image_as() 合并。去重校验的缺席,让对象内部不可逆地并存了两套 Alpha 平面。
  4. 此时 Item 3 尺寸(16 × 16)与主图 Item 5(128 × 128)存在差异,系统强制触发 scale_nearest_neighbor() 缩放。类型混淆的平面随之被喂进缩放循环,引发堆溢出。

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

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

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

Next.js (/_next/image)

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

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

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

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

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

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

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

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

Nuxt.js (/_ipx/...)

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

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

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

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

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

JAVASCRIPT
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 模块处理:

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

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

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

三个框架不同的图片入口最终收敛到同一条 Sharp、libvips、libheif 原生管线
三个框架不同的图片入口最终收敛到同一条 Sharp、libvips、libheif 原生管线

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

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

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

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

且由于 Discourse 是容器化部署,因而 ASLR 也不再构成主要障碍。

这个案例也证明了一件事,在实际场景下,单纯通过判定业务组件是否使用了 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