كيف ترجّع الـ Structs والـ Arrays المفقودة من الـ Assembly للـ Decompiler
1. Identifying structs and Arrays in Assembly
1.1 مقدمة: لماذا يهم التمييز بين البُنى؟
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 هي توليد كود آلة يعمل بأقصى كفاءة، لا الحفاظ على أسمائك ومفاهيمك. فأثناء الترجمة يحدث ما يلي:
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) للتعامل معه. النوع لم يعد مكتوباً صار مستنتَجاً من السلوك.

.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
// Decompiler
v7 = 100;
v8 = 33;
j_strcpy(Destination, "Player1");
if ( v7 && v8 )
v9 = 1;
v2 = v9;

النتيجة أن الـ Binary يحتفظ بالتخطيط (layout: أين يقع كل بايت) لكنه يفقد المعنى (semantics: ماذا يمثل كل بايت). ولهذا يظهر ناتج الـ Decompiler في البداية بهذا الشكل.
الكودان متكافئان تماماً على مستوى الآلة — نفس الـ instructions — لكن كود المصدر قابل للقراءة لأننا أعدنا حقن معلومات الأنواع التي ضاعت.
لماذا نستعيدها يدوياً؟
الـ Decompiler ذكي على هذه النطاق في إعادة بناء البنية المنطقية (loops، شروط، دوال)، لكنه بطبيعته محافِظ تجاه الأنواع؛ فبما أن معلومات الأنواع غير موجودة في الـ Binary، لا يستطيع اختراعها ، فيتراجع إلى الافتراض الأعمّ والأكثر شفافية:
Pointer إلى عدد + offset او اسمه الصريح كما في حالتنا .
هو يعرف أنه يصل إلى الذاكرة، لكنه لا يعرف أن هذه الكتلة هي struct player. تعريف الأنواع الصحيحة قرارٌ سياقيّ يحتاج بشر يفهم ماذا يفعل البرنامج، مش مجرد كيف يحرّك البايتات. وحين تُغذي الـ Decompiler بهذا التعريف — وهذا هو موضوع بقية هذا المقال— فإنه يعيد رسم كل شيء لك بلغة الحقول والأنواع بدل الإزاحات الخام.
1.2 كيفية تخزين البيانات في الـ Stack
الـ Prologue:
كل دالة تحجز مساحة على الـ Stack لازم تعمل مقدّمة (prologue) تُهيّئ الإطار. خلينا نفكّك مقدّمة دالتنا:
لناخذ اولا الكود الكامل المولد :

.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
__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 و R12–R15 و XMM6–XMM15 تُعتبر 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:
.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)؛ والهدف إن أي قراءة من متغير غير مُهيّأ تطلع قيمة سهلة الملاحظة بدلا من الاصفر.
v0 = &v5;
for ( i = 26; i; --i )
{
*(_DWORD *)v0 = 0xCCCCCCCC;
v0 += 4;
}
2) الـ Stack cookie (Canary):
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:
lea rcx, byte_14002100E
call sub_14001138E
هذا sub_14001138E هو __CheckForDebuggerJustMyCode تبع الـ /JMC. لما يكون /JMC مُفعّل، الـ Compiler بيحقن استدعاءات لدالة مساعدة اسمها __CheckForDebuggerJustMyCode .
فك ترميز notation الخاص بـ IDA Pro :
[rbp+130h+var_X]
الـ IDA ما بتعرض الإزاحة بشكل صريح افتراضيا بتعرّف لكل متغير رمز مثل (var_EC) بإزاحة سالبة عن قاعدة افتراضية، وبتضيف قيمة الـ frame-pointer delta (0x130 هون) للتوفيق. المعادلة:
بالوضع الافتراضي يكون بهذا الشكل :

.text:0000000140011750 var_EC = dword ptr -0ECh
العنوان الفعلي = rbp + 0x130 + var_EC يمكنك حسابه بشكل سريع من خلال الضغط على K ويتحول مباشره .
مثال ، حقل health (كما نعرفه مسبقا) (المسمّى var_EC):
var_EC = -0xEC
rbp + 0x130 + (-0xEC) = rbp + 0x44 = rbp + 68
وهذا بالضبط اللي بتشوفه بالـ instruction هو:
mov dword ptr [rbp+68], 100
سبب هذا التحايل إن الـ IDA بتحبّ تعرض المتغيرات المحلية كإزاحات سالبة عن قاعدة أعلى الإطار (عشان تطابق النموذج الشائع)، مع إن الترميز الحقيقي هنا [rbp+موجب] .
ان قمنا بتتبع هنا :
.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 بايت مبدئيا :
BYTE Unknown_array[52]
او كما نعلم هو الـ name ومساحته 50.
وبعدها من rbp+68 الى rbp+72 بمعنى 4 بايتات مخزن عن بداية rbp+68
بناء على نمط الاستخدام هو integer سنقوم بتسمية :
DWORD Unknown_Variable ;
ومن rbp+72 الى rbp+76 ايضا 4 بايتات :
DWORD Unknown_Variable2 ;
لكن ملاحظة لا يوجد دليل على member id المتواجد ؟ بكل اختصار نحن لا نعلم اين هو هل هو مستخدم ؟ لا هل يوجد اي وصول له هنا ؟ لأ هل يوجد اي تعامل مع هذا الهيكل في اي مكان اخر ؟ لأ اذن لا نستطيع ان نحدد ما هذا لذالك بالوضع الطبيعي لن نعلم الا بحالة وحدة سنتكلم عليها بهذا المقال لكن بناء على معرفتنا في الكود المصدري سيكون بهذا الشكل :
كيف شوّه الـ Decompiler الـ struct
هون بيتجسد كلام القسم السابق. الـ Decompiler ما استرجع struct player، بل فتّتها:
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] | 0 | rbp+16 | lea rcx, [rbp+130h+Destination] قبل strcpy |
id (short) | 50 | rbp+66 | لا يوجد وصول |
health (int) | 52 | rbp+68 | mov [rbp+68], 100 |
armor (int) | 56 | rbp+72 | mov [rbp+72], 33 |
score (int) | 60 | rbp+76 | mov [rbp+76], 1 |
البيانات على الـ Stack ليست إلا بايتات متجاورة تُعنون بإزاحات من نقطة مرجعية — هون rbp (المثبّت عند rsp+0x20)، ويمكن أن تكون rsp مباشرة في بناءات أخرى. تجاوُر الـ 64 بايت في الذاكرة موجود فعلاً، لكن كَوْنها struct player بخمسة حقول مقابل كونها خمسة متغيرات منفصلة هو قرار نتّخذه إحنا ونحقنه في الـ Decompiler. كيف نميّز الـ array عن الـ struct، وكيف نعيد بناء الحقول ونطبّقها، هو موضوع 1.4 وما بعده.
وايضا من الغير السهل على التعرف على الـ structs الموجودة فقط في scope في الغالب الهياكل تتعامل بشكل اوسع من ذالك بكثير وتتوزع من خلال pointers واكثر من دالة تستخدمها هذا يعتمد على استنتاجك الخالص لمنطق الكود وايضا ان اعتبرتها struct or local variables لن يختلف الموضوع بهذا النطاق .

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:
| النوع | الحجم | المحاذاة |
|---|---|---|
char | 1 | 1 |
short | 2 | 2 |
int | 4 | 4 |
long long / pointer | 8 | 8 |
عمليا: عنوان المتغيّر لازم يكون من مضاعفات محاذاته (مثلاً 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 تبعتنا، حقلاً حقلاً:
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 :
struct demo {
char a;
int b;
char c;
}; // sizeof = 12
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. ومجرّد إعادة الترتيب توفّر مساحة — لو بدّك تخلّي المتغيّرات تاخد مساحة أقل، بتحقّق هذا بمبادلة الترتيب. شوف الفرق:
struct demo {
int a;
char b;
char c;
}; // sizeof = 8
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مبني بـ
#
pragmapack/
Zp__declspec(align)
مثال على ذالك :
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';

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 :
-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

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:
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
التعليمة
imul rax, index
(وهي هنا الصيغة المختصرة لثلاثية المعاملات imul rax, rax, imm) بتحسب 4 × index، وبعدين
[rbp + rax + 8].
index 0 rax = 0 -> rbp+8 -> numbers[0] = 0x64 = 100.
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:
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 بايت
(0Ah, 14h, 1Eh, 28h, 32h = 10, 20, 30, 40, 50)
هذا التتابع المنتظم نفسه array.
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 بايت.
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
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 :
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. خليني أرسم الشكل أول:


الكود المصدري :
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 يحمل الإزاحة الثابتة للحقل داخلها.
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)
لكن عندما نمشي على قليلا نجد :
mov rcx, rax ; Destination
….
mov rax, [rbp+8]
تم تغيره بسبب شروط انه Volatile Register وتحول الان ليكون موجود في rax كما ذكرنا.
من مسار main، القاعدة (Base) + إزاحة ثابتة (Offset)، وبأحجام مختلفة:
; 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/ax2 بايتshort.dword ptr4 بايتint.strcpyعلى القاعدةchar array.
| الحقل | offset | الحجم | ملاحظة |
|---|---|---|---|
name[25] | 0 | 25 | ينتهي عند 24 |
padding | 25 | 7 | حتى يقع double على مضاعف 8 |
height (double) | 32 | 8 | 32 محاذى لـ 8 |
ID (int) | 40 | 4 | |
age (short) | 44 | 2 | ينتهي عند 45 |
padding | 46 | 2 | trailing، لتدوير الحجم لـ 48 |
المجموع 48، محاذاة الـ struct = 8 (أوسع عضو double)، و9 بايت مهدورة padding.
قراءة المؤشرات في الـ Decompiler
استعمل صيغتين مختلفتين بتشيرا لنفس البايت بالضبط — لازم تعرف الفرق وإلا راح تحسب offsets غلط:
في main (المؤشر مُعرَّف char *) — حساب مؤشرات (scaled):
*((_WORD *)Destination + 22) = 22;
j_strcpy(Destination, "Aos");
*((_QWORD *)Destination + 4) = 0x4065A00000000000LL;
*((_DWORD *)Destination + 10) = 123456789;
هون الـ cast بيصير قبل الجمع، فالـ +N بوحدات نوع الـ cast: تضربها بحجم النوع لتطلّع الإزاحة بالبايت.
في changename (الوسيط مُعرَّف __int64 a1) — إزاحة بالبايت مباشرة:
*(_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 | التعليمة الملاحظة | حجم الوصول |
|---|---|---|
| 0 | strcpy على القاعدة | array (غير محدد بعد) |
| 32 | movsd [..], xmm0 | 8 بايت |
| 40 | mov dword ptr [..], imm | 4 بايت |
| 44 | mov [..], ax | 2 بايت |
| — | malloc(48) | الحجم الكلي = 48 |
الـ malloc هون اكبر هديه واثبات: بيثبّت الحجم الكلي، فبتعرف حدود الـ struct من فوق. (وعلى الـ Stack، الحدّ العلوي بتاخده من أول متغيّر مجاور.)
الخطوة 2: استنتاج النوع من طريقة الاستخدام (Usage-based Type Inference)
بدون الـ source، الـالعليمة نفسه هو معلومة النوع. الجدول بعد التحويل:
| نمط الاستخدام | النوع المُستنتَج | الدليل |
|---|---|---|
byte ptr | char / bool / uint8 | حجم الوصول 1 |
word ptr (مثل ax) | 16-bit (short) | حجم الوصول 2 |
dword ptr | 32-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 movsb | char[] | نسخ نصّي |
وللتمييز بين 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(offset25) وبدايةheight(offset32) = 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، بتطلّع بتعريف مطابق للأصل:
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 — تُفتح عبر View → Open subviews → Local Types أو Shift+F1، وهي المركز الموحّد لإدارة تعريفات الأنواع داخل واجهة IDA؛ تضيف منها struct/union/enum عبر حوار "Add type" بصيغة C. نُدخل تعريف s1:




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

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


المصادر :
[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) لمراجعة هذا النص وتصحيح الأخطاء اللغوية فيه فقط
