
CVE-2018-4280: ثغرة استبدال منفذ Mach في launchd على iOS 11.2.6 تؤدي إلى تجاوز الحماية (sandbox escape) ورفع الامتيازات وتجاوز توقيع الشيفرة.
Blanket هي أداة للهروب من صندوق الرمل تستهدف iOS 11.2.6، على الرغم من أن الثغرة الرئيسية لم تُصلَح إلا في iOS 11.4.1. تستغل ثغرة استبدال منفذ Mach في launchd (CVE-2018-4280)، بالإضافة إلى عدة ثغرات أصغر في خدمات أخرى، لتنفيذ كود داخل عملية ReportCrash، وهي عملية غير محصورة في صندوق الرمل، وتعمل بصلاحيات الجذر، وتمتلك امتياز task_for_pid-allow. وهذا يمنح Blanket تحكمًا كاملًا في كل عملية تعمل على الهاتف، بما في ذلك العمليات الحرجة أمنيًا مثل amfid.
يتكون الاستغلال من عدة مراحل. سيشرح هذا README الثغرة الرئيسية ومراحل الهروب من صندوق الرمل خطوة بخطوة.
أثناء بحثي في آلية الإبلاغ عن الأعطال على iOS، اكتشفت ثغرة استبدال منفذ Mach في launchd. من خلال التعطل بطريقة معينة، يمكن لعملية ما أن تجعل النواة ترسل رسالة Mach إلى launchd، مما يؤدي إلى إفراط launchd في إلغاء تخصيص حق إرسال لمنفذ Mach في فضاء أسماء IPC الخاص به. وهذا يسمح للمهاجم بانتحال صفة أي خدمة من خدمات launchd يمكنه البحث عنها أمام باقي النظام، مما يفتح العديد من السبل لرفع الامتيازات.
هذه الثغرة موجودة أيضًا على macOS، لكن استغلالها على iOS أصعب بسبب الفحوصات الموجودة في launchd والتي تضمن أن رسالة استثناء Mach تأتي من النواة.
يجمع launchd بين معالجات متعددة ومختلفة لرسائل Mach عبر منفذه الرئيسي، بما في ذلك معالج MIG لرسائل الاستثناء. إذا أرسلت عملية رسالة mach_exception_raise أو mach_exception_raise_state_identity إلى منفذ bootstrap الخاص بها، فسوف يستقبل launchd تلك الرسالة ويعالجها كاستثناء على مستوى المضيف.
لسوء الحظ، معالجة launchd لهذه الرسائل معيبة. إذا كان نوع الاستثناء هو EXC_CRASH، فسيقوم launchd بإلغاء تخصيص منافذ الخيط والمهمة المرسلة في الرسالة ثم يُرجع KERN_FAILURE من روتين الخدمة، مما يجعل نظام MIG يلغي تخصيص منافذ الخيط والمهمة مرة أخرى. (الافتراض هو أنه إذا أعاد روتين الخدمة النجاح، فإنه يكون قد استولى على ملكية جميع الموارد في رسالة Mach، بينما إذا أعاد روتين الخدمة خطأ، فإنه لم يستولِ على ملكية أي من الموارد.)
فيما يلي الكود المأخوذ من روتين الخدمة في launchd لرسائل mach_exception_raise، مفكوك الترجمة باستخدام IDA/Hex-Rays ومعدل قليلًا لتسهيل القراءة:```C
kern_return_t __fastcall
catch_mach_exception_raise( // (a) The service routine is
mach_port_t exception_port, // called with values directly
mach_port_t thread, // from the Mach message
mach_port_t task, // sent by the client. The
exception_type_t exception, // thread and task ports could
mach_exception_data_t code, // be arbitrary send rights.
mach_msg_type_number_t codeCnt)
{
__int64 __stack_guard; // ST28_8@1
kern_return_t kr; // w0@1 MAPDST
kern_return_t result; // w0@4
__int64 codes_left; // x25@6
mach_exception_data_type_t code_value; // t1@7
int pid; // [xsp+34h] [xbp-44Ch]@1
char codes_str[1024]; // [xsp+38h] [xbp-448h]@7
__stack_guard = *__stack_chk_guard_ptr;
pid = -1;
kr = pid_for_task(task, &pid);
if ( kr )
{
_os_assumes_log(kr);
_os_avoid_tail_call();
}
if ( current_audit_token.val[5] ) // (b) If the message was sent by
{ // a process with a nonzero PID
result = KERN_FAILURE; // (any non-kernel process),
} // the message is rejected.
else
{
if ( codeCnt )
{
codes_left = codeCnt;
do
{
code_value = *code;
++code;
__snprintf_chk(codes_str, 0x400uLL, 0, 0x400uLL, "0x%llx", code_value);
--codes_left;
}
while ( codes_left );
}
launchd_log_2(
0LL,
3LL,
"Host-level exception raised: pid = %d, thread = 0x%x, "
"exception type = 0x%x, codes = { %s }",
pid,
thread,
exception,
codes_str);
kr = deallocate_port(thread); // (c) The "thread" port sent in
if ( kr ) // the message is deallocated.
{
_os_assumes_log(kr);
_os_avoid_tail_call();
}
kr = deallocate_port(task); // (d) The "task" port sent in the
if ( kr ) // message is deallocated.
{
_os_assumes_log(kr);
_os_avoid_tail_call();
}
if ( exception == EXC_CRASH ) // (e) If the exception type is
result = KERN_FAILURE; // EXC_CRASH, then KERN_FAILURE
else // is returned. MIG will
result = 0; // deallocate the ports again.
}
*__stack_chk_guard_ptr;
return result;
}
هذا ما يفعله الكود:
1. هذه الدالة هي روتين خدمة Mach لرسائل استثناء `mach_exception_raise`: يتم استدعاؤها مباشرةً
بواسطة نظام Mach عندما يعالج launchd رسالة استثناء `mach_exception_raise` من نوع
Mach. الوسائط التي تُمرَّر إلى روتين الخدمة تُحلَّل من رسالة Mach، وبالتالي
يتحكم فيها مُرسِل الرسالة.
2. عند (b)، يتحقق launchd من أن رسالة استثناء Mach أُرسلت بواسطة النواة.
يحتوي رمز التدقيق (audit token) الخاص بالمُرسِل على معرّف العملية (PID) للعملية المُرسِلة في الحقل 5،
والذي يكون صفرًا فقط بالنسبة للنواة. إذا لم تكن الرسالة مُرسلة من النواة، فسيتم رفضها.
3. يتم تحرير منافذ المهمة والخيط (thread and task ports) من الرسالة بشكل صريح عند (c) و(d).
4. عند (e)، يتحقق launchd مما إذا كان نوع الاستثناء هو `EXC_CRASH`، ويعيد `KERN_FAILURE` إذا
كان كذلك. والهدف هو التأكد من عدم معالجة رسائل `EXC_CRASH`، على الأرجح حتى
يتم استدعاء ReportCrash كمعالج للجثة. ومع ذلك، فإن إعادة `KERN_FAILURE` في هذه النقطة
ستؤدي إلى تحرير منافذ المهمة والخيط مرة أخرى عند تنظيف رسالة الاستثناء لاحقًا.
هذا يعني أن هذين المنفذين سيتم تحريرهما بشكل زائد.
لكي تكون هذه الثغرة مفيدة، سنحتاج إلى تحرير حق الإرسال (send right) الخاص بـ launchd إلى خدمة
Mach التي يقدمها، حتى نتمكن بعد ذلك من انتحال شخصية تلك الخدمة أمام باقي النظام. هذا
يعني أننا سنحتاج إلى أن يكون منفذا المهمة والخيط في رسالة الاستثناء حقيقيًا بمثابة حقوق إرسال
إلى منفذ خدمة Mach الذي نريد تحريره في launchd. بعد ذلك، بمجرد أن نرسل إلى launchd رسالة
الاستثناء الضارة ونحرر منفذ الخدمة، سنحاول إعادة استخدام نفس اسم المنفذ، لكن هذه المرة
لمنفذ Mach نحن نملك حق الاستلام له. وبهذه الطريقة، عندما يطلب عميل من launchd
أن يمنحه حق إرسال إلى منفذ Mach الخاص بالخدمة، سيمنحه launchd بدلاً من ذلك حق
إرسال إلى منفذنا الخاص، مما يتيح لنا انتحال شخصية تلك الخدمة أمام العميل. بعد ذلك، هناك
العديد من الطرق المختلفة للحصول على امتيازات النظام.
### تفعيل الثغرة
من أجل تفعيل الثغرة فعليًا، سنحتاج إلى تجاوز الفحص الذي يتحقق من أن الرسالة قد أُرسلت بواسطة
النواة. وذلك لأننا إذا أرسلنا رسالة الاستثناء إلى launchd مباشرةً فسيتم تجاهلها فقط.
بطريقة ما، نحتاج إلى جعل النواة ترسل رسالة استثناء "ضارة" تحتوي على حق إرسال Mach
لخدمة نظام بدلاً من منافذ المهمة والخيط الحقيقية.
كما يتبين، هناك فخ Mach باسم `task_set_special_port` يمكن استخدامه لتعيين حق إرسال
مخصص ليُستخدم بدلاً من منفذ المهمة الحقيقي في حالات معينة. واحدة من هذه الحالات
هي عندما تولّد النواة رسالة استثناء نيابةً عن مهمة: بدلاً من وضع حق إرسال المهمة
الحقيقي في رسالة الاستثناء، ستستخدم النواة حق الإرسال المقدم من
`task_set_special_port`. وبشكل أكثر تحديدًا، إذا استدعت مهمة `task_set_special_port` لتعيين قيمة
مخصصة لمنفذها الخاص `TASK_KERNEL_PORT` ثم تعطلت المهمة، فإن رسالة الاستثناء
التي تولّدها النواة ستحتوي على حق إرسال إلى المنفذ المخصص، وليس منفذ المهمة الحقيقي، في
حقل "task". وهناك واجهة برمجة مكافئة، وهي `thread_set_special_port`، يمكن استخدامها لتعيين منفذ مخصص في
حقل "thread" الخاص برسالة الاستثناء المُولَّدة.
بسبب هذا السلوك، ليس من الصعب إطلاقًا جعل النواة تولّد رسالة استثناء
"ضارة" تحتوي على منفذ خدمة Mach بدلاً من منفذ المهمة والخيط.
ومع ذلك، ما زلنا بحاجة إلى ضمان أن رسالة الاستثناء التي نولّدها يتم تسليمها إلى
launchd.
مرة أخرى، ضمان قيام النواة بتسليم رسالة الاستثناء "الضارة" إلى launchd ليس
أمرًا صعبًا إذا كنت تعرف واجهة البرمجة الصحيحة. الدالة `thread_set_exception_ports` ستضبط أي حق إرسال
Mach كمنفذ يتم تسليم رسائل الاستثناء الخاصة بهذا الخيط إليه. وبالتالي، كل ما نحتاجه
هو استدعاء `thread_set_exception_ports` مع منفذ الإقلاع (bootstrap port)، وبعدها أي استثناء
سنولّده سيجعل النواة ترسل رسالة استثناء إلى launchd.
الجزء الأخير من اللغز هو الحصول على نوع الاستثناء الصحيح. لن يتم تفعيل الثغرة إلا
باستثناءات `EXC_CRASH`. وبتجربة بسيطة نكتشف أنه يمكننا بسهولة توليد
استثناءات `EXC_CRASH` عن طريق استدعاء الدالة القياسية `abort`.
وهكذا، باختصار، يمكننا استخدام واجهات برمجة تطبيقات موجودة وموثقة جيدًا لجعل النواة تولّد
رسالة استثناء `EXC_CRASH` ضارة نيابةً عنا وتسليمها إلى launchd، مما يفعل
الثغرة ويحرر منفذ خدمة Mach:
1. استخدم `thread_set_exception_ports` لتعيين launchd كمعالج الاستثناءات لهذا الخيط.
2. استدعِ `bootstrap_look_up` للحصول على منفذ الخدمة للخدمة التي نريد انتحال شخصيتها من
launchd.
3. استدعِ `task_set_special_port`/`thread_set_special_port` لاستخدام منفذ الخدمة هذا بدلاً من
منافذ المهمة والخيط الحقيقية في رسائل الاستثناء.
4. استدعِ `abort`. ستُرسل النواة رسالة استثناء `EXC_CRASH` إلى launchd، لكن منافذ المهمة و
الخيط في الرسالة ستكون منفذ الخدمة المستهدف.
5. سيعالج launchd رسالة الاستثناء ويحرر منفذ الخدمة.
### تشغيل الكود بعد التعطل
هناك مشكلة في الاستراتيجية المذكورة أعلاه: استدعاء `abort` سيقتل عمليتنا. إذا أردنا أن نكون
قادرين على تشغيل أي كود على الإطلاق بعد تفعيل الثغرة، نحتاج إلى طريقة لتنفيذ التعطل
في عملية أخرى.
(مع أنواع الاستثناءات الأخرى، يمكن للعملية فعليًا التعافي من الاستثناء. الطريقة التي تتعافى بها العملية
هي تعيين معالج استثناءات الخيط الخاص بها ليكون launchd ومعالج استثناءات المهمة الخاص بها
ليكون نفسها. بعد أن يعالج launchd الاستثناء ويفشل في التعامل معه، ستُرسل النواة
الاستثناء إلى معالج المهمة، الذي سيعيد تعيين حالة الخيط ويخبر النواة بأن
الاستثناء قد تمت معالجته. ومع ذلك، لا يمكن للعملية التقاط استثناءات `EXC_CRASH` الخاصة بها، لذا
نحن بحاجة إلى عمليتين.)
إحدى الاستراتيجيات هي أولاً استغلال ثغرة في عملية أخرى على iOS وإجبار تلك العملية
على تعيين منافذ النواة الخاصة بها ثم التعطل. ومع ذلك، بالنسبة لإثبات المفهوم، من الأسهل إنشاء
امتداد تطبيق (app extension).
توفر امتدادات التطبيقات، التي تم تقديمها في iOS 8، طريقة لتغليف بعض وظائف التطبيق
بحيث تكون متاحة خارج التطبيق. يتم تشغيل كود امتداد التطبيق في عملية منفصلة
ومعزولة (sandboxed). وهذا يجعل من السهل جدًا تشغيل عملية ستضبط منافذها الخاصة،
وتسجّل launchd كمعالج استثناءات لـ `EXC_CRASH`، ثم تستدعي `abort`.
لا توجد طريقة مدعومة لتطبيق لتشغيل امتداد التطبيق الخاص به برمجيًا والتواصل معه.
ومع ذلك، كتب Ian McDowell [مقالًا رائعًا][Multi-Process iOS App Using NSExtension]
يصف كيفية استخدام واجهة برمجة `NSExtension` الخاصة لتشغيل عملية امتداد تطبيق والتواصل معها.
لقد استخدمت استراتيجية شبه متطابقة هنا. الاختلاف الوحيد هو أننا بحاجة إلى
نقل منفذ Mach إلى عملية امتداد التطبيق، وهو ما يتضمن تسجيل خدمة وهمية
مع launchd يتصل بها امتداد التطبيق.
[Multi-Process iOS App Using NSExtension]: https://ianmcdowell.net/blog/nsextension/
### منع إعادة استخدام المنفذ في launchd
أحد التحديات التي ستلاحظها إذا قمت بتشغيل الاستغلال كما هو موضح هو أنه من حين لآخر
لن تتمكن من إعادة الحصول على المنفذ المُحرر. والسبب في ذلك هو أن النواة تتعقب
إدخالات IPC الحرة للعملية في قائمة حرة (freelist)، وبالتالي سيتم إعادة استخدام اسم المنفذ المُحرر للتو (مع
رقم جيل مختلف) عند تخصيص منفذ جديد في جدول IPC. وبالتالي، لن نعيد تخصيص
اسم المنفذ الذي نريده إلا إذا لم يعِد launchd استخدام فتحة إدخال IPC تلك لمنفذ آخر أولاً.
الطريقة للتغلب على ذلك هي دفن فتحة إدخال IPC الحرة في أسفل القائمة الحرة، بحيث إذا قام launchd
بتخصيص منافذ جديدة، فسيتم استخدام تلك الفتحات الأخرى أولاً. كيف نفعل ذلك؟ يمكننا تسجيل
مجموعة من خدمات Mach الوهمية في launchd مع منافذ نملك حق الاستلام لها. عندما نستدعي
`abort`، سيتم تشغيل معالج الاستثناءات أولاً، ثم سيتم تنظيف حالة العملية، بما في ذلك منافذ
Mach. عندما يستلم launchd استثناء `EXC_CRASH`، فإنه سيحرر عن غير قصد
منفذ الخدمة المستهدف، واضعًا فتحة إدخال IPC المقابلة لاسم المنفذ هذا في
مقدمة القائمة الحرة. بعد ذلك، عندما يتم إتلاف باقي منافذ Mach الخاصة بامتداد تطبيقنا، سيستلم launchd
إشعارات ويحرر منافذ الخدمة الوهمية، مما يدفن فتحة إدخال IPC المستهدفة خلف
فتحات المنافذ المُحررة للتو. وبالتالي، طالما أن launchd يخصص منافذ أقل من
عدد الخدمات الوهمية التي سجلناها، فستظل الفتحة المستهدفة في القائمة الحرة، مما يعني
أننا ما زلنا قادرين على جعل launchd يعيد تخصيص الفتحة بنفس اسم المنفذ الخاص بالخدمة الأصلية.
القيد في هذه الاستراتيجية هو أننا نحتاج إلى امتياز `com.apple.security.application-groups`
من أجل تسجيل الخدمات مع launchd. هناك طرق أخرى لتخزين منافذ Mach في
launchd، لكن استخدام مجموعات التطبيقات هو بالتأكيد الأسهل، ويكفي لهذا
إثبات المفهوم.
### انتحال شخصية الخدمة المُحررة
بمجرد أن نكون قد أنشأنا امتداد التطبيق المتعطل وحررنا حق إرسال Mach في launchd، نحتاج إلى
إعادة تخصيص اسم منفذ Mach ذلك مع حق إرسال نملك حق الاستلام له. بهذه الطريقة، أي
رسائل يرسلها launchd إلى اسم المنفذ هذا سيتم استلامها من قبلنا، وأي وقت يشارك فيه launchd اسم
المنفذ هذا مع عميل، سيستلم العميل حق إرسال إلى منفذنا. على وجه الخصوص، إذا تمكنا من
تحرير حق إرسال launchd إلى خدمة Mach، فإن أي عملية تطلب تلك الخدمة من
launchd ستستلم حق إرسال إلى منفذنا الخاص بدلاً من منفذ الخدمة الحقيقي. وهذا يسمح لنا
بانتحال شخصية الخدمة أو تنفيذ هجوم رجل في المنتصف، وفحص جميع الرسائل التي
يرسلها العميل إلى الخدمة.
إعادة استخدام اسم المنفذ المُحرر بحيث يشير إلى منفذ نملكه هو أيضًا أمر بسيط جدًا،
نظرًا لأننا قررنا بالفعل استخدام امتياز مجموعات التطبيقات: فقط سجّل خدمات Mach وهمية
مع launchd حتى يعيد أحدها استخدام اسم المنفذ الأصلي. سنحتاج إلى القيام بذلك على
دفعات، وتسجيل عدد كبير من الخدمات الوهمية معًا، والتحقق مما إذا كان أي منها قد
أعاد بنجاح استخدام اسم المنفذ المُحرر، ثم إلغاء تسجيلها. والسبب هو أننا بحاجة إلى
التأكد من أن تسجيلاتنا تعود على طول الطريق في القائمة الحرة لمنافذ IPC لاستعادة اسم
المنفذ المدفون الذي نريده.
يمكننا التحقق مما إذا كنا قد نجحنا في إعادة استخدام اسم المنفذ المُحرر عن طريق البحث عن
الخدمة الأصلية باستخدام `bootstrap_look_up`: إذا أعادت أحد منافذ خدمتنا المسجلة، فحينئذٍ
انتهينا.
بمجرد أن نتمكن من تسجيل خدمة جديدة تحصل على نفس اسم المنفذ مثل الأصلية، فإن أي
عملاء يبحثون عن الخدمة الأصلية في launchd سيتم منحهم حق إرسال إلى منفذنا، وليس
منفذ الخدمة الحقيقي. وبالتالي، فإننا فعليًا ننتحل شخصية الخدمة الأصلية أمام باقي النظام
(أو على الأقل، أمام تلك العمليات التي تبحث عن الخدمة بعد هجومنا).
المرحلة 1: الحصول على منفذ host-priv
---------------------------------------------------------------------------------------------------
بمجرد أن نمتلك القدرة على انتحال شخصية خدمات النظام التعسفية، فإن الخطوة التالية هي الحصول
على منفذ host-priv. هذه الخطوة مباشرة، ولا تتأثر بالتغييرات في iOS 11.3.
الفكرة العامة لهذا الهجوم هي انتحال شخصية SafetyNet، وتعطيل ReportCrash، ثم
استرجاع منفذ host-priv من منفذ مهمة ReportCrash المحتضر المُرسل في رسالة الاستثناء.
### حول ReportCrash وSafetyNet
يتولى ReportCrash مسؤولية إنشاء تقارير الأعطال على iOS. هذا الملف الثنائي الواحد يقدم في الواقع 4
خدمات مختلفة (كل منها في عملية مختلفة، على الرغم من أنه قد لا تكون جميعها قيد التشغيل في أي
وقت معين):
1. `com.apple.ReportCrash` مسؤول عن إنشاء تقارير الأعطال للعمليات المتعطلة. إنه
معالج الاستثناءات على مستوى المضيف لاستثناءات `EXC_CRASH` و`EXC_GUARD` و`EXC_RESOURCE`.
2. يتعامل `com.apple.ReportCrash.Jetsam` مع تقارير Jetsam.
3. ينشئ `com.apple.ReportCrash.SimulateCrash` تقاريرًا للأعطال المحاكاة.
4. `com.apple.ReportCrash.SafetyNet` هو معالج الاستثناءات المسجل لخدمة
`com.apple.ReportCrash`.
الخدمتان اللتان تهمنا هما `com.apple.ReportCrash` و`com.apple.ReportCrash.SafetyNet`،
ويشار إليهما فيما بعد ببساطة باسم ReportCrash وSafetyNet. كلاهما من الخدمات القائمة على MIG،
ويقومان بتشغيل نفس الكود فعليًا.
عندما يبدأ تشغيل ReportCrash، فإنه يبحث عن خدمة SafetyNet في launchd ويضبط المنفذ
المُعاد كمعالج استثناءات على مستوى المهمة. يبدو أن القصد هو أنه إذا تعطل ReportCrash نفسه،
فستقوم عملية منفصلة بإنشاء تقرير الأعطال له. ومع ذلك، يبدو مسار الكود هذا معطلاً: يسجّل ReportCrash
SafetyNet لرسائل `mach_exception_raise`، على الرغم من أن كلاً من
ReportCrash وSafetyNet يتعاملان فقط مع رسائل `mach_exception_raise_state_identity`. ومع ذلك،
فإن كلتا الخدمتين ما زالتا موجودتين ويمكن الوصول إليهما من داخل صندوق رمل حاوية iOS.
### بدائيات التلاعب بـ ReportCrash
من أجل تنفيذ الهجوم التالي، نحتاج إلى أن نكون قادرين على التلاعب بـ ReportCrash (أو
SafetyNet) ليتصرف بالطريقة التي نريدها. على وجه التحديد، نحتاج إلى القدرات التالية: بدء تشغيل ReportCrash
عند الطلب، وإجبار ReportCrash على الخروج، وتعطيل ReportCrash، والتأكد من أن ReportCrash
لا يخرج بينما نستخدمه. هنا سأصف كيف نحقق كل هدف.
من أجل بدء تشغيل ReportCrash، نحتاج ببساطة إلى إرسال رسالة Mach إليه: سيبدأه launchd عند
الطلب. ومع ذلك، نظرًا لتصميمه الغريب، فإن أي نوع رسالة باستثناء
`mach_exception_raise_state_identity` سيؤدي إلى توقف ReportCrash عن الاستجابة للرسائل الجديدة و
في النهاية الخروج. وبالتالي، نحتاج إلى إرسال رسالة `mach_exception_raise_state_identity` إذا أردنا
أن يظل على قيد الحياة بعد ذلك.
من أجل إنهاء ReportCrash، يمكننا ببساطة إرسال أي نوع آخر من رسائل Mach إليه.
هناك العديد من الطرق لتعطيل ReportCrash. الأسهل على الأرجح هو إرسال رسالة
`mach_exception_raise_state_identity` مع ضبط منفذ الخيط على `MACH_PORT_NULL`.
أخيرًا، نحتاج إلى ضمان أن ReportCrash لا يخرج بينما نستخدمه. كل رسالة
`mach_exception_raise_state_identity` يعالجها تتسبب في إطلاق خيط آخر
للاستماع إلى الرسالة التالية بينما يقوم الخيط الأصلي بإنشاء تقرير الأعطال.
سيخرج ReportCrash بمجرد انتهاء جميع الخيوط المعلقة التي تقوم بإنشاء تقرير أعطال.
وبالتالي، إذا تمكنا من تعليق أحد تلك الخيوط بينما هو في عملية إنشاء تقرير أعطال،
فيمكننا منعه من الخروج أبدًا.
أسهل طريقة وجدتها للقيام بذلك هي إرسال رسالة `mach_exception_raise_state_identity` مع
منفذ مخصص في حقلي المهمة والخيط. بمجرد أن يحاول ReportCrash إنشاء تقرير أعطال،
فسيستدعي `task_policy_get` على منفذ "المهمة"، مما سيؤدي إلى إرسال رسالة Mach إلى
المنفذ الذي أرسلناه وانتظار الرد. ولكن نظرًا لأن منفذ "المهمة" هو مجرد منفذ Mach عادي قديم،
فيمكننا ببساطة عدم الرد على رسالة Mach، وسينتظر ReportCrash إلى أجل غير مسمى حتى
يعود `task_policy_get`.
### استخراج host-priv من ReportCrash
بالنسبة للمرحلة الأولى من الاستغلال، فإن خطة الهجوم مباشرة نسبيًا:
1. ابدأ خدمة SafetyNet وأجبرها على البقاء على قيد الحياة طوال مدة هجومنا.
2. استخدم بدائية انتحال شخصية خدمة launchd لانتحال شخصية SafetyNet. وهذا يعطينا منفذًا
جديدًا يمكننا من خلاله استلام الرسائل المخصصة لخدمة SafetyNet الحقيقية.
3. اجعل أي مثيل موجود من ReportCrash يخرج. بهذه الطريقة، يمكننا ضمان أن ReportCrash
يبحث عن منفذ SafetyNet الخاص بنا في الخطوة التالية.
4. ابدأ تشغيل ReportCrash. سيتم البحث عن SafetyNet في launchd وضبط المنفذ الناتج،
وهو منفذ SafetyNet المزيف الذي نملك حق الاستلام له، كوجهة لرسائل
`EXC_CRASH`.
5. قم بتفعيل تعطل في ReportCrash. بعد رؤية أنه لا توجد معالجات مسجلة لـ
نوع الاستثناء الأصلي، سيدخل ReportCrash في مرحلة موت العملية. في هذه المرحلة سترى XNU
أن ReportCrash سجّل منفذ SafetyNet المزيف لاستلام استثناءات `EXC_CRASH`، لذلك
سيولّد رسالة استثناء ويرسلها إلى ذلك المنفذ.
6. نستمع بعد ذلك على منفذ SafetyNet المزيف لرسالة `EXC_CRASH`. سيكون نوعها
`mach_exception_raise`، مما يعني أنها ستحتوي على منفذ مهمة ReportCrash.
7. أخيرًا، نستخدم `task_get_special_port` على منفذ مهمة ReportCrash للحصول على منفذ المضيف
الخاص بـ ReportCrash. نظرًا لأن ReportCrash غير معزول ويعمل كجذر، فإن هذا هو منفذ host-priv.
في نهاية هذه المرحلة من الهروب من صندوق الرمل، ينتهي بنا الأمر بمنفذ host-priv قابل للاستخدام. وهذا وحده
يُظهر أن هذه مشكلة أمنية خطيرة.
المرحلة 2: الهروب من صندوق الرمل
---------------------------------------------------------------------------------------------------
على الرغم من أننا نملك منفذ host-priv، فإن هدفنا هو الهروب الكامل من صندوق الرمل وتشغيل الكود
كجذر مع امتياز `task_for_pid-allow`. الخطوة الأولى لتحقيق ذلك هي ببساطة الهروب
من صندوق الرمل.
من الناحية الفنية، لا يوجد سبب يجعلك تحتاج إلى الحصول على منفذ host-priv قبل الهروب
من صندوق الرمل: هاتان الخطوتان مستقلتان ويمكن أن تحدثا بأي ترتيب. ومع ذلك، فإن هذه المرحلة
ستترك النظام غير مستقر إذا فشلت هي أو المراحل اللاحقة، لذا من الأفضل تأجيلها إلى وقت لاحق.
الهجوم رفيع المستوى هو استخدام نفس ثغرة launchd مرة أخرى لانتحال شخصية خدمة نظام.
ومع ذلك، هذه المرة هدفنا هو انتحال شخصية خدمة سيرسل إليها عميل منفذ مهمته
في رسالة Mach. من السهل اكتشافه بالتجربة على iOS 11.2.6 أنه إذا قمنا
بانتحال شخصية `com.apple.CARenderServer` (يشار إليها فيما بعد باسم CARenderServer) المستضاف بواسطة backboardd ثم
تواصلنا مع `com.apple.DragUI.druid.source`، فإن البرنامج الخفي druid غير المعزول سيرسل منفذ مهمته
في رسالة Mach إلى منفذ الخدمة المزيف.
هذه الخطوة من الاستغلال معطلة على iOS 11.3 لأن druid لم يعد يرسل منفذ مهمته في
رسالة Mach إلى CARenderServer. على الرغم من ذلك، أنا واثق من أن هذه الثغرة لا يزال
يمكن استخدامها للهروب من صندوق الرمل. إحدى الطرق للقيام بذلك هي البحث عن خدمات غير معزولة تثق
بالمدخلات من الخدمات الأخرى. هذه الأنواع من "الثغرات" لن تكون قابلة للاستغلال أبدًا بدون
القدرة على استبدال خدمات النظام، مما يعني أنها ربما تكون سطح هجوم منخفض الأولوية،
سواء داخليًا أو خارجيًا بالنسبة لشركة Apple.
### تعطيل druid
تمامًا كما هو الحال مع ReportCrash، نحتاج إلى أن نكون قادرين على إجبار druid على إعادة التشغيل في حال كان
يعمل بالفعل حتى يبحث عن منفذ CARenderServer المزيف في launchd. قررت استخدام خطأ في
libxpc كان مقررًا بالفعل إصلاحه لهذا الغرض.
أثناء البحث في libxpc، وجدت قراءة خارج الحدود يمكن استخدامها لإجبار أي خدمة XPC على التعطل:```C
void _xpc_dictionary_apply_wire_f
(
OS_xpc_dictionary *xdict,
OS_xpc_serializer *xserializer,
const void *context,
bool (*applier_fn)(const char *, OS_xpc_serializer *, const void *)
)
{
...
uint64_t count = (unsigned int)*serialized_dict_count;
if ( count )
{
uint64_t depth = xserializer->depth;
uint64_t index = 0;
do
{
const char *key = _xpc_serializer_read(xserializer, 0, 0, 0);
size_t keylen = strlen(key);
_xpc_serializer_advance(xserializer, keylen + 1);
if ( !applier_fn(key, xserializer, context) )
break;
xserializer->depth = depth;
++index;
}
while ( index < count );
}
...
}
المشكلة هي أن استخدام strlen غير المُتحقَّق منه على بيانات يتحكم بها المهاجم يسمح لمفتاح
إدخال القاموس المتسلسل بالامتداد إلى ما بعد نهاية مخزن البيانات. وهذا يعني أن خدمة XPC
التي تقوم بإلغاء تسلسل القاموس ستنهار، إما عندما يقوم strlen بإلغاء مرجعية ذاكرة خارج الحدود
أو عندما يحاول _xpc_serializer_advance تقديم المُسلسِل إلى ما بعد نهاية
البيانات المقدَّمة.
تم إصلاح هذا الخطأ بالفعل في iOS 11.3 Beta بحلول الوقت الذي اكتشفته فيه، لذلك لم أُبلغ Apple به. الاستغلال متاح كمشروع مستقل في مستودعي xpc-crash.
من أجل استخدام هذا الخطأ لتعطيل druid، نحتاج ببساطة إلى إرسال رسالة XPC مشوّهة إلى خدمة druid بحيث يكون مفتاح القاموس غير منتهي ويمتد إلى آخر بايت من الرسالة.
الحصول على منفذ مهمة druid على iOS 11.2.6 باستخدام بدائية انتحال الخدمة الخاصة بنا أمر سهل:
بمجرد حصولنا على منفذ مهمة druid، ما زلنا بحاجة إلى معرفة كيفية تنفيذ كود داخل عملية druid.
المشكلة هي أن XNU يحمي منافذ المهام للثنائيات المنصّية من أن يتم تعديلها بواسطة
الثنائيات غير المنصّية. يتم تنفيذ الدفاع في الدالة task_conversion_eval، التي يتم
استدعاؤها بواسطة convert_port_to_locked_task وconvert_port_to_task_with_exec_token:```C
kern_return_t
task_conversion_eval(task_t caller, task_t victim)
{
/*
* Tasks are allowed to resolve their own task ports, and the kernel is
* allowed to resolve anyone's task port.
*/
if (caller == kernel_task) {
return KERN_SUCCESS;
}
if (caller == victim) {
return KERN_SUCCESS;
}
/*
* Only the kernel can can resolve the kernel's task port. We've established
* by this point that the caller is not kernel_task.
*/
if (victim == kernel_task) {
return KERN_INVALID_SECURITY;
}
#if CONFIG_EMBEDDED /* * On embedded platforms, only a platform binary can resolve the task port * of another platform binary. / if ((victim->t_flags & TF_PLATFORM) && !(caller->t_flags & TF_PLATFORM)) { #if SECURE_KERNEL return KERN_INVALID_SECURITY; #else if (cs_relax_platform_task_ports) { return KERN_SUCCESS; } else { return KERN_INVALID_SECURITY; } #endif / SECURE_KERNEL / } #endif / CONFIG_EMBEDDED */
return KERN_SUCCESS;
}
إجراءات التحويل في MIG التي تعتمد على هذه الدوال، بما في ذلك `convert_port_to_task` و
`convert_port_to_map`، ستفشل بالتالي عند استدعائها على مهمة druid. على سبيل المثال،
لن تسمح لنا `mach_vm_write` بالتلاعب بذاكرة druid.
ومع ذلك، أثناء النظر إلى ملف MIG `osfmk/mach/task.defs` في XNU، لاحظت شيئًا
مثيرًا للاهتمام:```C
/*
* Returns the set of threads belonging to the target task.
*/
routine task_threads(
target_task : task_inspect_t;
out act_list : thread_act_array_t);
الدالة task_threads، التي تُعدّد الخيوط داخل مهمة، تأخذ في الواقع
task_inspect_t بدلاً من task_t، مما يعني أن MIG يحوّلها باستخدام
convert_port_to_task_inspect بدلاً من convert_port_to_task. وبالنظرة السريعة إلى
convert_port_to_task_inspect يتبيّن أن هذه الدالة لا تقوم بفحص
task_conversion_eval، أي أنه يمكننا استدعاؤها بنجاح على ثنائيات المنصة. وهذا
مثير للاهتمام لأن الخيوط المُعادة ليست حقوق thread_inspect_t، بل حقوق
thread_act_t كاملة. بعبارة أخرى، تعمل task_threads على ترقية حق مهمة غير قابل للتعديل إلى
حقوق خيوط قابلة للتعديل. وبما أنه لا يوجد مكافئ لـ thread_conversion_eval، فهذا يعني أنه
يمكننا استخدام واجهات برمجة خيوط Mach لتعديل الخيوط داخل مهمة حتى لو كانت تلك المهمة ثنائي منصة.
من أجل الاستفادة من ذلك، كتبتُ مكتبة باسم threadexec تبني قدرة استدعاء دوال كاملة الميزات فوق واجهة برمجة خيوط Mach. مشروع threadexec في حد ذاته كان عملاً كبيراً، لكن بما أنه مرتبط بهذا الاستغلال بشكل غير مباشر فقط، سأتجاوز شرح تفاصيله الداخلية.
بمجرد أن نحصل على منفذ host-priv وتنفيذ كود غير محاصر داخل druid، تكون المرحلة التالية من الهروب الكامل من صندوق الرمل هي تثبيت معالج استثناءات جديد على مستوى المضيف. هذه العملية مباشرة بالنظر إلى قدراتنا الحالية:
EXC_BAD_ACCESS عبر استدعاء
host_get_exception_ports.EXC_BAD_ACCESS.host_set_exception_ports لتسجيل
منفذ Mach لدينا كمعالج استثناءات على مستوى المضيف لـ EXC_BAD_ACCESS.بعد هذه المرحلة، في أي وقت تصل فيه أي عملية إلى عنوان ذاكرة غير صالح (ولا تملك أيضاً
معالج استثناءات مسجلاً)، سيتم إرسال رسالة استثناء EXC_BAD_ACCESS إلى منفذ معالج
الاستثناءات الجديد لدينا. سيعطينا ذلك منفذ المهمة لأي عملية تتعطل، وبما أن
EXC_BAD_ACCESS استثناء قابل للاسترداد، فهذه المرة يمكننا استخدام منفذ المهمة لتنفيذ كود.
المرحلة التالية هي إحداث استثناء EXC_BAD_ACCESS في ReportCrash بحيث يتم
إرسال منفذ مهمته في رسالة استثناء إلى منفذ معالج الاستثناءات الجديد لدينا:
EXC_BAD_ACCESS. وبما أن ReportCrash لا يملك معالج استثناءات مسجلاً
لـ EXC_BAD_ACCESS (تذكّر أن SafetyNet مسجّل لـ EXC_CRASH)، فسيتم تسليم الاستثناء
إلى معالج الاستثناءات على مستوى المضيف.KERN_SUCCESS لإعلام النواة بأن الاستثناء قد
تمت معالجته ويمكن استئناف ReportCrash.عند هذه النقطة، لدينا تنفيذ كود داخل عملية غير محصورة، بصلاحيات الجذر، وتسمح بـ task_for_pid-allow.
المرحلتان التاليتان ليستا ضروريتين بشكل صارم، لكن ينبغي القيام بهما على أي حال.
بمجرد حصولنا على تنفيذ كود داخل ReportCrash، يجب علينا إعادة ضبط معالج الاستثناءات على مستوى المضيف
لـ EXC_BAD_ACCESS باستخدام druid:
host_set_exception_ports في druid لإعادة تسجيل معالج الاستثناءات القديم على مستوى المضيف لـ
EXC_BAD_ACCESS.سيؤدي هذا إلى إيقاف استقبال منفذ معالج الاستثناءات لدينا لرسائل الاستثناء الخاصة بالعمليات الأخرى المتعطلة.
الخطوة الأخيرة هي إصلاح الضرر الذي ألحقناه بـ launchd عندما حررنا منافذ الخدمات في فضاء IPC الخاص به من أجل انتحال شخصيتها:
task_for_pid في ReportCrash للحصول على منفذ مهمة launchd.mach_port_insert_right في ReportCrash لدفع منفذ الخدمة الحقيقي إلى فضاء
IPC الخاص بـ launchd تحت الاسم الأصلي.بعد اكتمال هذه الخطوة، يجب أن يعمل النظام بكامل وظائفه مرة أخرى. بعد الاستغلال الناجح، لا ينبغي أن تكون هناك حاجة لإعادة تعيين الجهاز قسرياً، لأن الاستغلال يصلح جميع الأضرار بنفسه.
تتضمن Blanket أيضاً حمولة ما بعد الاستغلال تتجاوز amfid وتُطلق صدفة bind shell. سيصف هذا القسم كيفية تحقيق ذلك.
حتى بعد الحصول على تنفيذ كود داخل ReportCrash، فإن استخدام هذه الإمكانية ليس سهلاً: نحن مقيدون بأداء استدعاءات دوال فردية من داخل العملية، مما يجعل القيام بمهام معقدة أمراً شاقاً. من الناحية المثالية، نود أن نملك طريقة لتشغيل كود بشكل أصلي بصلاحيات ReportCrash، إما عن طريق حقن كود في ReportCrash أو عن طريق تشغيل عملية جديدة بصلاحيات مماثلة (أو أعلى).
تختار Blanket مسار تشغيل عملية جديدة. نستخدم task_for_pid وحالة ثنائي المنصة لدينا في
ReportCrash للحصول على منفذ مهمة launchd وإنشاء خيط جديد داخل launchd يمكننا
التحكم فيه. ثم نستخدم هذا الخيط لاستدعاء posix_spawn لتشغيل ثنائي الحمولة. يمكن توقيع
ثنائي الحمولة بتصاريح مقيدة، بما في ذلك task_for_pid-allow، لمنح قدرات إضافية.
لكي يقبل iOS الثنائي الذي شغّلناه حديثاً، نحتاج إلى تجاوز توقيع الكود. تمت مناقشة
استراتيجيات مختلفة على مر السنين، لكن الاستراتيجية الأكثر شيوعاً حالياً هي تسجيل
معالج استثناءات لـ amfid ثم إجراء تصحيح للبيانات بحيث يتعطل amfid عند محاولة
استدعاء MISValidateSignatureAndCopyInfo. يتيح لنا ذلك تزييف تنفيذ تلك الدالة
للادعاء بأن توقيع الكود صالح.
مع ذلك، هناك نهج آخر أعتقد أنه أكثر متانة ومرونة: بدلاً من تصحيح amfid تماماً، يمكننا ببساطة تسجيل منفذ amfid جديد في النواة.
تتتبع النواة المنفذ الذي يجب إرسال الرسائل إلى amfid إليه باستخدام منفذ مضيف خاص يسمى
HOST_AMFID_PORT. إذا كان لدينا تنفيذ كود بصلاحيات الجذر وغير محصور، يمكننا تعيين هذا المنفذ إلى قيمة جديدة.
قامت Apple بالحماية من هذا الهجوم عن طريق التحقق مما إذا كان الرد على طلب التحقق
قد جاء فعلاً من amfid: تتم مقارنة cdhash للمرسل مع cdhash الخاص بـ amfid. لكن هذا
لا يمنع في الواقع إرسال الرسالة إلى عملية غير amfid؛ بل يمنع فقط
وصول الرد من عملية غير amfid. إذا قمنا بإعداد مثلث حيث ترسل النواة
الرسائل إلينا، ونولّد الرد ونمرره إلى amfid، ثم يرسل amfid الرد إلى
النواة، فسنكون قادرين على تجاوز فحص المرسل.
هناك مزايا عديدة لهذا النهج، ولعل أكبرها هو الوصول إلى
الأعلام الإضافية في روتين الخدمة verify_code_directory. حتى لو لم يستخدمها amfid جميعاً،
فهناك العديد من أعلام المخرجات الأخرى التي يمكن أن يضبطها amfid للتحكم في سلوك
توقيع الكود. فيما يلي نموذج أولي جزئي لـ verify_code_directory:```C
kern_return_t
verify_code_directory(
mach_port_t amfid_port,
amfid_path_t path,
uint64_t file_offset,
int32_t a4,
int32_t a5,
int32_t a6,
int32_t * entitlements_valid,
int32_t * signature_valid,
int32_t * unrestrict,
int32_t * signer_type,
int32_t * is_apple,
int32_t * is_developer_code,
amfid_a13_t a13,
amfid_cdhash_t cdhash,
audit_token_t audit);
من الأمور ذات الأهمية الخاصة لمطوّري كسر الحماية (jailbreak) هو المُعامل `is_apple`. لا يبدو أن هذا المُعامل يُستخدم من قِبل amfid، لكن إذا تم ضبطه، فسيجعل النواة تضبط علامة توقيع التعليمات البرمجية `CS_PLATFORM_BINARY`، مما يمنح التطبيق امتيازات ثنائيات المنصة. على وجه الخصوص، هذا يعني أن التطبيق يمكنه الآن استخدام منافذ المهام (task ports) لتعديل ثنائيات المنصة مباشرة.
Loopholes used in this attack
---------------------------------------------------------------------------------------------------
يستفيد هذا الهجوم من عدة ثغرات ليست في حد ذاتها ثغرات أمنية، لكنها تقلل من فعالية مختلف وسائل التخفيف من الاستغلال. ليس من الضروري إغلاق كل هذه الثغرات معًا، لأن بعضها زائد عن الحاجة جزئيًا، لكن من الجدير سردها جميعًا على أي حال.
في النواة:
1. يمكن لـ `task_threads` ترقية `task_inspect_t` المخصص للفحص فقط إلى `thread_act_t` القادر على التعديل.
2. لا يوجد `thread_conversion_eval` لأداء دور `task_conversion_eval` بالنسبة للخيوط (threads).
3. يمكن لثنائي غير تابع للمنصة استخدام حق `task_inspect_t` الخاص بثنائي تابع للمنصة.
4. يمكن تسليم رسائل الاستثناءات الخاصة بالعمليات غير المعزولة (unsandboxed) إلى عمليات معزولة (sandboxed)، حتى وإن كان ذلك يوفر وسيلة للهروب من العزلة (sandbox). ليس من الواضح ما إذا كان هناك إصلاح نظيف لهذه الثغرة.
5. يمكن الجمع بين تنفيذ التعليمات البرمجية في بيئة غير معزولة، ومنفذ host-priv، والقدرة على تعطيل عملية `task_for_pid-allow` لبناء حل بديل لـ `task_for_pid`. (الحل البديل هو: استدعاء `host_set_exception_ports` لتعيين معالج استثناءات جديد على مستوى المضيف، ثم تعطيل عملية `task_for_pid-allow` لاستلام منفذ المهام الخاص بها وتنفيذ التعليمات البرمجية مع الامتياز.)
في امتدادات التطبيقات:
1. يمكن لامتدادات التطبيقات التي تشترك في مجموعة تطبيقات (application group) التواصل باستخدام رسائل Mach، على الرغم من أن التوثيق يشير إلى أن التواصل بين التطبيق المضيف وامتداد التطبيق يجب أن يكون مستحيلًا.
Recommended fixes and mitigations
---------------------------------------------------------------------------------------------------
أوصي بالإصلاحات التالية، مرتبة تقريبًا حسب الأهمية:
1. لا تقم بإلغاء تخصيص منافذ Mach في إجراءات خدمة launchd إلا عند إرجاع `KERN_SUCCESS`. سيؤدي هذا إلى إصلاح ثغرة استبدال منفذ Mach.
2. أغلق ثغرة `task_threads` التي تسمح لثنائي غير تابع للمنصة باستخدام منفذ المهام الخاص بثنائي تابع للمنصة لتحقيق تنفيذ التعليمات البرمجية.
3. أصلح مشكلات التعطل في ReportCrash.
4. ينبغي تقليل مجموعة خدمات Mach التي يمكن الوصول إليها من داخل عزل الحاوية. لا أرى سببًا مشروعًا لمعظم تطبيقات iOS للتواصل مع ReportCrash أو SafetyNet.
5. ينبغي عزل أكبر عدد ممكن من العمليات. لست متأكدًا مما إذا كان druid بحاجة إلى أن يكون غير معزول ليعمل بشكل صحيح، لكن إذا لم يكن كذلك، فيجب وضعه في بيئة عزل مناسبة.
6. ينبغي التخلص من التعليمات البرمجية الميتة. لا يبدو أن SafetyNet يؤدي الوظيفة المقصودة منه. إذا لم يعد هناك حاجة إليه، فمن الأرجح إزالته.
7. أغلق الحل البديل القائم على `host_set_exception_ports` لـ `task_for_pid`. على سبيل المثال، فكّر فيما إذا كان من المجدي تقييد `host_set_exception_ports` على الصلاحيات الجذرية (root) أو تقييد قابلية استخدام منفذ host-priv في بعض الإعدادات. هذا ينتهك التصميم الأنيق القائم على الصلاحيات في Mach، لكن `host_set_exception_ports` قد يكون هدفًا واعدًا لإساءة الاستخدام.
8. فكّر فيما إذا كان من المجدي إضافة `task_conversion_eval` إلى `task_inspect_t`.
Running blanket
---------------------------------------------------------------------------------------------------
يجب أن تعمل Blanket على أي جهاز يعمل بنظام iOS 11.2.6.
1. قم بتنزيل المشروع: ```
git clone https://github.com/bazad/blanket
cd blanket
headers/config.h وغيّر APP_GROUP إلى أي معرّف مجموعة تطبيقات
حددته مسبقًا.بعد ذلك، يجب أن تتمكن من بناء المشروع وتشغيله على الجهاز.
إذا نجح blanket، فسيقوم بتشغيل الحمولة الثنائية (الكود المصدري في
blanket_payload/blanket_payload.c)، والتي تفتح افتراضيًا قشرة bind shell على المنفذ 4242. يمكنك
الاتصال بهذا المنفذ باستخدام netcat وتنفيذ أوامر shell عشوائية.
شكر جزيل لإيان بير وجوناثان ليفين على أبحاثهما الممتازة في أمن iOS والبنية الداخلية.
اكتشفت هذه الثغرة في يناير 2018، وبدأت في تطوير الاستغلال في أواخر فبراير. أبلغتُ Apple بهذه المشكلة في 13 أبريل. حددت Apple ثغرة استبدال منفذ Mach في launchd بالرقم CVE-2018-4280، وتم إصلاحها في iOS 11.4.1 وmacOS 10.13.6 في 9 يوليو.
تم إصدار Blanket بموجب رخصة MIT.
Brandon Azad