
استغلال نواة أندرويد 14 لأجهزة Pixel7/8 Pro
تقدم هذه المقالة تحليلاً متعمقاً لثغرتين في نواة معالج Mali GPU، يمكن الوصول إليهما من بيئة التطبيقات الافتراضية المعزولة (sandbox)، وقد اكتشفتُهما بشكل مستقل وأبلغت جوجل بهما. وتتضمن استغلالاً للنواة (kernel exploit) يحقق قدرات قراءة/كتابة عشوائية في النواة. ونتيجةً لذلك، فإنه يعطّل SELinux ويرفع الامتيازات إلى صلاحيات الجذر (root) على أجهزة Google Pixel 7 و8 Pro التي تعمل بالإصدارات التالية من Android 14:
google/husky/husky:14/UD1A.231105.004/11010374:user/release-keysgoogle/cheetah/cheetah:14/UP1A.231105.003/11010452:user/release-keysgoogle/cheetah/cheetah:14/UP1A.231005.007/10754064:user/release-keysgoogle/panther/panther:14/UP1A.231105.003/11010452:user/release-keys (بواسطة m4b4 (Marcel))يعتمد هذا الاستغلال على ثغرتين: تجاوز عدد صحيح (integer overflow) ناتج عن تصحيح غير مكتمل في أمر ioctl الخاص بـ gpu_pixel_handle_buffer_liveness_update_ioctl، وتسريب معلومات داخل مخازن رسائل تدفق الخط الزمني (timeline stream).
عالجت جوجل تجاوز العدد الصحيح في أمر ioctl الخاص بـ gpu_pixel_handle_buffer_liveness_update_ioctl في هذا الالتزام. في البداية، عندما أبلغت عن هذه المشكلة، ظننت أن الخطأ ناتج عن مشكلة في التصحيح الموصوف سابقاً. وبعد مراجعة التقرير، أدركت أن تحليلي للثغرة كان غير دقيق. وعلى الرغم من افتراضي الأول بأن التصحيح غير مكتمل، فإنه يحل فعلياً ويمنع حدوث تجاوز سفلي في العملية الحسابية. قادني هذا إلى الاشتباه في أن التغيير لم يُطبَّق في إصدارات الإنتاج. ومع ذلك، على الرغم من أنني أستطيع التسبب في تجاوز سفلي في العملية الحسابية، فلا يمكنني التسبب في تجاوز علوي. يشير هذا إلى أن أمر ioctl قد تم إصلاحه جزئياً، وإن لم يكن بالتصحيح المعروض أعلاه. كشف الفحص باستخدام IDA أنه تم شحن تصحيح غير مكتمل آخر في إصدارات الإنتاج، وهذا التصحيح غير موجود في أي فرع git من وحدة نواة معالج mali gpu.
تم اكتشاف هذه الثغرة لأول مرة في أحدث إصدار من Android وأُبلغ عنها في 19 نوفمبر 2023. وأخبرتني جوجل لاحقاً أنها كانت قد حددتها داخلياً بالفعل، وأنها خصصت لها CVE-2023-48409 في نشرة أمان Android لشهر ديسمبر، وصنفتها كمشكلة مكررة.
على الرغم من أنني تمكنت من التحقق من أن الخطأ قد تم تحديده داخلياً قبل أشهر من تقريري (استناداً إلى تاريخ الالتزام حوالي 30 أغسطس)، إلا أنه لا تزال هناك بعض الالتباسات. وتحديداً، من الغريب أن مستويات تصحيح الأمان (SPL) لشهرَي أكتوبر ونوفمبر على أحدث الأجهزة كانت لا تزال متأثرة بهذه الثغرة — ولم أتحقق من الإصدارات السابقة لهذه. لذلك، لا أستطيع الجزم بشكل قاطع ما إذا كانت هذه مشكلة مكررة فعلاً، وما إذا كان التصحيح المناسب قد جُدول بالفعل لشهر ديسمبر قبل تقديمي، أم كان هناك سهو في معالجة هذه الثغرة.
على أي حال، ما يجعل هذه الثغرة قوية هو ما يلي:
info.live_ranges تحت سيطرة المستخدم بالكامل.info.live_ranges عند إزاحة عشوائية قبل بداية عنوان النواة للمخزن buff.تتقاسم هذه الثغرة أوجه تشابه مع ثغرة التجاوز السفلي للمخزن المؤقت DeCxt::RasterizeScaleBiasData() التي اكتشفتُها واستغللتها في نواة iOS 15 في عام 2022.
يُنفذ معالج Mali GPU تدفق خط زمني (timeline stream) مخصصاً مصمماً لجمع المعلومات وتسلسلها ثم كتابتها في مخزن حلقي (ring buffer) وفق تنسيق محدد. يمكن للمستخدمين استدعاء أمر ioctl kbase_api_tlstream_acquire للحصول على واصف ملف (file descriptor)، مما يمكنهم من القراءة من هذا المخزن الحلقي. تنسيق الرسائل هو كما يلي:
على سبيل المثال، تقوم الدالة __kbase_tlstream_tl_kbase_kcpuqueue_enqueue_fence_wait بتسلسل مؤشرات النواة kbase_kcpu_command_queue وdma_fence في مخزن الرسائل، مما يؤدي إلى تسريب مؤشرات النواة إلى عمليات مساحة المستخدم.```c
void __kbase_tlstream_tl_kbase_kcpuqueue_enqueue_fence_wait(
struct kbase_tlstream *stream,
const void *kcpu_queue,
const void *fence
)
{
const u32 msg_id = KBASE_TL_KBASE_KCPUQUEUE_ENQUEUE_FENCE_WAIT;
const size_t msg_size = sizeof(msg_id) + sizeof(u64)
+ sizeof(kcpu_queue)
+ sizeof(fence)
;
char *buffer;
unsigned long acq_flags;
size_t pos = 0;
buffer = kbase_tlstream_msgbuf_acquire(stream, msg_size, &acq_flags);
pos = kbasep_serialize_bytes(buffer, pos, &msg_id, sizeof(msg_id));
pos = kbasep_serialize_timestamp(buffer, pos);
pos = kbasep_serialize_bytes(buffer,
pos, &kcpu_queue, sizeof(kcpu_queue));
pos = kbasep_serialize_bytes(buffer,
pos, &fence, sizeof(fence));
kbase_tlstream_msgbuf_release(stream, acq_flags);
}
يكشف استغلال إثبات المفهوم عن عنوان كائن `kbase_kcpu_command_queue` من خلال مراقبة معرف الرسالة `KBASE_TL_KBASE_NEW_KCPUQUEUE` الذي يتم إرساله بواسطة الدالة `kbasep_kcpu_queue_new` كلما تم تخصيص كائن طابور kcpu جديد.
أبلغني Google أن الثغرة تم الإبلاغ عنها في مارس 2023 وتم تعيينها [CVE-2023-26083](https://source.android.com/docs/security/bulletin/2023-07-01) في نشرة الأمان الخاصة به. ومع ذلك، تمكنت من تكرار المشكلة على أحدث أجهزة Pixel التي تم تزويدها بمستويات تصحيح الأمان (SPL) لشهر أكتوبر ونوفمبر، مما يشير إلى أن الإصلاح لم يتم تطبيقه بشكل صحيح أو لم يتم تطبيقه على الإطلاق. بعد ذلك، عالجت Google المشكلة بسرعة في نشرة تحديث الأمان لشهر ديسمبر دون منح الفضل، وأخبرتني لاحقًا أن المشكلة اعتُبرت مكررة. لكن المبرر وراء تصنيف هذه المشكلة على أنها مكررة لا يزال موضع تساؤل.
## الاستغلال
---
إذن لدي ثغرتان مثيرتان للاهتمام. الأولى توفر قدرة قوية على تعديل محتوى أي عنوان نواة محاذٍ لـ 16 بايت يقع قبل عنوان ~buff~ المخصص. أما الثغرة الثانية فتوفر تلميحات حول المواقع المحتملة للكائنات داخل ذاكرة النواة.
### ملاحظات حول قيم buffer_count و live_ranges_count
بفضل السيطرة الكاملة على الحقلين `buffer_count` و `live_ranges_count`، يمكنني اختيار الشريحة (slab) المستهدفة والإزاحة الدقيقة التي أنوي الكتابة إليها. ومع ذلك، فإن اختيار قيم `buffer_count` و `live_ranges_count` يتطلب دراسة متأنية بسبب عدة قيود وعوامل:
- ترتبط القيمتان ببعضهما، ولن يحدث الفائض إلا إذا تم تجاوز جميع الفحوصات المضافة حديثًا.
- إن شرط أن تكون الإزاحة السالبة محاذية لـ 16 بايت يحد من القدرة على الكتابة في أي موقع مختار. ومع ذلك، فهذا ليس عائقًا كبيرًا بشكل عام.
- اختيار إزاحة أكبر يؤدي إلى كتابة كمية كبيرة من البيانات في مناطق ذاكرة قد لا تكون أهدافًا مقصودة. على سبيل المثال، إذا تجاوز حجم التخصيص إلى `0x3004`، فسيتم تعيين مؤشر `live_ranges` على `-0x4000` بايت من مساحة الذاكرة المخصصة لكائن `buff`. ستقوم الدالة `copy_from_user` عندها بكتابة `0x7004` بايت، بناءً على حساب قيمة `update->live_ranges_count` مضروبة في 4. ونتيجة لذلك، ستؤدي هذه العملية إلى قيام بيانات يتحكم بها المستخدم بالكتابة فوق منطقة الذاكرة الواقعة بين مؤشر `live_ranges` وتخصيص `buff`. لذلك، من الضروري التأكد بعناية من عدم الكتابة فوق أي كائنات نظام حساسة داخل هذا النطاق عن طريق الخطأ. ونظرًا لأن العملية تتضمن استدعاء `copy_from_user`، قد يفكر المرء في إحداث خطأ `EFAULT` عن طريق إلغاء تعيين منطقة الذاكرة غير المرغوب فيها بعد مخزن المستخدم المصدر بشكل متعمد، لمنع كتابة البيانات إلى مواقع حساسة. ومع ذلك، فإن هذا الأسلوب غير فعّال، وذلك لأنه إذا فشلت الدالة `raw_copy_from_user`، فسوف تقوم بتصفير البايتات المتبقية في المخزن المؤقت للنواة الوجهة. تم تطبيق هذا السلوك لضمان أنه في حالة النسخ الجزئي بسبب خطأ، لا يحتوي باقي مخزن النواة على بيانات غير مهيأة.```c
static inline __must_check unsigned long
_copy_from_user(void *to, const void __user *from, unsigned long n)
{
unsigned long res = n;
might_fault();
if (!should_fail_usercopy() && likely(access_ok(from, n))) {
instrument_copy_from_user(to, from, n);
res = raw_copy_from_user(to, from, n);
}
if (unlikely(res))
memset(to + (n - res), 0, res);
return res;
}
بالنظر إلى ذلك، نحتاج إلى اختيار الكائن الذي سنستبدل محتواه والبيانات التي سنكتبها بعناية.
نظرًا لأنني عالق مع هذا الفحص غير المريح، تتمثل استراتيجيتي في تحديد كائن لن يُنتج، إذا تم تصفيره، أي نتيجة غير مرغوبة. لكن، قبل أن أصل إلى ذلك، هناك مسألة أخرى يجب التعامل معها. تذكّر عندما قلت في الجزء السابق إنه يمكنني اختيار أي حجم تخصيص وبالتالي أي مُخصِّص من مُخصِّصات الـ slab cache ذات الأغراض العامة لخدمة المخزن المؤقت المخصص؟ هذا غير صحيح، لأن السبب يعود إلى copy_from_user مرة أخرى! يعود السبب إلى التخفيف المعروف بـ CONFIG_HARDENED_USERCOPY. إنه يمنع تحديد حجم لا يطابق حجم الـ slab cache المقابل الذي يخص المخزن المؤقت الوجهة في النواة (والموافق في هذه الحالة لكائن من الكائنات المخصصة في الـ heap). يتحقق مما إذا كانت صفحة المخزن المؤقت صفحة slab، وإذا كان الأمر كذلك، فإنه يسترجع قيمة kmem_cache->size المطابقة ويحدد ما إذا كان الحجم الذي يوفره المستخدم لن يتجاوزها؛ وإلا فإن النواة تتعطل ببساطة بسبب عدم تطابق الأحجام. إذن، بعبارة أخرى، لا يمكنني استهداف الكائنات التي تنتمي إلى المُخصِّص العام، لكن لا يزال بإمكاني استهداف الكائنات ذات الأحجام الكبيرة (أي تلك التي يخدمها مُخصِّص الصفحات (page allocator) مباشرة).
أول فكرة خطرت ببالي كانت استخدام تقنية pipe_buffer، وهي تقنية أنيقة جدًا للحصول على بدائيات قراءة/كتابة عشوائية. لن أخوض في تفاصيل التقنية، لكنني أشجع القراء على قراءة هذه المدونة الرائعة من Interrupt Labs. عند إنشاء كائن pipe، يُنشأ كائن pipe_buffer مبدئيًا في مصفوفة من 16 عنصرًا؛ ومع ذلك، يمكن ضبط حجم المصفوفة باستخدام fcntl(F_SETPIPE_SZ). لذلك، يمكن ضبط تخصيص مصفوفة pipe_buffer بحيث يُخدم من مُخصِّص الصفحات، مما يجعله كائنًا مثاليًا للاستهداف.
بعد اختيار كائن pipe_buffer كمرشح للاستهداف، الخطوة التالية نحو تحقيق القراءة/الكتابة في النواة هي الكتابة فوق محتواه باستخدام ثغرة التدفق السفلي (underflow)، مما سيسمح لي بالقراءة/الكتابة من/إلى أي موقع ذاكرة من خلال استبدال الحقل pipe_buffer->page بصفحة ذلك الموقع.
لأن الثغرة تسمح لي بكتابة بيانات عشوائية، يمكنني التحكم في المحتوى الكامل لـ'pipe_buffer' بما في ذلك حقل الصفحة الخاص به، وللقيام بذلك، أحتاج إلى تخصيص مصفوفة pipe_buffer قبل كائن kbuff الضعيف، ويجب أن يكونا بجانب بعضهما البعض.
لقد قمت برشّ ذاكرة النواة بعدد كبير من كائنات kbase_kcpu_command_queue ثم تبعتها بمجموعة من مصفوفات pipe_buffer.
لا يمكنني ببساطة استخدام مصفوفات pipe_buffer وحدها كمصدر أساسي للرش بسبب القيد الذي تفرضه pipe_max_size. لذلك، قررت أن أبدأ الرش باستخدام كائن kbase_kcpu_command_queue. كان اختيار كائن kbase_kcpu_command_queue لسببين: حجم تخصيصه هو 0x38C8 وبالتالي يتعامل معه مُخصِّص الصفحات، ويمكنني الحصول بشكل حتمي على عنوانه في النواة باستخدام ثغرة تسريب معلومات النواة، مما يجعله كائنًا جيدًا للرش به وكذلك كائنًا جيدًا للاستهداف (كما سنرى في القسم التالي).
كما ذكرت سابقًا، استخدمت fcntl(F_SETPIPE_SZ) لزيادة حجم تخصيص مصفوفة pipe_buffer بحيث يمكن أن يخدمها مُخصِّص الصفحات. لكي أكون أكثر تحديدًا، اخترت أن يكون حجم التخصيص ==0x4000 بايت (4 * PAGE_SIZE)== لكي يتوافق مع تخصيصات kbase_kcpu_command_queue.
من أجل استخدام pipe_buffer بشكل صحيح، يلزم عنوان صفحة. القدرة على تحديد عنوان النواة لكائن kbase_kcpu_command_queue يمكنني إنشاؤه وتدميره عمدًا تجعله مرشحًا جيدًا للاستخدام، ويمكن الحصول على struct page المطابق له باستخدام virt_to_page.
إذن، كائن pipe_buffer يكون كما يلي:```c
struct pipe_buffer {
struct page *page;
unsigned int offset, len;
const struct pipe_buf_operations *ops;
unsigned int flags;
unsigned long private;
};
كما ذُكر سابقًا، يجب أن يحتوي حقل `page` على عنوان صفحة صالح. يجب ألا يتجاوز حقلا `offset` و`len` قيمة `PAGE_SIZE`، وإلا ستقوم الأنبوب بزيادة عدّادي head/tail، مما يؤدي إلى استخدام كائن `pipe_buffer` جديد وفقدان السيطرة على مخزن الأنبوب المزيّف (fake pipe buffer).
أيضًا، يجب أن تكون `flags` مساوية لـ `PIPE_BUF_FLAG_CAN_MERGE` بحيث أنه في استدعاءات `pipe_write` التالية، بدلًا من زيادة عدّاد head بشكل أعمى واستخدام مخزن الأنبوب التالي، يتم أولًا التحقق مما إذا كانت هناك مساحة في `pipe_buffer` الحالي تتسع لطلب الكتابة أم لا، وإذا وُجدت، فسيتم ببساطة إلحاق البيانات بنفس مخزن الأنبوب بدءًا من القيمة المخزنة في حقل `len`.
لتجنب تعطل الجهاز عند `pipe_buf_confirm`، والذي يتم استدعاؤه من قبل `pipe_write` و`pipe_read`’، يجب أن يكون مؤشر `ops` أيضًا عنوانًا صالحًا في النواة مع تعيين حقل `ops->confirm` إلى _NULL_. يمكنني ببساطة استخدام إزاحة داخل الكائن المُسرَّب `kbase_kcpu_command_queue` تكون قيمتها NULL ولا تتغير تحت أي ظرف.
### اختيار قيمة الإزاحة المثلى للـ underflow
بينما تكون أحجام التخصيص لكل من `buff` و`kbase_kcpu_command_queue` و`pipe_buffer` حوالي ~0x4000~ بايت، اخترت إجراء underflow للمخزن بمقدار **0x8000** بايت. لماذا؟
لنلقِ نظرة سريعة على كيفية تحديث `pipe_buffers` أثناء عمليات القراءة والكتابة. افترض أننا نستطيع تشكيل `pipe_buffer` بالشكل التالي:```c
struct pipe_buffer {
.page = virt_to_page(addr),
.offset = 0,
.len = 0x40,
.ops = kcpu_addr + 0x50,
.flags = PIPE_BUF_FLAG_CAN_MERGE,
unsigned long private = 0
};
بينما تمنح الثغرة القدرة على التحكم التعسفي في محتوى هذا الكائن، فإنها لا تفعل ذلك إلا مرة واحدة فقط لأن الكائن الذي حدث فيه الـ underflow يتم تحريره مباشرة بعد انتهاء استدعاء ioctl. يشكّل هذا في الواقع مشكلة لأنني أحتاج إلى تحديث كائن pipe_buffer يدويًا لإعادة استخدامه، نظرًا لأن كل عملية قراءة/كتابة على الأنبوب:
.page؛ يبقى كما هو، وعندما يصبح المخزن المؤقت فارغًا، يتم تحريره، وهو أمر لا أريده أن يحدث لأن حقل .ops غير مضبوط بشكل صحيح.pipe_buffer يحدّث حقل .offset عند إجراء القراءة، لذلك لا يمكنني قراءة نفس المنطقة من الذاكرة مرة أخرى.pipe_buffer ستُلحق بالمخزن المؤقت ابتداءً من قيمة .len (بافتراض أن علم PIPE_BUF_FLAG_CAN_MERGE مضبوط)، ويتم تحديث .len وفقًا لذلك. أي أننا لا نستطيع كتابة البيانات إلى العنوان نفسه مرتين.نتيجة لذلك، ما لم أُحدّث pipe_buffer بشكل صحيح بعد كل عملية قراءة أو كتابة، لا يمكنني القراءة والكتابة من/إلى نفس الأنبوب في الوقت نفسه. لهذا السبب فإن إحداث underflow بـ 0x8000 بايت هو أكثر عملية بكثير، لأنه بدلاً من استبدال pipe_buffer واحد، سأستبدل مثيلين مختلفين من pipe_buffer لكائني أنبوبين مختلفين: أحدهما سيُستخدم للقراءة والآخر لعمليات الكتابة.```c
#define PIPE_BUF_FLAG_CAN_MERGE 0x10 /* can merge buffers */
pipe_read = (struct pipe_buffer *)( ptr); pipe_read->page = virt_to_page(ta->kcpu_kaddr); pipe_read->offset = 0; pipe_read->len = 0xfff; pipe_read->ops = (const void *)(ta->kcpu_kaddr + 0x50); pipe_read->flags = PIPE_BUF_FLAG_CAN_MERGE; pipe_read->private = 0;
pipe_write = (struct pipe_buffer )( ptr + 0x4000); pipe_write->page = virt_to_page(ta->kcpu_kaddr); pipe_write->offset = 0; pipe_write->len = 0; / This is the starting position of the pipe_write */ pipe_write->ops = (const void *)(ta->kcpu_kaddr + 0x50); pipe_write->flags = PIPE_BUF_FLAG_CAN_MERGE; pipe_write->private = 0;
إن `pipe_read` عبارة عن مخزن أنبوب وهمي سيُستخدم لقراءة البيانات من الصفحة الهدف بدءًا من `.offset = 0` وحتى `0xfff` بايت، بينما `pipe_write` هو `pipe_buffer` وهمي سيُستخدم لكتابة البيانات بدءًا من `.len = 0` وحتى `0xfff` بايت.
من المهم أيضًا أن نذكر مرة أخرى أن كتابة أكثر من `PAGE_SIZE` بايت ستدفع الأنبوب إلى زيادة عدّاد الرأس (head counter)، وبالتالي استخدام `pipe_buffer` مخصّص حديثًا وفقدان السيطرة على `pipe_write` الوهمي. من ناحية أخرى، فإن إفراغ مخزن `fake_read` (قراءة بيانات بحجم `0xfff` منه) يخبر النواة بتحرير الصفحة الفعلية عبر استدعاء `ops→release`، مما يؤدي إلى انهيار النواة لأنني لا أملك بعد عنوان نص النواة.
رغم أنني نجحت في عزل عمليات القراءة والكتابة على الأنبوب بحيث لا يتعارض تنفيذ كتابة في أحد طرفي الأنبوب مع مخزن الأنبوب الآخر والعكس صحيح، إلا أنني لم أحل المشكلة الأساسية بعد: كيف يمكن تحديث مخزن الأنبوب بشكل موثوق؟ الجواب الواضح الذي تبادر إلى ذهني كان ببساطة تكرار عملية الرش (spray) مرارًا وتكرارًا بعد كل استدعاء قراءة أو كتابة على الأنبوب. وهذا غير منطقي لأنه سيكون له تأثير كبير على موثوقية الاستغلال. في القسم التالي، سأقسم الهدف إلى هدفين فرعيين: أولاً، سأركز على حقل `.page` فقط، ثم على الحقلين `.len/.offset` بعد ذلك.
### تعديل حقل pipe_buffer→page
لدهشتي، لا يلزمني ولا أحتاج إلى تحديث `.page` إطلاقًا، وذلك لأنني أستطيع الكتابة فوق `pipe_buffer→page` ليشير إلى عنوان صفحة `kbase_kcpu_command_queue` المسرَّبة. لذلك، **كل ما عليّ فعله هو تحرير كائن `kbase_kcpu_command_queue` وتراكبه مع كائن `pipe_buffer` جديد. أجل! الآن لديّ `pipe_buffer→page` يشير إلى كائن `pipe_buffer` شرعي!
استبدال `kbase_kcpu_command_queue` بـ `pipe_buffer` يمنحنا القدرة على التعامل مع مخزن أنبوب شرعي دون الحاجة إلى تحديث حقل `.page` بانتظام. ومع ذلك، لا يزال يتعين عليّ التعامل مع الحقلين `.len` و`.offset`.
### تعديل حقلي pipe_buffer→len/offset
كما ذكرت سابقًا، فإن إجراء عمليات القراءة/الكتابة على الأنبوب يحدّث الحقلين `.len` و`.offset`، مما يجعل عمليات القراءة/الكتابة اللاحقة على نفس الصفحة غير قابلة للاستخدام حتى لو تمت عبر الأنبوبين المنفصلين. إليك حيلة أخرى: **هناك تقنية لقراءة/كتابة البيانات دون حتى لمس الحقلين `.len/.offset`!**. ومن الممكن تحقيق ذلك عن طريق إحداث خطأ (fault) في استدعاءات `copy_page_from_iter` و`copy_page_to_iter` الخاصة بـ `pipe_read/write`! نعم، تمامًا مثل `copy_to/from_user`، تقوم `copy_page_to/from_iter` بنسخ البيانات من/إلى مساحة المستخدم التي يتم تمريرها عبر بنية `iov_iter`، ويمكن إحداث خطأ فيها.
لمواصلة المثال السابق، إذا أردنا كتابة 8 بايتات من البيانات إلى عنوان معيّن، يجب أن يكون حجم المخزن الموفَّر في مساحة المستخدم مساويًا لـ 8، متبوعًا بمنطقة ذاكرة غير معيّنة أو غير قابلة للقراءة، ثم نمرر `9` كوسيط حجم إلى استدعاء النظام `write` للإشارة إلى كمية البيانات التي نريد كتابتها.ستكتب هذه العملية 8 بايتات وتفشل عند البايت _التاسع_ لأنها تصادف موقع ذاكرة غير معيّن/غير قابل للقراءة. ونتيجة لذلك، تكون البيانات قد كُتبت فعليًا إلى المخزن الوجهة في النواة ولم يتم تعديل حقل `.len`. ستعود دالة النواة `pipe_write` فحسب دون تحديث حقل `buf->len`.```c
if ((buf->flags & PIPE_BUF_FLAG_CAN_MERGE) &&
offset + chars <= PAGE_SIZE) {
ret = pipe_buf_confirm(pipe, buf);
if (ret)
goto out;
ret = copy_page_from_iter(buf->page, offset, chars, from);
if (unlikely(ret < chars)) {
ret = -EFAULT;
goto out;
}
buf->len += ret;
if (!iov_iter_count(from))
goto out;
}
الأمر نفسه ينطبق على عمليات القراءة؛ إذا أردنا قراءة 8 بايتات، اجعل البايت التاسع من المخزن المؤقت غير قابل للقراءة ثم ادّعِ فقط أننا نريد قراءة 9 بايتات، سيتم نسخ البيانات إلى مخزن المستخدم المؤقت دون تغيير حقل .offset.
نتيجةً لذلك، يمكننا إجراء عمليات قراءة/كتابة غير محدودة على أي عنوان ذاكرة في النواة دون الحاجة إلى المرور المتكرر بعملية الرش (spray).
الآن وبعد أن أصبح لديّ إمكانية قراءة/كتابة عشوائية قوية، قمت بالبحث في جميع struct page داخل مصفوفة VMEMMAP_START لتحديد عنوان بداية نص النواة باستخدام التقنية الموضحة في منشور مدونة Interrupt Labs. ثم أدركت أن init_task يتم تصفيره إلى صفر في تحديثات أندرويد الأمنية لشهر نوفمبر, لذا استخدمت kthreadd_task بدلاً منه. امتلاك عنوان نواة kthreadd_task سمح لي باجتياز قائمة task->tasks والحصول على عنوان نواة المهمة الحالية الخاصة بي current، ثم تصفير بنية cred لتحقيق صلاحيات الجذر.
لاحقًا، أدركت أن فحص جميع عناوين الصفحات كان غير ضروري لأنني كنت أملك بالفعل عنوان نص النواة anon_pipe_buf_ops من كائن pipe_buffer. بهذه المعلومات، تمكنت من استنتاج عنوان قاعدة نص النواة، متجاوزًا بذلك KASLR بشكل فعّال.
يُعطّل الاستغلال أيضًا SELinux، وبوجود عنوان قاعدة نص النواة، كل ما أحتاجه هو العثور على موقع البنية العامة selinux_state ثم تصفير قيمة .enforcing.
تم اختبار إثبات المفهوم المرفق بالتقرير على أجهزة Pixel 7 و8 Pro التي تعمل بنظام Android 14 مع تحديثات ASB لشهري أكتوبر ونوفمبر، محققًا معدل نجاح يقارب 100%. من المهم أيضًا الإشارة إلى أن الاستغلال لن يعمل بشكل مباشر على الأجهزة الأخرى بسبب استخدام بعض الإزاحات الثابتة (hardcoded offsets). لإضافة دعم لجهاز جديد، يجب توفير ما يلي:
kthreadd_task من عنوان قاعدة النواة.selinux_state من عنوان قاعدة النواة.task_struct->cred , task_struct->pid وtask_struct->tasks.anon_pipe_buf_ops من عنوان قاعدة النواة.لتجميع الاستغلال كملف ثنائي مستقل، استخدم الأمر التالي، ثم استخدم adb shell لتشغيله:```sh
$ aarch64-linux-androidXX-clang++ -static-libstdc++ -w -Wno-c++11-narrowing -DUSE_STANDALONE -o poc poc.cpp -llog
$ adb push poc /data/local/tmp/
$ adb shell /data/local/tmp/poc
يمكنك أيضًا تشغيل الاستغلال عبر تطبيق Android Studio من خلال تضمين هذا الدليل معه، وتأكد من تعطيل تحذيرات C++ غير المفيدة بإضافة `-w -Wno-c++11-narrowing` إلى ملف cmake.
### عرض توضيحي```shell
$ adb logcat |grep -i EXPLOIT
11-28 16:04:12.500 7989 7989 E EXPLOIT : [+] Target device: 'google/husky/husky:14/UD1A.231105.004/11010374:user/release-keys' 0xa9027bfdd10203ff 0xa90467faa9036ffc
11-28 16:04:15.563 7989 7989 E EXPLOIT : [+] Got the kcpu_id (0) kernel address = 0xffffff8901390000 from context (0x0)
11-28 16:04:18.441 7989 7989 E EXPLOIT : [+] Got the kcpu_id (255) kernel address = 0xffffff89b0bf8000 from context (0xff)
11-28 16:04:18.442 7989 7989 E EXPLOIT : [+] Found corrupted pipe with size 0xfff
11-28 16:04:18.442 7989 7989 E EXPLOIT : [+] SUCCESS! we have a fake pipe_buffer (0)!
11-28 16:04:18.444 7989 7989 E EXPLOIT : 10 00 39 01 89 FF FF FF 10 00 39 01 89 FF FF FF | ..9.......9.....
11-28 16:04:18.444 7989 7989 E EXPLOIT : 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 | ................
11-28 16:04:18.444 7989 7989 E EXPLOIT : 00 B0 CD 12 C0 FF FF FF 00 00 00 00 00 00 00 00 | ................
11-28 16:04:18.444 7989 7989 E EXPLOIT : 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 | ................
11-28 16:04:18.445 7989 7989 E EXPLOIT : [+] Freeing kcpu_id = 0 (0xffffff8901390000)
11-28 16:04:18.446 7989 7989 E EXPLOIT : [+] Allocating 61 pipes with 256 slots
11-28 16:04:18.462 7989 7989 E EXPLOIT : [+] Successfully overlapped the kcpuqueue object with a pipe buffer
11-28 16:04:18.463 7989 7989 E EXPLOIT : 40 AB BA 26 FE FF FF FF 00 00 00 00 30 00 00 00 | @..&........0...
11-28 16:04:18.463 7989 7989 E EXPLOIT : 70 37 8D F1 DA FF FF FF 10 00 00 00 00 00 00 00 | p7..............
11-28 16:04:18.463 7989 7989 E EXPLOIT : 00 00 00 00 00 00 00 00 | ........
11-28 16:04:18.463 7989 7989 E EXPLOIT : [+] pipe_buffer {.page = 0xfffffffe26baab40, .offset = 0x0, .len = 0x30, ops = 0xffffffdaf18d3770}
11-28 16:04:18.463 7989 7989 E EXPLOIT : [+] kernel base = 0xffffffdaf0010000, kthreadd_task = 0xffffff8002da3780 selinux_state = 0xffffffdaf28a3168
11-28 16:04:20.097 7989 7989 E EXPLOIT : [+] Found our own task struct 0xffffff88416c5c80
11-28 16:04:20.097 7989 7989 E EXPLOIT : [+] Successfully got root: getuid() = 0 getgid() = 0
11-28 16:04:20.097 7989 7989 E EXPLOIT : [+] Successfully disabled SELinux
11-28 16:04:20.102 7989 7989 E EXPLOIT : [+] Cleanup ... OK