לדלג לתוכן

מה זה יישור זיכרון, ולמה מבנה ב-C גדול ממה שחשבתם

אחד הרגעים המבלבלים ביותר עבור מי שמתחיל לנתח בינארי הוא לגלות שגודל מבנה (struct) בפועל בזיכרון גדול מסכום הגדלים של השדות שבתוכו. זה לא באג, זה תופעה שנקראת יישור זיכרון - Memory Alignment, וההבנה שלה הכרחית כשמחשבים offsets מדויקים בניצול חולשות.

למה בכלל צריך יישור

מעבדים לא קוראים זיכרון בייט אחר בייט - הם קוראים אותו בבלוקים בגודל קבוע, שנקבע לפי רוחב הבוס של הזיכרון (למשל 4 או 8 בייטים). כשמשתנה יושב בדיוק בגבול של אחד הבלוקים האלה, המעבד יכול לקרוא אותו בפעולה אחת. אבל אם המשתנה "פרוש" על פני שני בלוקים כאלה, כי הוא לא מיושר נכון, המעבד צריך לבצע שתי פעולות קריאה נפרדות ולאחות אותן, מה שאיטי משמעותית - ובחלק מהארכיטקטורות, גישה לא מיושרת אפילו אסורה לחלוטין וגורמת לקריסה.

בגלל זה, קומפיילרים דואגים ליישר כל משתנה לגבול המתאים לגודל שלו - למשל, משתנה מסוג int בן 4 בייטים יושב תמיד בכתובת שמתחלקת ב-4.

מה זה אומר בפועל למבנה - struct

הבעיה מתחילה כשיש לכם מבנה עם שדות בגדלים שונים. קחו לדוגמה מבנה עם char (בייט אחד) ואחריו int (ארבעה בייטים). אם הקומפיילר היה שם אותם צמוד לצמוד, ה-int היה מתחיל בכתובת שלא מתחלקת ב-4, וזה בעייתי. לכן הקומפיילר מוסיף "ריפוד" (padding) - שלושה בייטים ריקים אחרי ה-char, כדי שה-int שאחריו יתחיל בכתובת מיושרת נכון.

התוצאה - מבנה שנראה כאילו הוא צריך לתפוס חמישה בייטים (1 + 4) בפועל תופס שמונה בייטים בזיכרון, בגלל שלושת בייטי הריפוד הנסתרים. סדר השדות במבנה משפיע ישירות על כמה ריפוד יתווסף - מהנדסים שמכוונים לביצועים לפעמים מסדרים מחדש שדות במבנה, מהגדול לקטן, כדי לצמצם כמה שאפשר את הריפוד המבוזבז.

למה זה קריטי בעולם ניצול החולשות

כשחוקר חולשות מנתח מבנה נתונים בזיכרון - למשל כדי לחשב את ה-offset המדויק מתחילת buffer ועד לשדה מסוים שהוא רוצה לדרוס - חובה להביא בחשבון את הריפוד הזה. אם מתעלמים מיישור זיכרון ומניחים שהשדות פשוט צמודים אחד לשני, החישוב יהיה שגוי, וכתובת ההחזרה או המצביע שרציתם לדרוס פשוט לא יהיו במקום שחשבתם.

זה רלוונטי במיוחד בניתוח מבני חתיכות זיכרון (chunks) ב-heap, שבהם כל בייט חשוב, וגם בהנדסה לאחור של פורמטי קבצים או פרוטוקולים בינאריים, שבהם צריך להבין בדיוק איך מבנה נתונים מסודר בזיכרון כדי לפרש אותו נכון.

איך בודקים את זה בפועל

הדרך הכי בטוחה לוודא את גודל וסידור מבנה בפועל היא להשתמש בפעולת sizeof בקוד עצמו, או פשוט לצפות בזיכרון תחת debugger ולראות איפה כל שדה באמת יושב. אפשר גם להשתמש בהנחיית קומפיילר כמו __attribute__((packed)) בשפת C, שמבטלת את הריפוד לגמרי - אבל זה בא במחיר של ביצועים, ולכן משתמשים בזה רק כשצריך פורמט נתונים מדויק בבייט הבודד, כמו בפרוטוקולי רשת.

איך זה מתחבר לשאר הידע

יישור זיכרון הוא דוגמה טובה לפער בין "מה שכתבתם בקוד" לבין "מה שבאמת קורה בזיכרון" - פער שכל חוקר חולשות רציני צריך להיות מודע אליו כל הזמן. הוא לא נושא מרכזי בפני עצמו, אבל הוא מסביר תופעות מבלבלות שנתקלים בהן שוב ושוב בניתוח מבני נתונים בזיכרון.

בקורס מחקר חולשות אנחנו מתייחסים ליישור זיכרון בכל פעם שמחשבים offsets בתרגילים מעשיים, כדי שההרגל הזה ייכנס לדם מההתחלה.

חישבתם offset וזה פשוט לא מסתדר עם מה שאתם רואים ב-debugger? כנראה שיישור זיכרון הוא החשוד הראשון, ובדיסקורד שלנו אפשר לבדוק את זה ביחד.

הצטרפו לקהילה בדיסקורד

לסיכום

יישור זיכרון הוא לא רק פרט טכני משעמם, הוא הסיבה שגודל מבנה בזיכרון לא תמיד שווה לסכום הפשוט של השדות שבו. הבנה שלו היא מה שמפריד בין חישוב offset מדויק לבין ניצול שנכשל בלי סיבה ברורה לכאורה.