يوم بدأت أراجع تحميل GGUF في Ember كحد أمني، اكتشفت إن عبارة «ملف نموذج» مضللة شوي. الملف يتحكم في أشياء أكثر بكثير من قيم الأوزان.
أبعاد الـtensor تحدد حسابات الأشكال وقد إيش ذاكرة بنخصص. والـoffsets تحدد أي أجزاء من الملف تتحول إلى نوافذ ذاكرة. والـmetadata تختار هندسة النموذج، وعدد الطبقات، وطول السياق، وهندسة الرؤوس، وجداول RoPE. وأنواع التكميم تختار تنسيق الـbytes والنوى العددية. حتى إعدادات الـtokenizer توصل إلى محلل مستقل ومحرك تعبيرات منتظمة وعقد مفردات. كل هذا يصير قبل ما المحرك يولّد أول token.
لو جا ملف GGUF من تنزيل مو موثوق، أو cache مشترك، أو ملف أحد عبث فيه، فكل هذي الحقول تصير مدخلات تحكم مو موثوقة. قيم الأوزان نفسها ممكن تكون معتمة بطبيعتها، بس البنية اللي حولها فعلياً سياسة تنفيذ للمحمّل.
ملف GGUF مو مجرد كيس أرقام. هو برنامج يشرح للمحرك كيف يبني الحالة اللي بيشغّلها. عشان كذا، بنية الملف تستاهل نفس الحذر اللي نعطيه لأي صيغة ثنائية معقدة ومو موثوقة.
المشكلة من البداية ما كانت محلل متهور مليان كود
unsafe. محلل GGUF في Ember
مكتوب بـRust الآمن، وكان من أول يرفض أغلب المدخلات
المشوهة. كان يتأكد من نهاية الملف، ويقيد طول النص بالـbytes
الباقية، ويرفض الأسماء والمفاتيح المكررة، ويقيد رتبة
الـtensor، ويحسب جداء الأبعاد والأطوال بطريقة آمنة،
ويفحص نطاقات الملف وتداخلها والمحاذاة وأنواع البيانات المدعومة.
وما كان داخل مسار التحليل نفسه أي كود unsafe.
الفرق هذا مهم. الشغل الأساسي ما كان إننا نرمي فوق الكود كومة شروط حدود زيادة.
الفجوة المعمارية كانت إن TensorInfo الخام يمر على
فحوص إجرائية وبعدها يوصل لبناء نوافذ الذاكرة. الفحوص موجودة
وصحيحة، بس البرنامج ما كان عنده تمثيل مستقل يقول: «هذا الوصف عدى كل
الشروط اللي تحتاجها الذاكرة والتنفيذ». أي تعديل بعدين يقدر بالغلط
يغير ترتيب فحص أو يتخطاه، ونظام الأنواع ما حيوقفه.
GGUF bytes
│
▼
parser ───────────────► untrusted descriptors
│
semantic validation
│
▼
validated descriptors
│
▼
tensor / model construction
│
▼
validated execution-facing objects
│
▼
kernels
metadata ──► validated config ──► allocation / model construction
tokenizer.json ──► size / UTF-8 / JSON gates ──► tokenizer library
شغل EmberSEC خلّى الانتقالات هذي واضحة. التحليل يقول إيش الملف يدّعي. والتحقق يقرر: هل Ember مستعد يبني من الادعاء هذا حالة يشغّلها؟ هذي وظيفتين مختلفتين.
فكرة التغيير في Rust بسيطة:
struct TensorInfo {
// جاي من المحلل ولسه مو موثوق
}
pub struct ValidatedTensorInfo {
// حقول خاصة، وبدون باني عام
}
impl TensorInfo {
fn validate(&self, data_start: u64, file_len: u64)
-> Result<ValidatedTensorInfo>;
}
النوع المتحقق منه في الكود الفعلي يحتفظ بعدد العناصر المحسوب بأمان، وطول البيانات المشفرة، ونطاقها الكامل داخل الملف، مع الاسم والأبعاد والنوع والإزاحة. حقوله خاصة. بناء نافذة الذاكرة ياخذ النوع هذا، مو وصف المحلل الخام. وما فيه باني عام يخلي كود ثاني يدّعي إن وصفاً ما صالح.
البوابة تفحص الرتبة، والأبعاد اللي مو صفر، وتجاوز جداء الأبعاد، وأنواع البيانات المدعومة، وحساب طول البيانات، وتجاوز الإزاحة والنطاق، وحدود الملف، وتداخل النطاقات. وفي التكميم الكتلي تفحص البعد المتصل نفسه، مو بس العدد الإجمالي للعناصر. النقطة هذي قفلت خطأ سلوكي: مسار K-quant المتعجل كان يقدر يقبل شكلاً مشوهاً حجمه الإجمالي متوافق مع حجم الكتلة، مع إن المحور الفعلي للكتل مو متوافق، وبعدها يفسر البيانات بترتيب مختلف عن محرك ملتزم بالصيغة.
الحدود المسماة أخذت مكان الافتراضات المبهمة عن القيم «المعقولة». أعداد الـtensors والـmetadata، وأحجام النصوص والمصفوفات، والرتبة، وعمق التداخل، كلها تتقيد عند حد الملف. وأبعاد النموذج مثل طول السياق وعدد الطبقات وحجم المفردات وعدد الرؤوس وبعد الرأس وعرض التضمين، وكذلك الجداء المستخدم لحجم جداول RoPE، صار لها حدود مستقلة عند بوابة الإعدادات.
الحد الأعلى مو معناته إن القيمة صحيحة دلالياً. هو عقد موارد: المحرك ما يسمح لحقل واحد يطلب ذاكرة عبثية قبل ما بناء النموذج أصلاً يوصل لسبب ثاني يرفض الملف عشانه.
أول هدف fuzzing وقف بعد التحليل والتحقق من الأوصاف. كان مفيد، بس في الغالب أكد إن المحلل الموجود من أول صعب يتلخبط. تعديلات الـbytes الخام عادة تخرب الملف من بدري، ونادراً تعيش كفاية عشان توصل لدلالة النموذج.
عشان كذا، الهدف الثاني حمّل نماذج صغيرة بس مكتملة بنيوياً، وبعدها شغّل بناء النموذج. هنا طلعت افتراضات من طبقة مختلفة: بعد الرأس لازم يكون زوجي لمسار RoPE، والوزن الخطي لازم يكون ثنائي الأبعاد فعلاً، والنموذج يحتاج عدد موجب ومقيد من الطبقات، وكل الـtensors المطلوبة للهندسة المختارة لازم تكون موجودة قبل ما تبدأ التخصيصات اللي تحددها الـmetadata.
ومن هنا صار عندنا مسار مستقل من metadata إلى إعدادات متحقق منها، وبعدها التخصيص. وبناة Llama وGPT-2 أخذوا كمان بوابة جرد تفحص كل الأوزان المطلوبة قبل بداية البناء. أما فحوص الأشكال الخاصة بكل طبقة فتبقى جنب الباني، في المكان اللي يكون فيه عقد الهندسة معروف.
والـtokenizer احتاج حد لحاله. دحين Ember
يفحص حداً مسمى لحجم الملف قبل ما يقرأه، ويتأكد من
UTF-8 وبنية JSON قبل استدعاء مكتبة
tokenizers، ويحتوي أي panic باقي من محلل
المكتبة في بناءات unwind. هذا احتواء، مو معناته إن كل
سلوك للـtokenizer آمن. لسه أنماط regex
المصممة بعناية تقدر نظرياً تستهلك CPU زيادة وقت الترميز، وهذا خطر
نظري موثق ولسه ما أثبتنا مدخلاً يفعّله.
ونفس الفكرة توصل للجانب العددي. دالة فك التكميم القديمة كانت تستقبل
شريحة bytes خام مع عدد كتل. الآن تستقبل
Q8WeightView ما تقدر تطلعه إلا من
QuantizedWeight متحقق منه. المفروض كود
SIMD يستقبل تمثيل ثبتت شروط تخطيطه من قبل، مو زوج
(data, count) فضفاض وتعليق يقول لكل واحد يستدعيه: انتبه.
التجربة استخدمت cargo-fuzz مع بذور صغيرة ومنظمة. فيه
هدف يمر من المحلل إلى بوابة التحقق، وهدف يشغل ملفات
GGUF صغيرة ومكتملة حتى بناء النموذج، وهدف ثالث يمرر
tokenizer.json عبر حد الـtokenizer.
كل فشل صغّرته، وصنفته، وحولته لاختبار انحدار. وبعد كل إصلاح شغلت
الأهداف مرة ثانية لمدة ثابتة.
الأشياء المفيدة اللي طلعها الاختبار كانت موزعة على أكثر من طبقة:
tokenizers 0.20.4. هذا انكشاف لـpanic
من مكتبة خارجية، واحتواه Ember عند حد المكتبة.
لو سمينا كل هذي الأشياء «ثغرات» بنضيع فروق مهمة. الأدلة تشمل panics، وتفسير تخطيط صامتاً بشكل خاطئ، ومسارات تضخيم موارد، وانكشافاً لـpanic في مكتبة، ونقاط ضعف بنيوية من بدون ما نثبت خطأ في الذاكرة. التصنيف خلّى الحل الهندسي مربوط بالشيء اللي شفناه فعلاً.
بعد ما صارت عندي أداة الاختبار، كان السؤال الطبيعي: هل هذي الافتراضات خاصة بـEmber؟ ثبّتنا البيانات ونسخ البرامج، وقارنّا Ember قبل التحصين وبعده، وllama.cpp b7999 عبر مسار التحميل وبناء النموذج، وCandle 0.11.0 عند واجهة محلل GGUF بس.
| الاختبار | Ember قبل التحصين | EmberSEC | llama.cpp b7999 | Candle 0.11 |
|---|---|---|---|---|
| مجموعة ثابتة فيها 62 حالة | 5 حالات panic، وتعطّل البرنامج مرة، وانتهت المهلة مرة | ما صار أي panic أو تعطّل أو انتهاء للمهلة | تعطّل البرنامج مرتين | حالة panic واحدة في المحلل |
| 10,000 تعديل على مدخلات المحلل | ما صار أي فشل | ما صار أي فشل | تعطّل البرنامج 197 مرة، وانتهت المهلة 4 مرات | 6 حالات panic |
| 2,000 تعديل على مدخلات البناء | 194 فشل (9.7%) | ما صار أي فشل | تعطّل البرنامج 32 مرة، وانتهت المهلة 3 مرات | الاختبار وقف عند المحلل |
ثبّتنا هذي النتائج بتاريخ 2026-08-12. «الفشل» هنا يعني panic أو تعطّل البرنامج أو انتهاء المهلة، مو رفض منظم عادي. مجموعة الحالات العدائية تثبت فئات فشل، بس ما تقدر تقول لنا قد إيش هي منتشرة في الملفات الموجودة فعلياً.
لازم نفرّق بين المراحل. نسختين Ember ما سجلوا أي فشل في تعديلات المحلل. مشاكل النسخة القديمة ما ظهرت إلا لما وصلت بنية صالحة كفاية إلى الإعدادات والتخصيص وبناء النموذج والـtokenizer. أما Candle، فاختبرناه عند المحلل بس. ولما نقول إنه «قبل» حالة، هذا يعني إن المحلل قبل بنيتها؛ ما يعني إن باني نموذج بعده في Candle حيقبلها أو يشغلها.
تعاملت مع النتائج الخارجية بهدوء. بعض سلوكيات llama.cpp كانت إعادة اكتشاف. وقت ما أعدت بناء التقرير على master الحالي، كان رفض مفاتيح الـmetadata الفارغة والنصوص ذات الطول المعلن الضخم انصلح من قبل، وصارت حالة صفر طبقات تنتهي بـassert أوضح. اقترحت إصلاح البعد الصفري الباقي في llama.cpp PR #26946؛ اتقفل الـPR بعد ما أشار المشرفون إلى مشاكل وإصلاحات موجودة من قبل. ما اندمج. أما إصلاح المحاذاة الصفرية في Candle فهو PR #3876، ولسه مفتوح وقت كتابة هذي الملاحظة.
إعادة الاكتشاف لسه مفيدة. تثبت إن أداة الاختبار توصل إلى فئات فشل معروفة ولها معنى، وتعطي مرجع انحدار للتصميم المحلي. بس هي مو اكتشاف جديد، والـPR المقفول مو معناته إن الإصلاح اتقبل أو اندمج.
بوابة الوصف أخذت تقريباً 69 نانوثانية لكل وصف tensor في التجربة المجمّدة. على مقياس تحميل نموذج، الكلفة هذي تختفي وسط القراءة وفك التكميم. نموذج Llama Q8_0 صالح بحجم 1.3 GB تحمّل في 1.77 ثانية على النسخة المحصنة مقابل 2.03 ثانية على النسخة القديمة في قياس من ثلاث مرات وعلى جهاز حرارته مو مستقرة. الفرق مو ادعاء تسريع.
الرقم الأهم هنا هو كلفة الرفض. حالة بطول سياق عدائي أخذت 30.8 ثانية على النسخة القديمة وهي تتخبط في مسار تخصيص مرضي. النسخة المحصنة رفضتها خلال 10.4 مللي ثانية، تقريباً نفس زمن تحميل النموذج الصغير الصالح: 10.5 مللي ثانية. التحقق الدلالي مو بس رخيص. في الحالة العدائية منع المحرك من تنفيذ العمل الخطأ والمكلف.
Ember مو:
Rust سهّل علينا نعبّر عن الحد: الحقول الخاصة، والبناة
اللي يقدروا يفشلوا، والشرائح، والحساب المتحقق، والأنواع التعدادية
تعطينا أشياء مفيدة يفرضها المترجم. بس هو ما يعرف إن بعد الرأس لازم
يكون زوجي، أو إن كتلة التكميم تمشي على dims[0]، أو إن
حدين مقبولين كل واحد لحاله يقدروا يطلعوا جداء مو مقبول. هذي عقود
دلالية، ولازم المحرك يصرح عنها ويختبرها.
المفروض نتعامل مع صيغ نماذج التعلم الآلي الثنائية زي أي صيغة معقدة ومو موثوقة: نحللها إلى تمثيل مو موثوق، وبعدها نتحقق من دلالتها العامة وتأثيرها على الموارد، وبعد كذا بس نبني الحالة اللي حيشوفها التنفيذ.
وحدة التحصين المفيدة ما كانت شرط if لحاله. كانت إننا
نخلّي الانتقال من «bytes فهمناها» إلى «حالة راضين
نشغّلها» واضح في البرنامج.
هذا أنظف درس أخذته من EmberSEC. المحمّل ما يخلص شغله أول ما يفهم الملف. يخلص لما كل جزء بعده يقدر يعرف، من الكائن اللي في يده، إيش الوعود اللي ثبتناها فعلاً.