كيف ترجّع الـ Structs والـ Arrays المفقودة من الـ Assembly للـ Decompiler

1. Identifying structs and Arrays in Assembly

1.1 مقدمة: لماذا يهم التمييز بين البُنى؟

C
struct Point { 
int x;
int y; 
};

عندما تفتح ملف تنفيذي في الـ Disassembler، فأنت لا ترى structs ولا arrays ولا حتى متغيرات بأسماء. ما تراه فعلياً هو عناوين الذاكرة، والـ registers، وعمليات نقل بايتات من مكان لآخر الخ. البُنى المنطقية التي كتبها المبرمج الأصلي

لم تعد موجودة كـ struct كـكيان؛ تحولت الى سلسلة من الـ instructions التي تتعامل مع عناوين رقمية فقط. مهمتك كمهندس عكسي هي عكس هذه العملية: إعادة بناء المعنى من حركة هذه البيانات.

البيانات مقابل

المبدأ الذي يجب أن يستقر في ذهنك قبل أي شيء آخر: الذاكرة لا تحمل نوعاً (type). البايتات في الذاكرة هي مجرد بايتات؛ لا يوجد داخلها أي علامة تقول أنا int أو أنا حرف في نص. النوع ليس صفة في البيانات نفسها، بل هو تفسير يفرضه الكود عليها لحظة الوصول من خلال التعليمات.

لنأخذ مثالاً . تخيّل 16 بايت متتالية في الذاكرة:

01 00 00 00 02 00 00 00 03 00 00 00 04 00 00 00

هذه البايتات نفسها، دون تغيير حرف واحد فيها، يمكن أن تكون:

  • array من أربعة int بقيم {1, 2, 3, 4}

  • struct فيها حقلان: int قيمته 1 يليه بنية أخرى، أو long قيمته 0x0000000200000001 (على little-endian) وبعد long آخر

  • array من ثمانية short، أو من ستة عشر char

  • البنية مختلطة : int ثم float بعدها pointer

لا شيء في البايتات نفسها يحسم أي تفسير هو الصحيح. الذي يحسمها هو الطريقة التي يصل بها الكود إلى هذه البايتات. إذا رأيت الكود يقفز إلى العنوان بمقدار 4 بايت في كل دورة loop، فهو يعاملها كمصفوفة int. وإذا رأيته يصل للـ offset صفر ثم للـ offset الثامن بأحجام مختلفة، فهو يعاملها كـ struct. هذه هي البصمة التي ستتعلم قراءتها في بقية هذا القسم.

لاحظ أن الصف واحد فقط لم يتغيّر — نفس الـ 16 بايت. الفرق كله في كيفية "قصّها": أربع خلايا متساوية تعطينا مصفوفة، وخلايا بأحجام مختلفة تعطينا struct. الـ Assembly هو ما يحدد أي قص هو الصحيح.

كيف يفقد الـ Compiler معلومات الأنواع؟

في الكود المصدري، الأنواع كانت واضحة : كان المبرمج يكتب struct وله اسم، وحقول لها أسماء وأنواع، والـ Compiler يعرف بالضبط أن player.health يعني "اقرأ 4 بايت int من الـ offset رقم 8 داخل هذا الهيكل". لكن مهمة الـ Compiler هي توليد كود آلة يعمل بأقصى كفاءة، لا الحفاظ على أسمائك ومفاهيمك. فأثناء الترجمة يحدث ما يلي:

C
    struct player player;


    player.health = 100;
    player.armor = 33;
    strcpy(player.name, "Player1");


    if (player.health && player.armor)
    {
        player.score = 1;
    }
  • تختفي الأسماء. player.health تصبح ببساطة مثل [rbx+8]. لا يبقى أثر لكلمتي player أو health؛ فقط عنوان أساسي وإزاحة رقمية.

  • يختفي مفهوم الهيكل ككيان. الـ Compiler لا يولّد أي تعليمة تقول هنا تبدأ بنية Player. البنية تتحول إلى مجرد كتلة بايتات متجاورة، والحقول تصبح إزاحات ثابتة داخلها.

  • يمكن أن تتفتّت الحقول. حقل قد يُحمَّل مؤقتاً في register، أو يُدمج مع حقل آخر في تعليمة واحدة، أو يُلغى تماماً إذا رأى الـ Optimizer أنه غير مستخدم. فتضيع علاقة 1:1 بين الحقل والذاكرة.

  • يبقى النوع ضمنياً في طريقة الاستخدام فقط. الدليل الوحيد المتبقي على أن شيئاً ما هو float وليس int هو أن الكود استخدم تعليمة فاصلة عائمة (مثل movss/addss) للتعامل معه. النوع لم يعد مكتوباً صار مستنتَجاً من السلوك.

Image
Assembly
.text:000000014001178E                 mov     dword ptr [rbp+68], 100
.text:0000000140011795                 mov     dword ptr [rbp+72], 33
.text:000000014001179C                 lea     rdx, Source     ; "Player1"
.text:00000001400117A3                 lea     rcx, [rbp+16]   ; Destination
.text:00000001400117A7                 call    j_strcpy
.text:00000001400117AC                 nop
.text:00000001400117AD                 cmp     dword ptr [rbp+68], 0
.text:00000001400117B1                 jz      short loc_1400117C0
.text:00000001400117B3                 cmp     dword ptr [rbp+72], 0
.text:00000001400117B7                 jz      short loc_1400117C0
.text:00000001400117B9                 mov     dword ptr [rbp+76], 1
C
  // Decompiler
 
v7 = 100;  
  v8 = 33;
  j_strcpy(Destination, "Player1");
  if ( v7 && v8 )
    v9 = 1;
  v2 = v9;
Image

النتيجة أن الـ Binary يحتفظ بالتخطيط (layout: أين يقع كل بايت) لكنه يفقد المعنى (semantics: ماذا يمثل كل بايت). ولهذا يظهر ناتج الـ Decompiler في البداية بهذا الشكل.

الكودان متكافئان تماماً على مستوى الآلة — نفس الـ instructions — لكن كود المصدر قابل للقراءة لأننا أعدنا حقن معلومات الأنواع التي ضاعت.

لماذا نستعيدها يدوياً؟

الـ Decompiler ذكي على هذه النطاق في إعادة بناء البنية المنطقية (loops، شروط، دوال)، لكنه بطبيعته محافِظ تجاه الأنواع؛ فبما أن معلومات الأنواع غير موجودة في الـ Binary، لا يستطيع اختراعها ، فيتراجع إلى الافتراض الأعمّ والأكثر شفافية:

Pointer إلى عدد + offset او اسمه الصريح كما في حالتنا .

هو يعرف أنه يصل إلى الذاكرة، لكنه لا يعرف أن هذه الكتلة هي struct player. تعريف الأنواع الصحيحة قرارٌ سياقيّ يحتاج بشر يفهم ماذا يفعل البرنامج، مش مجرد كيف يحرّك البايتات. وحين تُغذي الـ Decompiler بهذا التعريف — وهذا هو موضوع بقية هذا المقال— فإنه يعيد رسم كل شيء لك بلغة الحقول والأنواع بدل الإزاحات الخام.

1.2 كيفية تخزين البيانات في الـ Stack

الـ Prologue:

كل دالة تحجز مساحة على الـ Stack لازم تعمل مقدّمة (prologue) تُهيّئ الإطار. خلينا نفكّك مقدّمة دالتنا:

لناخذ اولا الكود الكامل المولد :

Image
Assembly
.text:0000000140011750 sub_140011750   proc near               ; CODE XREF: sub_14001122B↑j
.text:0000000140011750                                         ; DATA XREF: .pdata:000000014001E800↓o
.text:0000000140011750
.text:0000000140011750 var_150         = byte ptr -150h
.text:0000000140011750 var_130         = byte ptr -130h
.text:0000000140011750 Destination     = byte ptr -120h
.text:0000000140011750 var_EC          = dword ptr -0ECh
.text:0000000140011750 var_E8          = dword ptr -0E8h
.text:0000000140011750 var_E4          = dword ptr -0E4h
.text:0000000140011750 var_18          = qword ptr -18h
.text:0000000140011750
.text:0000000140011750 ; __unwind { // j___GSHandlerCheck
.text:0000000140011750                 push    rbp
.text:0000000140011752                 push    rdi
.text:0000000140011753                 sub     rsp, 148h
.text:000000014001175A                 lea     rbp, [rsp+20h]
.text:000000014001175F                 lea     rdi, [rsp+150h+var_130]
.text:0000000140011764                 mov     ecx, 1Ah
.text:0000000140011769                 mov     eax, 0CCCCCCCCh
.text:000000014001176E                 rep stosd
.text:0000000140011770                 mov     rax, cs:__security_cookie
.text:0000000140011777                 xor     rax, rbp
.text:000000014001177A                 mov     [rbp+130h+var_18], rax
.text:0000000140011781                 lea     rcx, byte_14002100E
.text:0000000140011788                 call    sub_14001138E
.text:000000014001178D                 nop
.text:000000014001178E                 mov     dword ptr [rbp+68], 100
.text:0000000140011795                 mov     dword ptr [rbp+72], 33
.text:000000014001179C                 lea     rdx, Source     ; "Player1"
.text:00000001400117A3                 lea     rcx, [rbp+16]   ; Destination
.text:00000001400117A7                 call    j_strcpy
.text:00000001400117AC                 nop
.text:00000001400117AD                 cmp     dword ptr [rbp+68], 0
.text:00000001400117B1                 jz      short loc_1400117C0
.text:00000001400117B3                 cmp     dword ptr [rbp+72], 0
.text:00000001400117B7                 jz      short loc_1400117C0
.text:00000001400117B9                 mov     dword ptr [rbp+76], 1
.text:00000001400117C0
.text:00000001400117C0 loc_1400117C0:                          ; CODE XREF: sub_140011750+61↑j
.text:00000001400117C0                                         ; sub_140011750+67↑j
.text:00000001400117C0                 mov     eax, [rbp+130h+var_E4]
.text:00000001400117C3                 mov     edi, eax
.text:00000001400117C5                 lea     rcx, [rbp+130h+var_150]
.text:00000001400117C9                 lea     rdx, unk_140019C00
.text:00000001400117D0                 call    sub_14001105A
.text:00000001400117D5                 mov     eax, edi
.text:00000001400117D7                 mov     rcx, [rbp+130h+var_18]
.text:00000001400117DE                 xor     rcx, rbp        ; StackCookie
.text:00000001400117E1                 call    j___security_check_cookie
.text:00000001400117E6                 lea     rsp, [rbp+128h]
.text:00000001400117ED                 pop     rdi
.text:00000001400117EE                 pop     rbp
.text:00000001400117EF                 retn
.text:00000001400117EF ; } // starts at 140011750
.text:00000001400117EF sub_140011750   endp
C
__int64 sub_140011750()
{
  char *v0; // rdi
  __int64 i; // rcx
  unsigned int v2; // edi
  _BYTE v4[32]; // [rsp+0h] [rbp-20h] BYREF
  char v5; // [rsp+20h] [rbp+0h] BYREF
  char Destination[8]; // [rsp+30h] [rbp+10h] BYREF
  int v7; // [rsp+64h] [rbp+44h]
  int v8; // [rsp+68h] [rbp+48h]
  int v9; // [rsp+6Ch] [rbp+4Ch]

  v0 = &v5;
  for ( i = 26; i; --i )
  {
    *(_DWORD *)v0 = -858993460;
    v0 += 4;
  }
  sub_14001138E(byte_14002100E);
  v7 = 100;
  v8 = 33;
  j_strcpy(Destination, "Player1");
  if ( v7 && v8 )
    v9 = 1;
  v2 = v9;
  sub_14001105A(v4, &unk_140019C00);
  return v2;

push rbp / push rdi — حفظ registers غير متطايرة. في الـ x64 calling convention، الـ registers RBX و RBP و RDI و RSI و RSP و R12R15 و XMM6XMM15 تُعتبر nonvolatile ويجب على الدالة التي تستخدمها أن تحفظها وتستعيدها. فبما إن الدالة ستستخدم على rbp (كـ frame pointer) وعلى rdi (راح نشوفه بالـ rep stosd)، لازم تحفظ قيمتهم الأصلية أول إشي وتُرجّعها بالنهاية.

sub rsp, 148h — حجز 0x148 = 328 بايت للمتغيرات المحلية (ليس شرطاََ انت تكون متغيرات محلية جميعها).

  • lea rbp, [rsp+20h] — تثبيت rbp = rsp + 0x20. وهنا نقطة لازم اتكلم عنها: في كتير المراجع بيقولوا المتغيرات المحلية دايماً negative offsets من rbp"، وهاي غير دقيقة كتعميم. هنا الـ MSVC وضع rbp في أسفل الإطار (مش أعلاه)، فصارت كل المتغيرات المحلية تُعنون بـ offsets موجبة من rbp — لاحظ mov dword ptr [rbp+68], 100، مش [rbp-68] ولم يستخدم RSP ايضا كما انه هو الاكثر شيوعا.

اتجاه الإزاحة يعتمد على وين حطّ الـ Compiler الـ rbp، مش قاعدة مطلقة. وايضا وضعه على rsp+32 لحماية الـ shadow space .

مشاكل الـ Debug build لازم تفلتّره

هذا الـ Binary مبني بوضع Debug /Od، والـ Compiler حقن كود ما إلو أي علاقة بالـ source. مهارة أساسية بالهندسة العكسية إنك تتعرّف عليه وتتجاهله:

1) تعبئة الـ 0xCC:

Assembly
.text:000000014001175F                 lea     rdi, [rsp+32]
.text:0000000140011764                 mov     ecx, 26
.text:0000000140011769                 mov     eax, 0CCCCCCCCh
.text:000000014001176E                 rep stosd

قام بتعبئة الـ stack من rsp+32 الى rsp+136 بـ 0xCC .

هاي ميزة الـ /RTC.

المتغيرات المحلية تُملأ بـ 0xCC، وهو نفسه opcode تعليمة INT 3 (breakpoint)؛ والهدف إن أي قراءة من متغير غير مُهيّأ تطلع قيمة سهلة الملاحظة بدلا من الاصفر.

C
  v0 = &v5;
  for ( i = 26; i; --i )
  {
    *(_DWORD *)v0 = 0xCCCCCCCC;
    v0 += 4;
  }

2) الـ Stack cookie (Canary):

Assembly
mov     rax, cs:__security_cookie
xor     rax, rbp
mov     [rbp+130h+var_18], rax     ; = [rbp+0x118]

هاي حماية الـ /GS؛ الـ __security_cookie و __security_check_cookie مرتبطان بميزة الـ Buffer Security Check، وبتتفحّص بنهاية الدالة عبر j___security_check_cookie لكشف الـ stack overflow.

3) الـ Just My Code:

Assembly
lea     rcx, byte_14002100E
call    sub_14001138E

هذا sub_14001138E هو __CheckForDebuggerJustMyCode تبع الـ /JMC. لما يكون /JMC مُفعّل، الـ Compiler بيحقن استدعاءات لدالة مساعدة اسمها __CheckForDebuggerJustMyCode .

فك ترميز notation الخاص بـ IDA Pro :

Assembly
[rbp+130h+var_X]

الـ IDA ما بتعرض الإزاحة بشكل صريح افتراضيا بتعرّف لكل متغير رمز مثل (var_EC) بإزاحة سالبة عن قاعدة افتراضية، وبتضيف قيمة الـ frame-pointer delta (0x130 هون) للتوفيق. المعادلة:

بالوضع الافتراضي يكون بهذا الشكل :

Image
Assembly
.text:0000000140011750 var_EC          = dword ptr -0ECh

العنوان الفعلي = rbp + 0x130 + var_EC يمكنك حسابه بشكل سريع من خلال الضغط على K ويتحول مباشره .

مثال ، حقل health (كما نعرفه مسبقا) (المسمّى var_EC):

Python
var_EC = -0xEC
rbp + 0x130 + (-0xEC) = rbp + 0x44 = rbp + 68

وهذا بالضبط اللي بتشوفه بالـ instruction هو:

Assembly
mov dword ptr [rbp+68], 100

سبب هذا التحايل إن الـ IDA بتحبّ تعرض المتغيرات المحلية كإزاحات سالبة عن قاعدة أعلى الإطار (عشان تطابق النموذج الشائع)، مع إن الترميز الحقيقي هنا [rbp+موجب] .

ان قمنا بتتبع هنا :

Assembly
.text:000000014001178E                 mov     dword ptr [rbp+68], 100
.text:0000000140011795                 mov     dword ptr [rbp+72], 33
.text:000000014001179C                 lea     rdx, Source     ; "Player1"
.text:00000001400117A3                 lea     rcx, [rbp+16]   ; Destination
.text:00000001400117A7                 call    j_strcpy
.text:00000001400117AC                 nop
.text:00000001400117AD                 cmp     dword ptr [rbp+68], 0
.text:00000001400117B1                 jz      short loc_1400117C0
.text:00000001400117B3                 cmp     dword ptr [rbp+72], 0
.text:00000001400117B7                 jz      short loc_1400117C0
.text:00000001400117B9                 mov     dword ptr [rbp+76], 1
.text:00000001400117C0

من عند rbp+16 :

ان أتينا وحسبنا اقرب متغير له هو 16 - 68 = 52

اذن هذا الـ rbp+16 (Destination) يتسع 52 بايت اذن هو array من 52 بايت مبدئيا :

Assembly
BYTE Unknown_array[52]

او كما نعلم هو الـ name ومساحته 50.

وبعدها من rbp+68 الى rbp+72 بمعنى 4 بايتات مخزن عن بداية rbp+68

بناء على نمط الاستخدام هو integer سنقوم بتسمية :

Assembly
DWORD Unknown_Variable ;

ومن rbp+72 الى rbp+76 ايضا 4 بايتات :

Assembly
DWORD Unknown_Variable2 ;

لكن ملاحظة لا يوجد دليل على member id المتواجد ؟ بكل اختصار نحن لا نعلم اين هو هل هو مستخدم ؟ لا هل يوجد اي وصول له هنا ؟ لأ هل يوجد اي تعامل مع هذا الهيكل في اي مكان اخر ؟ لأ اذن لا نستطيع ان نحدد ما هذا لذالك بالوضع الطبيعي لن نعلم الا بحالة وحدة سنتكلم عليها بهذا المقال لكن بناء على معرفتنا في الكود المصدري سيكون بهذا الشكل :

كيف شوّه الـ Decompiler الـ struct

هون بيتجسد كلام القسم السابق. الـ Decompiler ما استرجع struct player، بل فتّتها:

C
char Destination[8];   // [rbp+10h] → name[50]
// الحقيقية، لكن بحجم 8 غلط

int v7;                // [rbp+44h] → health
int v8;                // [rbp+48h] → armor
int v9;                // [rbp+4Ch] → score

ثلاث ملاحظات وكلها نتيجة فقدان معلومات الأنواع:

  • health/armor/score صاروا v7/v8/v9 كمتغيرات مستقلة — الـ Decompiler ما إلو أي دليل إنهم حقول لبنية واحدة؛ شافهم ثلاث dwords منفصلة بإزاحات مختلفة، فعاملهم كذلك.

  • Destination أُعطي حجم 8 بايت وهو غلط — الحقيقي char[50]. السبب دلالي بحت: strcpy كتب 8 بايت بس ("Player1" + NULL)، فالبايتات الـ 42 الباقية من الـ array ما انلمست أبداً، والـ Decompiler ما بيخترع حجماً ما شافه مُستخدَماً.

  • id اختفى بالكامل — ولا سطر في المخرجات. لأنه (short عند rbp+66) ما انقرأ ولا انكتب في أي مكان، فبالنسبة للـ Decompiler هو بايتات مجهولة ضمن الفراغ، لا متغيّر.

الحقلالإزاحة داخل الـ structالعنوانالدليل من الـ Assembly
name[50]0rbp+16lea rcx, [rbp+130h+Destination] قبل strcpy
id (short)50rbp+66لا يوجد وصول
health (int)52rbp+68mov [rbp+68], 100
armor (int)56rbp+72mov [rbp+72], 33
score (int)60rbp+76mov [rbp+76], 1

البيانات على الـ Stack ليست إلا بايتات متجاورة تُعنون بإزاحات من نقطة مرجعية — هون rbp (المثبّت عند rsp+0x20)، ويمكن أن تكون rsp مباشرة في بناءات أخرى. تجاوُر الـ 64 بايت في الذاكرة موجود فعلاً، لكن كَوْنها struct player بخمسة حقول مقابل كونها خمسة متغيرات منفصلة هو قرار نتّخذه إحنا ونحقنه في الـ Decompiler. كيف نميّز الـ array عن الـ struct، وكيف نعيد بناء الحقول ونطبّقها، هو موضوع 1.4 وما بعده.

وايضا من الغير السهل على التعرف على الـ structs الموجودة فقط في scope في الغالب الهياكل تتعامل بشكل اوسع من ذالك بكثير وتتوزع من خلال pointers واكثر من دالة تستخدمها هذا يعتمد على استنتاجك الخالص لمنطق الكود وايضا ان اعتبرتها struct or local variables لن يختلف الموضوع بهذا النطاق .

Image
C
struct player_reconstructed
{
    BYTE  field_0[52];   // rbp+16   → فعلياً name[50] + id(short)
    DWORD field_52;      // rbp+68   → health
    DWORD field_56;      // rbp+72   → armor
    DWORD field_60;      // rbp+76   → score
};                       // sizeof = 64

1.3 مفهوم الـ Data Alignment (المحاذاة)

قاعدة الـ Self-Alignment

كل نوع إله متطلّب محاذاة (alignment) يساوي حجمه عادةً — وهذا اسمه self-alignment:

على منصّة ذات أنواع self-aligned، مصفوفات char/short/int/long/pointer ما فيها padding داخلي لأن كل عنصر يقع محاذىً تلقائياً. القيم المعتادة على معمارية 64-bit:

النوعالحجمالمحاذاة
char11
short22
int44
long long / pointer88

عمليا: عنوان المتغيّر لازم يكون من مضاعفات محاذاته (مثلاً int عند عنوان يقبل القسمة على 4). السبب للاداء: لما الكود يصل لعنوان غير محاذي في اغلب الاحيان المشكله تمر بسلام وفقط مشكله بالاداء اما في حالات اخرى قد يُطلق الـ misaligned load.

قواعد Syntax الـ struct

في C عنوان الـ struct هو نفسه عنوان أول عضو فيها — ما في leading padding. وعموماً، نسخة الـ struct تأخذ محاذاة أوسع عضو scalar فيها. وعليه :

Internal padding — بايتات تُحشى قبل عضو ليقع على محاذاته. هذا الـ padding الداخلي بحيث يكون العنصر التالي محاذى حسب نوعه.

محاذاة الـ struct = محاذاة أكبر عضو (وهذا موضوع "اعدة أكبر عنصر من الـ index).

Trailing padding — يُدوّر الحجم الكلي لمضاعف محاذاة الـ struct. الـ trailing padding بحيث يكون حجم الـ struct كاملةً مضاعفاً للعنصر ذي أكبر متطلّب محاذاة، والغاية إنه لو حوت الـ struct نوعاً بحجم 8 بايت، فحجمها الكلي لازم يكون مضاعف 8؛ ولو أكبر نوع 4 بايت، فالحجم مضاعف 4. وهذا مهم عشان كل عنصر في array من هالـ struct يظل محاذىً.

بايتات الـ padding قيمها غير محدّدة (أي قيمة ممكنة) لكن هي صفريه.

مثلا ليش struct player خالية من الـ padding

نطبّق القواعد على الـ struct تبعتنا، حقلاً حقلاً:

C
name[50]  align=1    offset 0   (بايت 0..49)
id short  align=2    offset 50  (50 زوجي  محاذى، صفر pad)  بايت 50..51
int health align=4   offset 52  (= 0  محاذى، صفر pad) بايت 52..55
int armor  align=4   offset 56
int score  align=4   offset 60  (بايت 60..63)

محاذاة الـ struct = 4 (أوسع عضو int)، والحجم 64، و64 % 4 = 0 → صفر trailing padding كمان.

النتيجة: صفر padding في كل الـ struct. وهذا بالضبط ليش الـ offsets اللي لاحظناها في الـ Assembly كانت متلاصقة تماماً (id عند rbp+66، health عند rbp+68، بلا أي فجوة).

صدفة سببها إن حجم name زوجي (50) و52 محاذى أصلاً لـ 4. لو كان name[49] بدل name[50]، لظهر بايت padding واحد قبل id، وتغيّرت كل الـ offsets اللاحقة.

مثال توضيحي يُظهر الـ padding :

C
struct demo { 
char a; 
int b; 
char c; 
};   // sizeof = 12
C
    printf("offsetof demo.a (char) %#x\n", offsetof(struct demo,a));
    printf("offsetof demo.b (int) %#x\n", offsetof(struct demo,b));
    printf("offsetof demo.c (char) %#x\n", offsetof(struct demo,c));


    printf("Size of Demo Struct : %#x\n", sizeof(demo));


//offsetof demo.a (char) 0
//offsetof demo.b (int) 0x4
//offsetof demo.c (char) 0x8
//Size of Demo Struct : 0xc

قد تتوقّع الحجم 1+4+1 = 6 بايت، لكنه بشكل افتراضي يكون 12 بايت؛ الـ Compiler يضيف padding عشان العضو int (b) يبدأ عند عنوان مضاعف لـ 4. ومجرّد إعادة الترتيب توفّر مساحة — لو بدّك تخلّي المتغيّرات تاخد مساحة أقل، بتحقّق هذا بمبادلة الترتيب. شوف الفرق:

C
struct demo { 
int a; 
char b; 
char c; 
};   // sizeof = 8
C
    printf("offsetof demo.a (int) %#x\n", offsetof(struct demo,a));
    printf("offsetof demo.b (char) %#x\n", offsetof(struct demo,b));
    printf("offsetof demo.c (char) %#x\n", offsetof(struct demo,c));


    printf("Size of Demo Struct : %#x\n", sizeof(demo));




//offsetof demo.a (int) 0
//offsetof demo.b (char) 0x4
//offsetof demo.c (char) 0x5
//Size of Demo Struct : 0x8

بالأعلى: a عند 0، ثلاث بايتات internal padding (1–3) عشان b يوقع على مضاعف 4، ثم c عند 8، وثلاث بايتات trailing padding (9–11). بالأسفل، مجرّد تقديم int b ألغى الـ internal padding وخلّى الحجم 8. عمليا: ضع الأنواع الأكبر قبل الأصغر، وتجنّب وضع عضو صغير بين عضوين كبيرين.

الأهمية للمهندس العكسي

المحاذاة مش اشئ بنمر عليه مرور الكرام هي منهجية تنبّؤ بتستعملها وأنت بتعيد البناء:

  • الفجوات في الـ offsets ليست مش عشوائية. لو لاحظت وصولاً عند +0 و+8 وما في شي بينهم، هاي الفجوة إما padding متوقّع أو حقل ما شفت وصولاً إله. لو الحقل عند +8 لازم يكون 8-محاذى، فالفجوة padding منطقي؛ لو لأ، فغالباً في حقل مخفي (زي id تبعنا).

  • استنتاج النوع من الـ offset. حقل مجبَر يقع على محاذاة 8 (offset من مضاعفات 8 وقبله فجوة) غالباً pointer أو long long أو double. المحاذاة بتضيّق احتمالات النوع.

  • لو بنيت الـ struct متجاهلاً الـ padding، حجمها الكلي راح يطلع غلط، وبالتالي في array of structs راح يكون الـ stride (المسافة بين عنصر وعنصر) غلط، وكل عنصر بعد الأول راح تنقرأ حقوله من مواقع مزاحة. هون بالضبط بيظهر دور الـ trailing padding.

  • الـ Packed structs. المحاذاة الافتراضية مش مضمونة دايماً. الـ packed structures ميزة بتخلّي الكود أبطأ، وعلى بعض المعماريات ممكن تسبّب كراش البرنامج . إذا لاحظت إن الـ offsets المُلاحَظة ما بتطابق المحاذاة الافتراضية (حقول متلاصقة بلا padding متوقّع)، فغالباً الـ Binary مبني بـ

  1. #pragma pack

  2. /Zp

  3. Assembly
    __declspec(align)
    

مثال على ذالك :

C
typedef struct demo {
    int a;
    char b;
    char c;
}demo;  




typedef struct demo2
{
    char a;
    int b;
    char c;
}demo2;

...
    demo d1;
    demo2 d2;


    d1.a = 'A';	
    d1.b = INT_MAX;
    d1.c = CHAR_MAX;








    d2.a = 'B';
    d2.b = _I16_MAX;
    d1.c = 'C';
Image
Assembly
mov     dword ptr [rbp+8], 41h ; 'A'
mov     byte ptr [rbp+12], 0FFh
mov     byte ptr [rbp+13], 7Fh
mov     byte ptr [rbp+40], 42h ; 'B'
mov     dword ptr [rbp+44], 7FFFh
mov     byte ptr [rbp+13], 43h ; 'C'

لاحظ المواقع المختلفة بينهم .

وهذا الـ demo2 :

Assembly
-00000000000000F8     _BYTE var_F8;
-00000000000000F7     // padding byte
-00000000000000F6     // padding byte
-00000000000000F5     // padding byte
-00000000000000F4     _DWORD var_F4;
-00000000000000F0     // padding byte
-00000000000000EF     // padding byte
-00000000000000EE     // padding byte
-00000000000000ED     // padding byte
-00000000000000EC     // padding byte
-00000000000000EB     // padding byte


لم يستطع استنتاج الـ member3 	char c;

واعتبره padding .

1.4 التعرّف على الـ Arrays في الـ Assembly

Image
C
   int numbers[5] = { 10, 20, 30, 40, 50 };
    numbers[0] = 100;
    numbers[1] = 200;




    char name[] = "AA";
    name[0] = 'B';
    name[1] = 'B';
    name[2] = '\0';

المثال فيه array عددهم 2 — int numbers[5] و char name[] — وهنّ جيدين لإظهار الـ array. خليني أول إشي أرسم الشكل اللي بتاخده الـ arrays بالذاكرة، لأن التجاور والـ stride الموحّد هما يسمح لك تميزهم:

‏العنوان = base + (index × scale)

الملاحظة: عناصر متلاصقة (contiguous) بحجم موحّد، والمسافة بين عنصر وعنصر (الـ stride) ثابتة وتساوي حجم العنصر — 4 للـ int، 1 للـ char. هذا واضح ما بدها تفكير هو الـ array.

الوصول من خلال base + index × scale

لازم أوضّح: هذا الـ (Compiler) ما استعمل صيغة الـ SIB الـ [rbp+rax*4] (اللي عامل الـ scale مُرمّز فيها داخل التعليمة)، بل جسّد عامل الـ scale صراحةً عبر ضرب منفصل لوحدة كما هو pattern كومبايلر MSVC:

Assembly
mov     eax, 4              ; scale = sizeof(int) = 4
imul    rax, 0              ; rax = 4 × 0 = 0   (index = 0)
mov     [rbp+rax+120h+var_118], 64h   ; [rbp+rax+8] -> [rbp+0+8] = numbers[0] = 100
mov     eax, 4
imul    rax, 1              ; rax = 4 × 1 = 4   (index = 1)
mov     [rbp+rax+120h+var_118], 0C8h  ; [rbp+4+8] = [rbp+12] = numbers[1] = 200

var_118 قاعدة الـ array عند rbp+8

التعليمة

Assembly
imul rax, index

(وهي هنا الصيغة المختصرة لثلاثية المعاملات imul rax, rax, imm) بتحسب 4 × index، وبعدين

Assembly
[rbp + rax + 8].
Assembly
index 0  rax = 0  -> rbp+8  -> numbers[0] = 0x64 = 100.
Assembly
index 1  rax = 4 -> rbp+12  -> numbers[1] = 0xC8 = 200.

صيغتان ([rbp+rax*4] الـ SIB و imul الصريح) بتعبّرا عن نفس الشئ: base + index × scale. الفرق العملي: صيغة الـ SIB محصورة بـ {1,2,4,8} اما التعبير عنها بشكل صريح اكثر مرونه .

والأهم: عامل الـ scale بيكشف حجم العنصر مباشرةً. شفت mov eax, 4 قبل ضرب index العنصر 4 بايت( int). وبنفس المنطق للـ char array:

Assembly
mov     eax, 1              ; scale = sizeof(char) = 1
imul    rax, 1              ; rax = 1 × 1 = 1
mov     [rbp+rax+120h+var_EC], 42h    ; [rbp+53] = name[1] = 'B'

تهيئة الـ arrays

  • numbers: خمس كتابات متلاصقة بـ stride موحّد 4 بايت

Assembly
(0Ah, 14h, 1Eh, 28h, 32h = 10, 20, 30, 40, 50)

هذا التتابع المنتظم نفسه array.

Assembly
mov     dword ptr [rbp+8], 0Ah
mov     dword ptr [rbp+12], 14h
mov     dword ptr [rbp+16], 1Eh
mov     dword ptr [rbp+20], 28h ; '('
mov     dword ptr [rbp+24], 32h ; '2'

خريطة الـ Stack

Numbers[5]

عند rbp+8 (يمتد لـ rbp+27، 20 بايت)، و

Name[3]

عند rbp+52، و var_18 (qword عند rbp+264) هو الـ /GS cookie. نقطة صدق: في فجوة بين نهاية numbers (rbp+28) وبداية name (rbp+52) مقدارها 24 بايت.

Assembly
mov     eax, 4
imul    rax, 0
mov     dword ptr [rbp+rax+8], 100
mov     eax, 4
imul    rax, 1
mov     [rbp+rax+120h+var_118], 200
lea     rax, [rbp+52]
lea     rcx, unk_140019C10
mov     rdi, rax
mov     rsi, rcx
mov     ecx, 3
rep movsb
;.rdata:0000000140019C10 unk_140019C10   db  41h ; A             ; DATA XREF: ;sub_1400154D0+88↑o
;.rdata:0000000140019C11                 db  41h ; A
;.rdata:0000000140019C12                 db    0
Assembly
mov     eax, 1
imul    rax, 0
mov     byte ptr [rbp+rax+52], 42h ; 'B'
mov     eax, 1
imul    rax, 1
mov     byte ptr [rbp+rax+52], 42h ; 'B'

موجودة بهذا الشكل على Decompiler :

C
 v7 = 30;
  v8 = 40;
  v9 = 50;
  v5 = 100;
  v6 = 200;
  strcpy(v10, "AA");
  memset(v10, 0x42, 2);

1.5 التعرّف على الـ Structs في الـ Assembly

هذه الـ struct على الـ heap (عبر malloc) ومُمرَّرة بمؤشر لدالة ايضا — وهذا بيبرز الفرق عن struct الـ stack في 1.2. خليني أرسم الشكل أول:

Image
Image

الكود المصدري :

C
typedef struct s1
{	
    char name[25];
    double height;
    int ID;
    short age;	
    
}s1;




void changename (s1 * person)
{
    person->age = 45;
    person->height = 174;
    person->ID = 9458581;
    strcpy(person->name,"Mhmd");
}




int main() {


    s1 * s;
    s = (s1*)malloc(sizeof(s1));




    s->age = 22;
    strcpy(s->name, "Aos");
    s->height = 173;
    s->ID = 123456789;


    changename(s);


}

قاعدة الهيكل صارت مؤشراً (Pointer)، مش rbp/rsp عن طريق الستاك :

في 1.2 كانت الـ struct على الـ Stack، والقاعدة هي rbp (إطار الدالة). هون الـ struct على الـ Heap، والقاعدة register يحمل مؤشراً (rcx/rax) يُحمَّل من متغيّر المؤشر قبل كل وصول. لاحظ إن الـ base register بيتغيّر (مرّة rcx مرّة rax) لأنه بيُعاد تحميله من المؤشر كل مرة (سلوك debug build). والنمط نفسه — عنوان القاعدة في register، وإزاحة ثابتة للحقل — هو ما توثّقه المصادر كنمذجة طبيعية للوصول للبُنى: الـ base + displacement بيمثّل دلالة البُنى، حيث الـ base register يحمل عنوان بداية البنية والـ displacement يحمل الإزاحة الثابتة للحقل داخلها.

Assembly
mov ecx, 48 ; Size 
call    cs:malloc
mov     [rbp+8], rax
mov     eax, 16h
mov     rcx, [rbp+8]
mov     [rcx+2Ch], ax
mov     rax, [rbp+8]

كما تلاحظ تم نقل الـ Address الى Local Variable موجود على الستاك rbp+8 بعدها نقله الى rcx ليكون هو الـ base النهائي .

malloc(48) sizeof(s1) = 48 حجمها 48 بايت

. والـ decompiler أكّدها: malloc (0x30u) (0x30 = 48).

الاعضاء/الحقول (Members): أحجام مختلفة عند offsets ثابتة (دليل على struct)

لكن عندما نمشي على قليلا نجد :

Assembly
mov     rcx, rax        ; Destination
.
mov     rax, [rbp+8]

تم تغيره بسبب شروط انه Volatile Register وتحول الان ليكون موجود في rax كما ذكرنا.

من مسار main، القاعدة (Base) + إزاحة ثابتة (Offset)، وبأحجام مختلفة:

Assembly
; 1 : 
; ax = 22
mov     [rcx+44], ax ; age  offset 44, WORD (2 بايت) 
; 2 : 
mov     rax, [rbp+8] 
lea     rdx, aAos       ; "Aos" 
mov     rcx, rax        ; Destination
call    j_strcpy ; name  offset 0, char array 




;3 : 
movsd   qword ptr [rax+32], xmm0 ; height offset 32, QWORD/double (8 بايت) 


; 4 : 
mov     dword ptr [rax+40], 123456789 ; ID offset 40, DWORD (4 بايت) 

Base+0    Base+31
Base+32  Base+39
Base+40  Base+43
Base+44  Base+47

أحجام مختلفة (2 و8 و4 بايت + array بايتات) عند offsets ثابتة من مؤشر واحد = struct.

قارنها بالـ array (حجم موحّد + index متغيّر) — هذا هو الفارق الحاسم.

النوع من نمط الاستخدام (Usage-based Type Inference)

ولا شي في البايتات بيقول نوع الحقل التعليمة بتقول:

  • movsd مع معامل XMM double. تعليمة MOVSD تنقل قيمة double-precision عائمة (كسرية) بين الـ low quadword لسجل XMM وموقع ذاكرة 64-bit.

  • word ptr/ax 2 بايت short.

  • dword ptr 4 بايت int.

  • strcpy على القاعدة char array.

الحقلoffsetالحجمملاحظة
name[25]025ينتهي عند 24
padding257حتى يقع double على مضاعف 8
height (double)32832 محاذى لـ 8
ID (int)404
age (short)442ينتهي عند 45
padding462trailing، لتدوير الحجم لـ 48

المجموع 48، محاذاة الـ struct = 8 (أوسع عضو double)، و9 بايت مهدورة padding.

قراءة المؤشرات في الـ Decompiler

استعمل صيغتين مختلفتين بتشيرا لنفس البايت بالضبط — لازم تعرف الفرق وإلا راح تحسب offsets غلط:

في main (المؤشر مُعرَّف char *) — حساب مؤشرات (scaled):

C
  *((_WORD *)Destination + 22) = 22;
  j_strcpy(Destination, "Aos");
  *((_QWORD *)Destination + 4) = 0x4065A00000000000LL;
  *((_DWORD *)Destination + 10) = 123456789;

هون الـ cast بيصير قبل الجمع، فالـ +N بوحدات نوع الـ cast: تضربها بحجم النوع لتطلّع الإزاحة بالبايت.

في changename (الوسيط مُعرَّف __int64 a1) — إزاحة بالبايت مباشرة:

C
*(_WORD  *)(a1 + 44) = 45;                       // byte 44 مباشرة  age
*(_QWORD *)(a1 + 32) = 0x4065C00000000000LL;     // byte 32  height
*(_DWORD *)(a1 + 40) = 9458581;                  // byte 40  ID

ليش الـ Decompiler ما استرجع الـ struct

عرّف المؤشر char *Destination و__int64 a1 مع حساب offsets بشكل مباشر، مش s1 *person. لأنه — كما في كل الأمثلة السابقة — معلومات النوع ضائعة. لتطلّع pseudocode نظيف زي person->age = 45، لازم تعرّف الـ struct وتطبّقها على المؤشر، وهذا موضوع سنتحدث به بعد هذه الاقسام.

1.6 ترجمة الـ Offsets إلى متغيرات فعلية

بعد ما تعرّفنا على أنماط الهياكل والمصفوفات، هاي المنهجية الكاملة لتحويل الـ offsets المُلاحَظة إلى تعريف struct فعلي يفهمه الـ Decompiler وانت ايضا. ثلاث خطوات متسلسلة.

الخطوة 1: بناء خريطة الحقول (Field Mapping)

اجمع كل وصول للذاكرة يشترك بنفس القاعدة (سواء rbp/rsp، أو مؤشر في register)، وسجّل لكل واحد ثلاثة أشياء — الـ offset، وحجم الوصول (من الـ size specifier: byte/word/dword/qword ptr)، والتعليمة/السياق. بعدها رتّبها تصاعدياً حسب الـ offset. نطبّقها على s1 من 1.5:

offsetالتعليمة الملاحظةحجم الوصول
0strcpy على القاعدةarray (غير محدد بعد)
32movsd [..], xmm08 بايت
40mov dword ptr [..], imm4 بايت
44mov [..], ax2 بايت
malloc(48)الحجم الكلي = 48

الـ malloc هون اكبر هديه واثبات: بيثبّت الحجم الكلي، فبتعرف حدود الـ struct من فوق. (وعلى الـ Stack، الحدّ العلوي بتاخده من أول متغيّر مجاور.)

الخطوة 2: استنتاج النوع من طريقة الاستخدام (Usage-based Type Inference)

بدون الـ source، الـالعليمة نفسه هو معلومة النوع. الجدول بعد التحويل:

نمط الاستخدامالنوع المُستنتَجالدليل
byte ptrchar / bool / uint8حجم الوصول 1
word ptr (مثل ax)16-bit (short)حجم الوصول 2
dword ptr32-bit (int / DWORD)حجم الوصول 4
qword ptrصحيح 64-bitحجم الوصول 8
لاحقة (movss) على XMM(4 بايت) floatتعليمة SSE single
لاحقة (movsd) على XMM(8 بايت) doubleتعليمة SSE double
qword يُحمّل ثم يُستعمل عنواناً (dereference)pointerنمط base register
هدف strcpy / rep movsbchar[]نسخ نصّي

وللتمييز بين signed و unsigned — وهي نقطة يغفلها كتير — لاحظ تعليمة التوسيع:

  • movsx (sign-extend) نوع signed؛ يُملأ الجزء العلوي بالـ sign bit.

  • movzx (zero-extend) نوع unsigned؛ يُملأ بالأصفار.

الخطوة 3: التعامل مع الـ Padding والفجوات

أصعب حُكم

لمّا تلاقي فجوة بين offsets

مُلاحَظة، عندك ثلاثة احتمالات:

(أ) padding من المحاذاة،

(ب) حقل موجود بس غير مستخدم فما تركش بصمة (زي id في player

(ج) حقل ما لاحظته بعد.

وللتفريق:

أي Member له متطلب محاذاة بنسميه A مثلا int = 4 او double = 8

مستحيل الـ compiler يضيف padding حجمه يساوي A او اكبر لضبط المحاذاه مش منطقي لانه كان محاذي.

اذن اقصى شيء يمكن ان يكون MaxPadding = A - 1

الفجوة Gap G

الـ internal padding قبل حقل محاذاته A لا يتجاوز A−1 بايت

. فإذا كانت الفجوة

G≤A−1G ≤ A−1

padding بالكامل.

وإذا كانت

G>A−1G > A−1

جزء منها حقل فعلي، مش padding.

قارن مثالينا الحقيقيين:

  • s1: الفجوة بين نهاية name (offset 25) وبداية height (offset 32) = 7 بايت.

الحقل التالي double محاذاته 8، وأقصى padding = 7.

Name = 25

Height = 32

32 - 25 = 7 <= A

الفجوة = 7 بالضبط padding اكيد.

  • player: الفجوة اللي "ابتلعت" id كانت أكبر بكثير من A−1 (الـ Decompiler شاف 8 بايت بس من name ثم قفز لـ health).

فجوة بهذا الحجم لا يمكن أن تكون padding لازم فيها حقل فعلي (بقية name + id غير المستخدم).

احجز الفجوة كـ placeholder صريح (BYTE gap[G]) حتى لا تنزاح الحقول اللاحقة.

أي غلط بحجم أو padding بيزيح كل offset بعده، والـ struct بتنهار — خصوصاً في array of structs حيث الـ stride الغلط بيفسد كل عنصر بعد الأول.

النتيجة: الـ struct المُعاد بناؤها

بتطبيق الخطوات الثلاث على s1، بتطلّع بتعريف مطابق للأصل:

C
struct s1_reconstructed
{
    char   name[25];   // +0   (هدف strcpy → char[])
    // 7 بايت padding ضمني (25..31) = G=7 = A−1 للـ double
    double height;     // +32  (movsd → double)
    int    ID;         // +40  (dword → int)
    short  age;        // +44  (word/ax → short)
    // 2 بايت trailing padding (46..47) لتدوير الحجم لـ 48
};                     // sizeof = 48 = malloc

1.7 العمل مع الـ Decompiler (IDA)

هاي الخطوة الأخيرة والغاية من المقال كله: نأخذ الـ struct اللي أعدنا بناءها في 1.6 ونحقنها في IDA، فيعيد الـ Decompiler رسم الـ pseudocode بلغة الحقول بدل حساب الـ offsets بشكل صريح وجاف:

الطريق 1: تعريف يدوي ثم تطبيق (Local Types + Set type)

1) نافذة Local Types — تُفتح عبر ViewOpen subviewsLocal Types أو Shift+F1، وهي المركز الموحّد لإدارة تعريفات الأنواع داخل واجهة IDA؛ تضيف منها struct/union/enum عبر حوار "Add type" بصيغة C. نُدخل تعريف s1:

Image
Image
Image
Image
C
struct s1
{
    char   name[25];
    double height;   // IDA بيحسب الـ 7 بايت padding تلقائياً
    int    ID;
    short  age;
};                   // sizeof = 48
Image

2) الأمر Set type — تطبّقه على المؤشر/الوسيط لتجعله s1 *. الأمر بياخذ إعلان C، وبيقدر يستعمل كل الأنواع المُعرَّفة في Local Types ومكتبات الأنواع المحمّلة، وهو أمر قوي جداً يغيّر المخرجات جذرياً، ويُستعمل لإزالة عمليات الـ cast من المخرجات وجعلها أقرأ، وأحياناً يلزم تعريف الـ struct في Local Types أولاً ثم تطبيقها في نافذة الـ pseudocode. (ملاحظة أمانة: الاختصار لهذا الأمر هو Y)

Image
Image

المصادر :

[0] Microsoft، x64 calling convention (learn.microsoft.com/cpp/build/x64-calling-convention) و x64 ABI conventions.

[1] Microsoft، /RTC (Run-time error checks) ومقالة Bugslayer (MSDN Magazine).

[2] Microsoft، /JMC (Just My Code debugging)

[3] Eric S. Raymond، The Lost Art of Structure Packing (catb.org/esr/structure-packing).

[4] C99 standard analysis (embeddedmonologue) ومواصفة C99 6.2.6.1.

[5] GeeksforGeeks و W3Schools.

[6] Microsoft، x64 ABI conventions و /RTC.

[7] Alexander Obregon، C Structs and Memory Alignment.

[8] ModR/M، SimplifyC++، OSDev Wiki، و Learning x86-64 Disassembler (Medium).

[9] ENOSUCHBLOG How x86_64 addresses memory UMaine COS335.

[10] Microsoft /RTC

[11] Yen Wang، Data Representation in C: Signedness, Sizes, and Terminology (Medium).

[12] c-jump Sign Extending with MOVSX and MOVZX و felixcloutier MOVZX.

[13] felixcloutier/Intel SDM، Wikipedia SIMD list، Eric Raymond Structure Packing، و ENOSUCHBLOG.

للتنويه: جميع ما اكتب انا من كتبته و تم استخدام نماذج الذكاء الاصطناعي (AI) لمراجعة هذا النص وتصحيح الأخطاء اللغوية فيه فقط