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

Arbitrary Pointer Manipulation Explained: When Memory-Safe Code Hands You the Keys

A memory-safe language can prevent whole classes of memory corruption, then leave one perfectly valid pointer in exactly the wrong place. This write-up breaks down how pointer corruption, stale length metadata, and indirect callbacks can turn an innocent note cache into code execution.

#Memory Safety #Pointer Corruption #RCE #Zig #Binary Exploitation
Arbitrary Pointer Manipulation Explained: When Memory-Safe Code Hands You the Keys hero illustration
تسجيل صوتي للمقال
قراءة آلية
0:00 / --:--

Arbitrary Pointer Manipulation Explained: When Memory-Safe Code Hands You the Keys

تخيّل أنك دخلت مبنى لا يحتوي أصلًا على أبواب مكسورة. القفل سليم، الحارس سليم، وحتى دفتر التعليمات مكتوب بعناية. المشكلة الوحيدة أن الحارس يقبل منك استبدال عنوان المكتب الذي سيذهب إليه قبل أن يبدأ جولته.

هذا تقريبًا ما يحدث في بعض أخطاء الـmemory-unsafe design حتى داخل برامج مكتوبة بلغة تهتم بالذاكرة. هنا لا تحتاج بالضرورة إلى heap overflow أو use-after-free. يكفي أحيانًا أن يكون لديك pointer صحيح، لكن يسمح لك البرنامج بتغيير ما يشير إليه، ثم يستخدم هذا الـpointer في عملية حساسة مثل القراءة أو استدعاء callback.

في هذا المقال سنبني سيناريو generic لخدمة note cache، نحلل كيف يتحول pointer manipulation إلى arbitrary read ثم إلى control-flow hijack، ونختم بكيفية إغلاق هذا النوع من الثغرات من المصدر.

What Is Pointer Corruption, Anyway?

Pointer corruption يعني أن البرنامج ما زال يتعامل مع قيمة تبدو صحيحة من ناحية النوع، لكنها تشير إلى المكان الخطأ.

الفرق مهم.

في buffer overflow التقليدي، أنت تكتب خارج الحدود وتفسد الذاكرة المجاورة. في pointer corruption، قد تكون كل الكتابات نفسها ضمن حدود buffer صحيح، لكنك غيّرت pointer أولًا، فأصبح الـbuffer يشير إلى metadata أو function pointer أو object آخر.

بمعنى آخر، المشكلة ليست أن القلم خرج من الورقة. المشكلة أنك غيّرت عنوان البيت المكتوب على الورقة ثم طلبت من البرنامج أن يسلّم الرسالة.

في services التي تدير objects بالشكل التالي:

Object
├── payload pointer
├── payload length
└── callback pointer

وجود عملية تسمح بتعديل payload pointer بدون التحقق من وجهته قد يكون كافيًا لتغيير معنى كل عملية لاحقة.

Why Should You Care?

هذا النمط خطير لأن primitive صغيرة قد تتسلسل إلى سلسلة كاملة:

  • Arbitrary read: تجعل البرنامج يقرأ من عنوان لا يفترض أن يقرأه.
  • Information disclosure: تسريب pointer يكشف PIE base أو عناوين أخرى.
  • Metadata corruption: تغيير length أو pointer يجعل عمليات لاحقة تعمل على object مختلف.
  • Control-flow hijacking: إذا كان object يحتوي callback أو function pointer، يصبح عنوان التنفيذ نفسه هدفًا.
  • RCE: استدعاء callback معدل قد ينقل التنفيذ إلى دالة موجودة أصلًا داخل البرنامج.

والجزء المزعج هنا أن كل خطوة بمفردها قد تبدو “معقولة”. المشكلة في تركيبها معًا.

Worked Example: A Generic Note Service

لنفترض خدمة TCP صغيرة تدير ملاحظات مؤقتة.

كل note object يحتوي:

+0x00  payload *
+0x08  length
+0x10  callback *

ولديها أوامر مبسطة:

PUT key data
GET key length
PATCH key data
RENDER key
DEL key

الفكرة الطبيعية هي:

  • PUT ينشئ note.
  • GET يقرأ عددًا محدودًا من bytes.
  • PATCH يحدّث الـpayload.
  • RENDER يستدعي callback ليطبع المحتوى.
  • DEL يحذف note.

المشكلة تبدأ عندما يكون PATCH معتمدًا على حساب حدود مبني على عنوان الـpayload الحالي، بدل أن يستخدم حدود object نفسه.

Step 1: Leak a pointer

لنفترض أن GET يعرض عددًا من الـbytes أكثر قليلًا مما يحتاجه المستخدم، فيكشف:

payload bytes
node metadata
...

لو حصلنا على:

payload1 = 0x7f...
payload2 = 0x7f...

فنستطيع استنتاج أن الـnode يعيش قبل الـpayload بمقدار ثابت معروف:

node = payload - 0x20

الـleak هنا لا يعطيك RCE. يعطيك خريطة للمبنى.

Step 2: Redirect the payload pointer

إذا كان PATCH يسمح بكتابة metadata بعد بداية الـpayload، يمكن تغيير:

node1->payload

ليشير إلى:

node2->callback

ويصبح state الداخلي:

node1->payload = &node2->callback
node1->length  = 8

الآن RENDER node1 لا يطبع note1 الحقيقي. هو يتعامل مع callback pointer الخاص بـnode2 على أنه بيانات.

هذه هي لحظة تحول bug صغير إلى arbitrary read.

Step 3: Turn the leak into a PIE base

لنفترض أن callback الطبيعي موجود عند offset ثابت:

callback = PIE_base + 0x12340

بالتالي:

PIE_base = callback - 0x12340

وبمجرد معرفة base، يمكن حساب أي symbol معروف داخل binary:

target = PIE_base + target_offset

ميزة هذه الخطوة أنك لا تحتاج إلى معرفة العنوان المطلق مسبقًا. ASLR يمكنه تحريك البرنامج، لكن الـoffsets داخل نفس image تبقى ثابتة.

Step 4: Overwrite the callback

الآن نحتاج إلى تغيير function pointer.

إذا كان target والـoriginal callback داخل نفس PIE mapping، فقد يكفي partial overwrite لعدة bytes منخفضة.

الفكرة:

original callback:
0x7f xx xx 12 34 56

target function:
0x7f xx xx ab cd ef

بدل كتابة 8 bytes كاملة، يمكن أحيانًا تغيير:

ab cd ef

فقط.

هذه الحيلة تقلل الاعتماد على bytes لا نعرفها وتحافظ على الجزء العلوي من العنوان.

Step 5: Trigger indirect execution

وأخيرًا:

RENDER node2
        |
        v
node2->callback(...)
        |
        v
target function

إذا كانت الدالة المستهدفة تنشئ shell أو تستدعي command execution في هذا السياق، يتحول primitive إلى RCE.

النقطة المهمة هنا ليست اسم الدالة أو اسم الخدمة. النقطة أن attacker لم يكسر allocator أصلًا. هو جعل البرنامج يستخدم pointer صحيحًا في المكان الخطأ.

The Core Primitive

يمكن تلخيص السلسلة كلها هكذا:

Pointer leak
    ↓
Derive object layout
    ↓
Corrupt payload pointer
    ↓
Read sensitive pointer
    ↓
Recover PIE base
    ↓
Corrupt callback
    ↓
Trigger indirect call
    ↓
Code execution

وهذا سبب أن كلمة “memory safe” وحدها لا تكفي.

لغة قوية في التحقق من حدود الذاكرة لا تمنعك تلقائيًا من تصميم API يسمح بتحويل:

"pointer to bytes"

إلى:

"pointer to executable metadata"

بشكل قانوني من وجهة نظر النوع.

Vulnerable Code Example

هذا المثال generic ومختصر، لكنه يوضح الفكرة:

const Node = struct {
    payload: []u8,
    callback: *const fn ([]u8) void,
};

fn patch(node: *Node, new_data: []const u8) !void {
    // Dangerous: the write target is derived from mutable payload state.
    if (new_data.len > node.payload.len + 24)
        return error.TooLarge;

    @memcpy(node.payload.ptr, new_data);
}

المشكلة أن node.payload.ptr ليس بالضرورة ما تتوقعه بقية أجزاء البرنامج إذا كان بإمكان attacker التأثير على payload نفسه.

الأهم أن callback موجود داخل نفس object. أي corruption للـobject metadata قد يتحول إلى control-flow corruption.

Patched Version

الأكثر أمانًا أن تفصل بين storage ownership وobject metadata، ولا تسمح لأي external operation بتغيير pointer الداخلي مباشرة:

const Node = struct {
    storage: []u8,
    callback: *const fn ([]u8) void,
};

fn patch(node: *Node, new_data: []const u8) !void {
    if (new_data.len > node.storage.len)
        return error.TooLarge;

    @memcpy(node.storage[0..new_data.len], new_data);
}

وتبقى قاعدة التصميم الأساسية:

untrusted input -> bytes
trusted code    -> object pointers

وليس:

untrusted input -> object pointers

Defense: How to Fix

  1. لا تسمح للـuntrusted input بتعديل pointers داخل objects.
    إذا كان المستخدم يحتاج إلى تعديل البيانات، عدّل bytes نفسها. لا تسمح له بتحديد address أو slice base.

  2. افصل metadata عن attacker-controlled storage.
    وجود payload, length, وcallback في object واحد يجعل corruption أكثر تأثيرًا.

  3. استخدم ownership واضحًا.
    الـstorage الذي يكتبه المستخدم يجب أن يكون منفصلًا عن metadata التي يعتمد عليها control flow.

  4. لا تعتمد على pointer-derived bounds.
    حدود الكتابة يجب أن تعتمد على allocation أو container ownership المعروف، وليس على pointer يمكن تغييره.

  5. عامل function pointers كـcontrol-flow state حساس.
    أي عملية تصل بشكل غير مباشر إلى callback يجب أن تكون مضبوطة جدًا، ويفضل تجنب function pointers القابلة للتأثر بالـinput في أول مكان.

  6. اختبر object invariants بعد كل mutation.
    مثال جيد: تأكد أن payload ما زال ضمن allocation المملوكة للـnode قبل RENDER أو PATCH.

  7. اجعل primitives صغيرة فعلًا.
    إذا كانت API تحتاج GET, PATCH, RENDER, وDEL، تأكد أن كل واحدة لا تستطيع أن تغيّر الافتراضات التي تعتمد عليها الأخرى.

Testing / Audit Points

أثناء مراجعة binary أو source مشابه، ابحث عن الأنماط التالية:

Areaالسؤال
Pointer fieldsهل يستطيع input خارجي تغيير pointer أو slice base؟
Length fieldsهل يمكن فصل length عن allocation الحقيقي؟
Callbacksهل يوجد indirect call يعتمد على object mutable؟
Serializationهل يمكن للمستخدم التأثير على struct layout أو metadata؟
Read pathsهل توجد عملية تطبع bytes خارج الـpayload المتوقع؟
Write pathsهل حدود الكتابة مبنية على pointer mutable؟
ASLRهل يوجد pointer leak يسمح بحساب PIE base؟

Common Myth: “But Zig Is Memory Safe”

اللغة قد تمنع أخطاء كثيرة، لكنها لا تفهم نية application logic نيابة عنك.

إذا قلت للبرنامج:

هذا pointer صالح

ثم جعلت attacker قادرًا على استبداله بـpointer آخر صالح من ناحية النوع، فلن يخرج الـcompiler ليصرخ:

“لحظة، هذا callback لا يجب أن يراه المستخدم.”

هذه مسؤولية design، لا syntax.

Final Thoughts

أخطر الثغرات ليست دائمًا التي تجعل البرنامج يكتب bytes خارج array. أحيانًا يكون كل شيء داخل حدود صحيحة، وكل pointer له نوع صحيح، وكل عملية تبدو قانونية. ثم تكتشف أن attacker استطاع إعادة توجيه pointer واحد، وبعدها صار البرنامج نفسه ينفذ بقية السلسلة نيابة عنه.

Memory safety تقلل مساحة الهجوم كثيرًا، لكنها لا تعفيك من بناء object invariants صحيحة.

إذا كان attacker يستطيع اختيار المكان الذي يشير إليه pointer حساس، فقد أعطيته مفتاح الغرفة بنفسك.

⌘
Suggested Searches