Skip to content
• 6 دقائق للقراءة

Heap Metadata Corruption Explained: عندما تبدأ بايت واحدة بإعادة كتابة قواعد الـAllocator

شرح عملي لكيفية تحويل one-byte heap write إلى memory corruption متحكم فيه على glibc حديثة. نبدأ من خطأ صغير في bounds checking ونصل إلى allocator metadata وdynamic linking ثم primitive حتمية لتنفيذ أمر.

#Heap Exploitation #glibc #Memory Corruption #Dynamic Linking #Pwn
Heap Metadata Corruption Explained: عندما تبدأ بايت واحدة بإعادة كتابة قواعد الـAllocator hero illustration
تسجيل صوتي للمقال
قراءة آلية
0:00 / --:--

Heap Metadata Corruption Explained: عندما تبدأ بايت واحدة بإعادة كتابة قواعد الـAllocator

تخيل مستودعًا فيه صناديق، وكل صندوق عليه label صغير يحدد حجمه ومكان الصندوق التالي. أنت مسموح لك بتعديل حرف واحد فقط من هذا الـlabel. يبدو الأمر محدودًا جدًا.

لكن ماذا لو كان مدير المستودع يثق بهذا الـlabel ثقة عمياء؟

هنا تبدأ المتعة.

الـheap allocator يعتمد على metadata لتحديد أحجام الـchunks وحالتها وروابطها. تغيير byte واحدة في المكان الصحيح قد يجعل allocator يعيد تفسير الذاكرة بالكامل.

في هذا المقال سنبني سيناريو عام يوضح الفكرة من البداية: خطأ صغير في input validation يتحول إلى one-byte out-of-bounds write، ثم إلى heap metadata corruption، ثم controlled allocation، وأخيرًا إلى primitive تصل إلى dynamic-linking structures وتؤدي إلى تنفيذ أمر.

ما هي الثغرة أصلًا؟

المشكلة الأساسية تأتي من كتابة comparison في C وكأنها chained comparison رياضي.

المطور قد يقصد:

0 <= index && index < size

لكنه يكتب:

0 <= index < size

في Python هذا مفهوم.

في C؟ ليس بالطريقة التي تتوقعها.

اللغة تقيم:

0 <= index

أولًا، والنتيجة تكون:

0 أو 1

ثم تستخدم النتيجة في المقارنة الثانية:

(0 <= index) < size

وبالتالي مع معظم قيم size >= 2 يصبح الشرط ناجحًا.

النتيجة أن index السالب يمكن أن يمر من الـvalidation.

وإذا كان لدينا:

char *ptr = malloc(size);
ptr[index] = value;
free(ptr);

فـnegative index يعني أن الكتابة ستحدث قبل الـuser pointer.

وهذه هي نقطة البداية.

لماذا one-byte write مهمة؟

قد تبدو one-byte write كأنها primitive ضعيفة جدًا.

لكن قوة الـprimitive لا تعتمد فقط على عدد الـbytes التي تستطيع كتابتها. المكان الذي تستطيع الكتابة فيه أهم بكثير.

الـallocator يحتفظ بـmetadata حول الـchunks، وهذه الـmetadata قد تحتوي على:

  • Chunk size
  • In-use bits
  • Forward وbackward links
  • Tcache metadata
  • Small-bin relationships
  • بيانات داخلية يستخدمها dynamic linker

لذلك:

one-byte write في مكان عادي شيء ممل.

أما one-byte write فوق metadata حساسة فهي أقرب إلى مفتاح صغير يفتح بابًا أكبر بكثير.

مثال عام على الكود الضعيف

لنأخذ برنامجًا مبسطًا:

#include <stdio.h>
#include <stdlib.h>

void handle(void) {
    size_t size;
    int index;
    unsigned char value;

    if (scanf("%zu %d %hhu", &size, &index, &value) != 3)
        exit(1);

    if (size > 0x3e8 || !(0 <= index < size))
        exit(1);

    char *ptr = malloc(size);
    ptr[index] = value;
    free(ptr);
}

الهدف كان واضحًا:

0 <= index && index < size

لكن الكود الفعلي لا يفعل ذلك.

ولهذا توجد قاعدة تدقيق بسيطة جدًا:

عندما ترى أكثر من < أو <= في expression واحدة بلغة C، لا تفترض أنها chained comparison. افحص طريقة تقييمها فعلًا.

النسخة المصححة

اكتب الشرط بشكل صريح:

if (size > 0x3e8 || index < 0 || (size_t)index >= size)
    exit(1);

هنا:

  • index < 0 يمنع negative offsets.
  • التحويل إلى size_t مقصود وواضح.
  • المقارنة الثانية أصبحت بين قيمتين منطقًا متوافقًا.

وبالتالي لا يحصل الـallocator على primitive يمكن العبث بها.

Worked Example: من byte واحدة إلى Heap Control

الخطوة الأولى: صنع Heap Layout يمكن توقعه

أول شيء يحتاجه الـexploit هو ترتيب مستقر نسبيًا للـchunks.

نعمل عدة allocations بأحجام مدروسة:

+------------------+
| chunk A          |
+------------------+
| chunk B          |
+------------------+
| chunk C          |
+------------------+

الهدف ليس عمل heap spray.

الهدف هو وضع metadata مهمة بالقرب من المكان الذي يمكن الوصول إليه باستخدام one-byte write.

كل offset هنا مهم.

الخطوة الثانية: استخدام الـnegative index

عند قبول negative index، تصبح الكتابة قبل الـuser pointer:

returned pointer
      |
      v
[A A A A A A A A]
^
|
metadata nearby

حتى:

-1

يغير byte واحدة قبل بداية المنطقة التي أعادها malloc.

والـnegative offsets الأكبر تسمح بالوصول إلى metadata الخاصة بـchunks مجاورة.

وهنا يتحول السؤال من:

هل أستطيع الكتابة في buffer؟

إلى:

هل أستطيع وضع byte واحدة فوق metadata التي يثق بها allocator؟

وهذا فرق هائل.

الخطوة الثالثة: تشكيل allocator metadata

بعد تجهيز الـheap، يتم استخدام allocations وfrees بعناية لجعل chunk headers والـfreelists تتفاعل بشكل يمكن استغلاله.

من هنا يبدأ الـallocator نفسه في العمل كجزء من الـexploit.

الصورة الذهنية المفيدة هي:

attacker-controlled byte
        ↓
chunk metadata
        ↓
allocator bookkeeping
        ↓
unexpected pointer
        ↓
future malloc() result

أنت لا تكتب كل شيء مباشرة.

أنت تغيّر القاعدة التي يتبعها allocator.

الخطوة الرابعة: Tcache والـBins

glibc الحديثة تستخدم أكثر من freelist mechanism، ومنها tcache وsmall/unsorted bins.

الهدف هو ترتيب chunks بحيث يؤدي allocation لاحق إلى إعادة pointer لمكان metadata أو structure متحكم فيه بدل أن يعطيك chunk عاديًا.

الفكرة الأساسية:

الـallocator يتوقع:
next free chunk = X

نحن نجعله يقتنع:
next free chunk = Y

لكن حتى يصدق allocator هذه الكذبة، يجب أن تتوافق metadata مع consistency checks الخاصة به.

الخطوة الخامسة: Safe-linking

هنا كثير من exploits القديمة تتوقف فجأة وتقول لك:

Segmentation fault

glibc الحديثة لا تخزن بعض tcache pointers بصورتها الخام.

النموذج الشائع هو:

stored = real_pointer ^ (location >> 12);

لذلك إذا كتبت:

stored = target;

قد يقوم malloc بفكها إلى عنوان غير صالح.

الفكرة الصحيحة تكون:

stored = target ^ (storage_address >> 12);

هذه النقطة مهمة جدًا.

إذا كانت فكرة tcache poisoning صحيحة لكن التنفيذ يموت فورًا على glibc حديثة، فـsafe-linking من أول الأشياء التي يجب فحصها.

الخطوة السادسة: تحويل allocator behavior إلى Controlled Allocation

بعد نجاح metadata corruption، يستطيع allocation لاحق أن يعيد pointer إلى عنوان متحكم فيه.

وهنا تتغير قوة الـprimitive جذريًا:

1-byte write
    ↓
metadata corruption
    ↓
forged freelist state
    ↓
controlled malloc address

الآن لم نعد محدودين بكتابة byte واحدة.

البرنامج نفسه ينفذ:

ptr[offset] = ...

على pointer أصبح تحت تأثيرنا.

وهذا يعطينا مساحة لكتابة بيانات أكبر عبر API طبيعي.

بعبارة أخرى:

الـallocator حمل الجزء الثقيل من الـexploit نيابة عنك.

لماذا Dynamic Linking مهم؟

عندما تصل إلى controlled allocation، لا يكون الهدف دائمًا overwrite بسيطًا على function pointer معروف.

يمكن بدلًا من ذلك استهداف structures يستخدمها dynamic linker أثناء symbol resolution.

هذه الـstructures تحتوي metadata خاصة بالـlibraries والsymbols والrelocations.

الفكرة العامة:

controlled allocation
        ↓
forged linker metadata
        ↓
resolver يقرأ البيانات المعدلة
        ↓
symbol resolution يذهب إلى عنوان نتحكم به
        ↓
تنفيذ command

ميزة هذه الفكرة أنها لا تعتمد على وجود hook كلاسيكي واحد يجب أن يكون متاحًا في كل إصدار libc.

لكن هناك مشكلة:

الـdynamic linker version-dependent جدًا.

لذلك يجب أن تطابق الـlibc والـloader الحقيقيين للهدف أثناء تطوير الـexploit.

لماذا نريد Exploit حتميًا؟

الـheap exploitation الجيد لا يفترض:

نجرب ونرى
نجرب مرة أخرى
ربما الـpointer يطلع صح

بل يبني حالة deterministic قدر الإمكان.

الـsequence يكون مثلًا:

groom
→ corrupt
→ free
→ reallocate
→ controlled chunk
→ forged linker data
→ resolver trigger
→ command execution

كل allocation له وظيفة.

وكل byte مكتوبة لها سبب.

حتى memory budget قد يصبح جزءًا من التحدي، لأن allocations الزائدة قد تجعل الاستغلال يفشل أو تتجاوز الحد المتاح.

الكود الضعيف مقابل الكود المصحح

Vulnerable

if (size > MAX || !(0 <= index < size))
    return;

char *p = malloc(size);
p[index] = value;
free(p);

التعبير يتم تفسيره كالتالي:

(0 <= index) < size

وبالتالي الـbounds check غير صحيح.

Patched

if (size > MAX || index < 0 || (size_t)index >= size)
    return;

char *p = malloc(size);
p[index] = value;
free(p);

الآن negative indexes مرفوضة والمقارنة واضحة.

نقاط مهمة أثناء الـCode Review

ركز على هذه الأشياء:

  1. وجود relational operators متكررة في expression واحدة.
  2. استخدام signed index مع allocation length.
  3. وجود malloc() ثم ptr[index] مباشرة.
  4. وجود free() مباشرة بعد الكتابة.
  5. وجود one-byte corruption قرب chunk metadata.
  6. وجود custom allocator أو wrappers تسمح بالتحكم في freelists.
  7. افتراض أن tcache pointers تُخزن كraw addresses.
  8. وجود controlled allocation قد يصل إلى dynamic-linking metadata.

حتى هذا النوع من البحث قد يكشف مشاكل كثيرة:

grep -RInE '\b0\s*<=|<=\s*[^&|]+<' .

ليس detector كاملًا، لكنه ممتاز كبداية للمراجعة اليدوية.

أخطاء شائعة

“One byte لا تكفي”

غالبًا غير صحيح.

إذا غيرت:

  • size flags
  • pointer byte
  • freelist relationship
  • metadata

فقد تتضخم primitive الصغيرة جدًا إلى memory control أكبر.

“Safe-linking يقضي على Heap Exploitation”

لا.

هو يغير primitive المطلوبة ويمنع بعض الصيغ القديمة، لكنه لا يجعل corrupted metadata آمنة.

“لازم libc leak”

ليس دائمًا.

بعض الأساليب تستفيد من allocator state نفسه ومن controlled allocation بدون طباعة libc address بشكل مباشر.

“Partial overwrite دائمًا غير موثوق”

أيضًا غير صحيح.

أحيانًا partial overwrite أفضل، لأنه يحافظ على معظم pointer صالح ويعدل فقط byte حساسة.

الدفاع: كيف نصلح المشكلة؟

  1. اكتب bounds checks في C بشكل صريح.

    استخدم:

    index < 0 || (size_t)index >= size

    ولا تستخدم chained comparisons.

  2. استخدم unsigned indexes عندما يكون السالب غير منطقي أصلًا.

  3. افصل input validation عن memory access.

  4. استخدم compiler hardening وsanitizers أثناء الاختبار:

    -Wall -Wextra -Wconversion -fsanitize=address,undefined
  5. اختبر boundary values صراحة:

    -1
    0
    size-1
    size
    size+1
  6. وثّق افتراضات allocator مع نسخة glibc المستخدمة.

  7. تعامل مع أي crash أثناء heap operations باعتباره security issue محتمل حتى يثبت العكس.

الخلاصة

Heap exploitation ليس سحرًا.

عادةً يبدأ بخطأ صغير جدًا، ثم يتم وضع هذا الخطأ بجانب شيء مهم، وبعدها يتم إقناع subsystem موثوق بأن يتولى تضخيمه.

البرنامج يمنحك byte واحدة.

الـallocator يمنحك الرافعة.

والـdynamic linker يمكن أن يوفر المرحلة الأخيرة.

لذلك أفضل سؤال أثناء تحليل memory corruption ليس:

كم byte أستطيع أن أكتب؟

بل:

ما الذي سيتغير إذا كانت هذه الـbyte مكتوبة في المكان الصحيح؟

الـsafe heap corruption هو corruption لم تصبح valid allocator metadata أصلًا.

References

⌘
Suggested Searches