المؤلفان: يوهان وليزا
التحرير: 77
في 6 سبتمبر 9 13:52:10 بتوقيت UTC، ظهرت في بلوك رقم 4,050,335 الخاص بـ Liquid معاملتان متطابقتان في البنية. بعد دقيقة، أكد بلوك رقم 4,050,336 المعاملة الثالثة، ودخل حوالي 3,998.5 L-BTC إلى مجموعة UTXO، دون وجود أي عملية peg-in خلفها. بعد دقائق قليلة، بدأت هذه الأصول غير الموجودة أصلًا بالانضمام وإعادة التجميع ثم الاستهلاك، وبعد ذلك تم تحويلها عبر قناة الفيدرال peg-out إلى بيتكوين حقيقية على الشبكة الرئيسية (True BTC).
في اليوم التالي، عاد 3,400 BTC إلى محفظة الفيدرال peg. بقيت 598.5 BTC المتبقية لدى المهاجم، وحين تحرير هذا التقرير كانت ما تزال تُحوَّل مرارًا بين حسابات المهاجم (self-transfer)، وكل معاملة مرفقة بسطر نصي واضح ضمن OP_RETURN يطلب من الفيدرال “عشرة بالمئة” كـ “مكافأة”. وما زالت المفاوضات تُجرى بشكل علني على السلسلة حتى الآن.
لقد راجعنا بلوكات ما بين 4,050,335 وما حولها خطوة بخطوة، وتتبعنا تدفق الأموال على جانب Bitcoin عبر كل قفزة باستخدام MistTrack. هذه المقالة تتناول نقطتين: ماذا فعل المهاجم تقنيًا، وأحد تحسينات الأداء التي دخلت إلى المستودع منذ عام 2016، ولماذا تمكن بعد عشر سنوات من صرف أربعة آلاف عملة بيتكوين.
نظرة عامة على الهجوم

كيف تتم إدارة أموال Liquid؟
إصدار L-BTC له مسار صحيح واحد فقط. ضع BTC في محفظة متعددة التوقيع مُدارة بواسطة الاتحاد؛ وعلى جانب Liquid يتم سكّ كمية مماثلة من L-BTC. ثم يتم الرجوع بالعكس عبر peg-out لاستعادة BTC. يحرس هذا الربط بنسبة 1:1 آلية المعاملات السرّية. لا يتم كتابة مبلغ كل خرج مباشرة على السلسلة؛ بل يتم إخفاؤه داخل التزام (Pedersen)؛ ولكل التزام يجب إرفاق rangeproof لإثبات أن المبلغ غير سالب ولا يتجاوز الحدود. عند التحقق من المعاملة، تقوم العقد بجمع نقاط الالتزامات الخاصة بالمدخلات والمخرجات على حدة، ثم تقارن الجانبين؛ وعند التطابق فقط تُحتسب العملية.
طالما يمكن إدخال مخرج واحد في fake rangeproof، يمكن إخفاء عدد سالب داخل الالتزام؛ أو رقم فلكي، وبهذا يتم “سكّ عملة” دون أساس في الرياضيات، وهذا نظريًا ممكن. لذلك يجب التحقق من rangeproof في كل مرة بشكل حقيقي.
المشكلة أنها مكلفة. عادةً ما تكون rangeproof بأكثر من أربعة آلاف بايت؛ والتحقق هو حساب حقيقي على منحنيات إهليلجية، ويجب إجراؤه لكل خرج. في 12 يوليو 2016 دخلت إلى مستودع Elements عملية بعنوان Rangeproof caching (66ab07bd1b)، وكانت الفكرة مستوحاة من ذاكرة التخزين المؤقت للتوقيعات في Bitcoin. الدليل الذي تم التحقق منه لا يتم التحقق منه مرة أخرى؛ ونتيجة “نجاح التحقق” يتم تخزينها في جدول ذاكرة تخزين مؤقت، وعند المرة التالية بالمدخلات نفسها يتم السماح مباشرةً دون تحقق.
مثل هذه التحسينات شائعة جدًا في كود التشفير. وهذه المرة أيضًا، كانت جميع المشكلات ناتجة عنه.
ما الذي ينقصه مفتاح ذاكرة التخزين المؤقت
لنبدأ بالنظر إلى هذا المقطع من كود الإصدار 23.3.3.

مفتاح ذاكرة التخزين المؤقت هو ببساطة proof وcommitment يتم توصليهما خامًا في نفس دفق SHA-256. proof حقل بطول متغير، والأطول فيه يزيد عن أربعة آلاف بايت. لا تحتوي هذه الخانة على معلومات عن الأصل ولا السكربت ولا أي معلومات حدود للحقول.
إرسال الإصلاح في 3 أغسطس من هذا العام (c26d719) أدخل التزام الأصل وسيناريو القفل ضمن الأمور المطلوبة، فأصبح هناك أربعة حقول. الاتجاه كان صحيحًا، إذ كان يمكن فعلًا “إعادة استخدام” نفس الدليل الذي تم إثباته سابقًا مرارًا بأصل وسكربتات مختلفة. ومع ذلك، ما زالت الرقعة تُوصل الحقول الأربعة الخام داخل نفس الدفق. كل من proof وscriptPubKey عبارة عن حقول ذات طول متغير؛ ويمكن نقل الحدود بينهما من مكان لآخر.
التعزيز المرسل في 8 سبتمبر (94000967f6) غيّر التسلسل ليستخدم تجزئة متسلسلة مع بادئة طول. وفي رسالة الإرسال توجد جملة واضحة جدًا.
هذا يُسلسل كل حقل مع بادئة طول، بحيث لم تعد tuples من الحجج المختلفة التي تتطابق بتجميع خامّها على مستوى البايت تصطدم لنفس مفتاح ذاكرة التخزين المؤقت. إدخال ذاكرة التخزين المؤقت هو نتيجة تحقق إيجابية؛ وإذا حدث تصادم، فسيتيح ذلك للمهاجم تجاوز التحقق.
في ذاكرة التخزين المؤقت يتم تخزين ما هو “تم التحقق بنجاح”. إذا تصادم المفتاح، فهذا يعني أنك قد قدّمت ضمانًا لشيء لم يتم التحقق منه من قبل. كما اكتشفت نفس الإرسالية مشكلة أخرى في مفتاح ذاكرة التخزين المؤقت الخاص بـ surjection proof؛ إذ لم يكن ربط ملصق أصل الدخل موجودًا داخل المفتاح، وتم إصلاح ذلك أيضًا.
ضربة تعني تخطي التحقق
حدث “الإطلاق” في بداية CachingRangeProofChecker::VerifyRangeProof.

إذا حدث “ضرب” مباشرة يرجع true. ولا يتم تنفيذ أي شيء بعد ذلك، مثل secp256k1_rangeproof_verify وفحص min_value؛ حتى سطر واحد لن يُنفَّذ.
لكل من كتابة ذاكرة التخزين المؤقت ومسحها قاعدة خاصة بها، وهذا هو المفتاح في تسلسل توقيت الهجوم. عند دخول المعاملة إلى mempool يتم التحقق منها بوضع “قابل للكتابة”، ويتم تخزين النتائج التي تم التحقق منها في ذاكرة التخزين المؤقت لاستخدامها لاحقًا عند إدخال البلوك. أما عند ربط البلوك (block connection) فالعكس تمامًا: يتم الاستعلام فقط دون الكتابة؛ وإذا تم العثور على نتيجة ولو مرة واحدة يتم وضع علامة “قابل للاسترجاع”. توضيح القاعدة الثانية مكتوب بوضوح في تعليق السطر 2340 من validation.cpp؛ ونصه الأصلي: Don't cache results if we're actually connecting blocks (still consult the cache, though).
مفتاح ذاكرة التخزين المؤقت هو شيء يُستهلك. لا يوجد سوى شيء واحد على المهاجم أن يضمنه: أن تكون المعاملة التي تحمل حمولة التصادم قد أُرسلت في نافذة الوقت التي لا تزال فيها العقدة المستهدفة تحتفظ بسجل “تم التحقق منه”.
مفتاح ذاكرة التخزين المؤقت لكل عقدة يتضمن أيضًا مقدارًا عشوائيًا من الملح يتم توليده عند بدء التشغيل. لا يمكن للملح إنقاذ ذلك. يُضاف الملح في مقدمة جميع الحقول. والسيلانان (المساران) نفسهُما بذاتهما متطابقان؛ سواء أضيف الملح أم لم يُضف فهو ما زال متطابقًا، وبالتالي يحدث التصادم بشكل مستقل على كل عقدة.
ثماني دقائق على السلسلة
بدأ الهجوم عند 13:52:10، وفي البلوك 4,050,335 تم إدخال معاملتي setup لهما نفس البنية داخل نفس البلوك. تحمل كل معاملة مخرج OP_RETURN؛ وهذا المخرج نفسه نظيف تمامًا. الأصل هو L-BTC مكتوبًا بنص واضح (6f0279e9…)، وcommitment للمبلغ C0 (09d6c615…) مرفق به rangeproof صادق وقانوني P0؛ وقد تحققت العقد وفق المسار الطبيعي. الحمولة مخفية داخل سكربت القفل الخاص به.
6a43 ‖ C1 ‖ X ‖ 6a
0x43 يساوي 67، وهذا يضبط تمامًا دفع الـ 67 بايت التالية كبيانات. C1 (086f5d67…) هو التزام مبلغ وهمي مُجهّز لخرجات الهجوم لاستخدامه. X (0a0a488de489…) هو شكل مُعَمّى (blind) من التزام الأصل؛ ولا يمكن معرفة أي نوع أصل يمثله بمجرد قراءة البايتات. طبقنا حسابًا فعليًا على secp256k1 الموجودة في المستودع لتوليد مُولدات L-BTC الصريحة، وكان بدايتها 0ab963d4، وهو ليس نفس النقطة التي يمثلها X. يتم وضع هذين القيمتين كبيانات حرفية داخل السكربت؛ وعند التحقق يتم التعامل معهما فقط كبيانات عادية. بعد إتمام التحقق، تمت إضافة سجل جديد داخل ذاكرة التخزين المؤقت لكل عقدة؛ المفتاح هو دفق التوصيل الخام لهذه الحقول الأربعة.
هناك أيضًا جزء تمهيدي (primer) في المنتصف. عندما يتصلّ البلوك بسلسلة البلوكات، يتم استهلاك مدخلات ذاكرة التخزين المؤقت، لذلك يقوم المهاجم بإعادة بث معاملات بالحِملات نفسها في الـ mempool مرارًا، ليعيد تعبئة ذاكرة التخزين المؤقت في كل عقدة. لا يمكن استنتاج بالضبط أي معاملات هي الـ primer من بيانات البلوك؛ وربما لم تدخل أصلًا في البلوك. ومع ضغط معاملتي setup في نفس البلوك، يبدو أن الهدف أيضًا كان إعادة تعبئة سريعًا بعد استهلاك المدخلات.
13:53:10، في البلوك 4,050,336 ظهرت معاملة السك. لديها مخرج أساسي واحد، وتم تعبئة أربعة حقول على النحو التالي: يتم وضع proof ضمن P0 ثم إلحاق ذيل من 68 بايت؛ ومحتوى الذيل هو C0 وA و6a43. وA هو التزام أصل الموجود في مخرج الإعداد نفسه. أما commitment فيوضع كـ C1. وasset_commitment يُوضع كـ X. يبقى في scriptPubKey بايت واحد فقط، وهو 6a.

أربعة حقول مختلفة فيما بينها. نقوم بتوصيلها خامًا بالترتيب في SHA-256؛ ويكون المساران متطابقين حرفًا بحرف من حيث كل بايت.
عندما تتحقق العقد من هذا المخرج، تحسب المفتاح، يحدث الضرب، وتعيد true. لم يتم لمس secp256k1_rangeproof_verify أبدًا لهذه البرهان. مهما كان حجم المبلغ الكامن وراء C1، ثم يتم تعادل مجموع Pedersen عبر مخرجات الالتزام ذات القيمة السالبة؛ وهكذا دخلت تقريبًا 3,998.5 من L-BTC إلى مجموعة UTXO بهذه الطريقة.
عند 13:54:10 و14:00:10، في البلوك 4,050,337 والبلوك 4,050,343، قامت معاملتان بتجميع UTXO ذات الصلة وصرفها. تم تفويض طلب peg-out من قبل الاتحاد كالمعتاد؛ ثم تم دفع BTC صحيحة مكافئة عبر الشبكة الرئيسية لـ Bitcoin إلى عنوان الاستلام bc1ql4mfu6aundtkksxklfajs2h3t9nzcd6gyqjlte. من أول معاملة setup حتى بدء تجميع الأموال استغرق الأمر ثماني دقائق تقريبًا.
الإرجاع والتفاوض على السلسلة
في اليوم التالي، تم تحويل 3,400 BTC مرة أخرى إلى محفظة peg الخاصة بالاتحاد bc1qdlld6antmv4xug242ed83q7k4rqw50cwfns38szx4qu2f4jwaxxsuhwxxr. أما الـ 598.5 BTC المتبقية فبقيت في مكانها؛ ومن 8 سبتمبر إلى 10 سبتمبر تم صرفها ذاتيًا مرارًا بين بلوك 966,052 و966,199، وكانت كل معاملة مرفقة برسالة OP_RETURN نصية. ومن بين الجُمل، كانت واحدة تقول: "You SHALL pay 10% using your own money as bug bounty"، وتم إرفاق حساب تيليجرام @MRBOND_1 في الخاتمة. يتبنى كاتب الرسالة دور “قبعة بيضاء”، ويطلب من الاتحاد دفع مكافأة رسمية بنسبة 10% من حسابه الخاص. وحتى الآن ما زالت المفاوضات تُجرى علنًا على السلسلة.
تتبع أموال MistTrack
بعد توضيح المسار الأساسي للأحداث، قمنا بتتبع تدفقات أموال المهاجم على جانبي Liquid وBitcoin الشبكات الرئيسية، واستخدمنا MistTrack لتحليل العناوين الأساسية على جانب Bitcoin، ثم أجرينا تدقيقًا تقاطعيًا مع بيانات السلسلة.
من جهة Liquid، يستخدم المهاجم العنوان ex1q7kgx4ptje7px48tn0nsmc6se5pngdp3smpqa2w لإعادة تجميع الأصول ذات الصلة واستهلاكها. يتضمن هذا العنوان 91 معاملة، و247 مخرجًا، وقد تم صرفها جميعًا بالفعل. وبما أن MistTrack لم تغطِ بعد سلسلة Liquid، فإن تدفق الأموال من سكّ L-BTC إلى استهلاك UTXO ثم إلى عمليات peg-out اللاحقة على جانب Liquid يتم التحقق منه بشكل رئيسي عبر بيانات مُستعرض البلوكات خطوة بخطوة.

على جانب شبكة Bitcoin الرئيسية، دخلت في النهاية أموال peg-out البالغة نحو 3,998.5 BTC إلى العنوان bc1ql4mfu6aundtkksxklfajs2h3t9nzcd6gyqjlte. في MistTrack حصل هذا العنوان على درجة مخاطرة 100/100، والتصنيف Severe. وتتضمن الوسوم: Malicious Address / Involved Theft Activity، وهو مرتبط بكيان نشاط غير قانوني من نوع Theft مباشرة عبر خطوة واحدة، مع نسبة ارتباط 100%.

من ناحية مصدر الأموال، فإن الـ BTC التي تلقاها العنوان لا تعود إلى أصل رأس مال تمويلي طبيعي مطابق؛ ويمكن إرجاع مصدره إلى سكّ L-BTC المذكور سابقًا وما تلاه من عمليات peg-out. وبشكل أساسي، كان على المهاجم تحمل رسوم المعاملات ذات الصلة فعليًا.
ثم قُسم هذا المبلغ إلى قسمين. حيث تم تحويل 3,400 BTC في اليوم التالي إلى محفظة الاتحاد الخاصة بـ peg bc1qdlld6antmv4xug242ed83q7k4rqw50cwfns38szx4qu2f4jwaxxsuhwxxr. عند إجراء التحقق عبر التحقيقات الخاصة بـ MistTrack للمعاملات، كان هذا العنوان أيضًا هو العنوان صاحب أعلى مبلغ ضمن عناوين التحويل المستهدفة، وبالطبع فإن مبلغ التحويل هو 3,400 BTC، وهو متوافق مع بيانات السلسلة.

أما المبلغ المتبقي 598.5 BTC فظل تحت سيطرة المهاجم. خلال الفترة من 8 سبتمبر إلى 10 سبتمبر، استمرت هذه الأموال في إجراء تحويلات ذاتية بين بلوكات Bitcoin من 966,052 إلى 966,199، مع كتابة رسالة علنية للاتحاد عبر OP_RETURN للتفاوض. تتضمن إحدى الرسائل عبارة: “You SHALL pay 10% using your own money as bug bounty”، وتم كذلك ترك حساب تيليجرام @MRBOND_1. وحتى الآن ما زال يمكن ملاحظة هذا التفاوض على السلسلة.

استنادًا إلى بيانات السلسلة حتى الآن، قام المهاجم بإرجاع 3,400 BTC من أموال peg-out التي بلغت نحو 3,998.5 BTC؛ أما الباقي، أي نحو 598.5 BTC، فما زال ضمن نطاق سيطرته، بما يعادل حوالي 15% من مبلغ peg-out. في الخطوات اللاحقة سنواصل مراقبة العناوين والأموال ذات الصلة وتغيراتها.
خلاصة القول
هذه الشيفرة ظلت في المستودع عشر سنوات، ومر عليها عدد لا يحصى من العقد وعدد لا يحصى من المرات. تم إصلاحها مرة واحدة في 3 أغسطس، وفي 6 سبتمبر تم اختراقها رغم ذلك. في الرقعة الأولى تم استكمال الحقول الناقصة في التجزئة، لكن تم إغفال حدود الحقول؛ وبعد خمسة أسابيع تم استغلال الحدود بدقة. وبالإصدار Elements 23.3.4 الذي صدر في 8 سبتمبر، إضافةً إلى تحويل طول المفتاح إلى بادئة، تم أيضًا إضافة مفتاح إيقاف طارئ -norangeproofcache؛ حيث يمكن لفرق التشغيل إيقاف هذه الجدول بالكامل وعدم السماح بأي نتيجة ذاكرة تخزين مؤقت.
يمكن تلخيص الدرس من هذه الحادثة في عبارة قصيرة جدًا. إن ذاكرة التخزين المؤقت لنتائج صحيحة على مسار التحقق في التشفير تعني، عند حدوث الضرب، أنك تتجاوز التحقق. ويجب التعامل مع مفتاحها باعتباره قيماً وفق معيار بدائيّات التشفير. كل حقل تقرأه دالة التحقق، وكل حدوده، يجب هرسُها بلا لبس (بدون غموض) — والبادئة الخاصة بالطول موجودة تحديدًا من أجل ذلك. إن تحسينات الأداء مثل التخزين المؤقت والـ memoization والتوازي، ما دامت تقع على مسار التحقق، فيجب أن تُعامل أثناء التدقيق بالطريقة نفسها التي تُعامل بها بدائيّات التشفير التي جرى تحسينها.
فريق SlowMist للأمن ينصح مالكي المشروع بإجراء تدقيق أمني خارجي شامل قبل النشر. في قائمة تدقيق طبقة الإجماع، ينبغي إدراج سلامة مفاتيح ذاكرة التخزين المؤقت الخاصة بالتحقق كعنصر يجب فحصه إجباريًا.

