5.11 פורמט ELF לעומק הרצאה
הקדמה¶
בפרק 5.3 למדנו כיצד הloader מזהה קבצי ELF וטוען אותם לזיכרון - ראינו את הELF header, את הprogram headers (סגמנטים), ואת התהליך שהקרנל עובר כדי להריץ תוכנית.
בפרק 5.10 למדנו על ספריות משותפות, ואיך מנגנוני PLT ו-GOT מאפשרים קריאה לפונקציות מספריות חיצוניות בזמן ריצה.
עכשיו הגיע הזמן להבין את פורמט הELF לעומק - לא רק מה הקרנל צריך כדי לטעון את הקובץ, אלא את כל המידע שנמצא בפנים. הידע הזה הוא בסיס קריטי להנדסה לאחור (reverse engineering), כתיבת exploit-ים, דיבוג, ובעצם להבנה אמיתית של מה שקורה כשאנחנו מקמפלים ומריצים תוכנית.
שתי נקודות מבט על קובץ ELF - linking view vs execution view¶
נקודה חשובה שכבר נגענו בה ב-5.3 אבל עכשיו נרחיב: לקובץ ELF יש שתי דרכים להסתכל על התוכן שלו:
נקודת המבט של הלינקר - linking view (סקשנים)
הלינקר (ld) צריך לדעת בדיוק מה כל חלק בקובץ מכיל - איפה הקוד, איפה הנתונים, איפה טבלת הסמלים, איפה מידע הrelocation. לשם כך הוא משתמש בסקשנים (sections) שמוגדרים ב-section header table.
נקודת המבט של הקרנל - execution view (סגמנטים)
הקרנל לא מתעניין בסקשנים. הוא צריך לדעת דבר אחד: מה לטעון לזיכרון ועם אילו הרשאות. לשם כך הוא משתמש בסגמנטים (segments) שמוגדרים ב-program header table.
+------------------+
| ELF Header |
+------------------+
| Program Headers | <-- הקרנל משתמש בזה (סגמנטים)
| (segments) |
+------------------+
| |
| תוכן הקובץ |
| (קוד, נתונים, |
| סמלים...) |
| |
+------------------+
| Section Headers | <-- הלינקר משתמש בזה (סקשנים)
| (sections) |
+------------------+
שימו לב - סגמנט אחד יכול להכיל מספר סקשנים. לדוגמה, סגמנט הקוד (PT_LOAD עם הרשאות R-X) מכיל בתוכו את הסקשנים .text, .rodata, .plt ועוד.
תזכורת קצרה - הELF Header¶
כבר ראינו את הELF header בפרק 5.3, אז רק נזכיר בקצרה. הכותרת נמצאת בתחילת הקובץ ומכילה את המידע הבסיסי ביותר:
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
Type: EXEC (Executable file)
Machine: Advanced Micro Devices X86-64
Entry point address: 0x401040
Start of program headers: 64 (bytes into file)
Start of section headers: 14200 (bytes into file)
Number of program headers: 13
Number of section headers: 31
הדברים החשובים שנשים לב אליהם:
- שדה הType - אומר לנו אם זה executable, shared object (ספריה משותפת/PIE), או relocatable (קובץ אובייקט .o)
- שדה הEntry point - הכתובת שבה התוכנית מתחילה לרוץ
- מספר הprogram headers ומספר הsection headers - אומרים לנו כמה סגמנטים וסקשנים יש בקובץ
הסקשנים - sections (נקודת המבט של הלינקר)¶
עכשיו נכנסים לעומק. כל סקשן בקובץ ELF מכיל סוג מסוים של מידע. נעבור על הסקשנים החשובים ביותר:
סקשן text. - קוד בר-הרצה¶
הסקשן .text מכיל את הוראות המכונה - הקוד שהמעבד מריץ בפועל. כל הפונקציות שכתבנו (כולל main) מקומפלות להוראות אסמבלי ונשמרות כאן.
הסקשן הזה ממופה לזיכרון עם הרשאות קריאה והרצה בלבד (R-X) - אי אפשר לכתוב אליו בזמן ריצה. זה חלק ממנגנוני ההגנה שלמדנו עליהם בפרק 2 (paging והרשאות דפים).
סקשן data. - משתנים גלובליים מאותחלים¶
הסקשן .data מכיל משתנים גלובליים ו-static שקיבלו ערך התחלתי בקוד:
הסקשן הזה ממופה עם הרשאות קריאה וכתיבה (RW-) כי המשתנים האלה יכולים להשתנות בזמן ריצה.
סקשן bss. - משתנים גלובליים לא מאותחלים¶
הסקשן .bss מכיל משתנים גלובליים ו-static שלא קיבלו ערך התחלתי (או שאותחלו ל-0):
הנקודה המעניינת: הסקשן .bss לא תופס מקום בקובץ עצמו. הקובץ רק רושם את הגודל של הסקשן, ובזמן טעינה הloader מקצה את הזיכרון ומאפס אותו. זו הסיבה שמשתנים גלובליים שלא אותחלו תמיד מתחילים עם ערך 0 ב-C.
חשבו על זה - אם יש לנו מערך של מיליון int-ים שלא מאותחל, אין סיבה לשמור מיליון אפסים בקובץ. מספיק לרשום "הסקשן הזה צריך 4MB של אפסים".
סקשן rodata. - נתונים לקריאה בלבד¶
הסקשן .rodata (read-only data) מכיל מחרוזות קבועות ונתונים שלא אמורים להשתנות:
const int MAX = 100; // נמצא ב-.rodata
printf("Hello, World!\n"); // המחרוזת "Hello, World!\n" נמצאת ב-.rodata
הסקשן הזה ממופה עם הרשאת קריאה בלבד (R--). ניסיון לכתוב אליו יגרום ל-segmentation fault.
סקשן symtab. - טבלת סמלים¶
הסקשן .symtab (symbol table) הוא אחד הסקשנים הכי חשובים להבנה. הוא מכיל רשימה של כל הסמלים בקובץ - שמות של פונקציות, משתנים גלובליים, וכו', יחד עם הכתובות שלהם.
כל רשומה בטבלת הסמלים מכילה:
- שם הסמל - למשל "main", "counter", "helper_func"
- כתובת - הכתובת שבה הסמל נמצא בזיכרון
- גודל - כמה בתים הסמל תופס
- סוג - האם זו פונקציה (FUNC), משתנה (OBJECT), או משהו אחר
- סקשן - באיזה סקשן הסמל נמצא (למשל .text עבור פונקציות)
הטבלה הזו היא מה שמאפשר לדיבאגרים כמו gdb להציג שמות פונקציות במקום כתובות גולמיות.
סקשן strtab. - טבלת מחרוזות¶
הסקשן .strtab (string table) פשוט מכיל את המחרוזות שטבלת הסמלים מצביעה עליהן. ב-.symtab כל רשומה מכילה אינדקס אל .strtab שבו נמצא שם הסמל עצמו.
למה לא לשמור את השם ישירות ב-.symtab? כי מחרוזות הן באורך משתנה, ושמירה שלהן בטבלה נפרדת מאפשרת מבנה יעיל יותר.
סקשנים dynsym. ו-dynstr. - סמלים דינמיים¶
הסקשנים .dynsym ו-.dynstr הם המקבילה הדינמית של .symtab ו-.strtab. הם מכילים רק את הסמלים שנדרשים בזמן ריצה - בעיקר פונקציות מספריות משותפות.
ההבדל הקריטי: כשעושים strip לקובץ (נדבר על זה בהמשך), הסקשנים .symtab ו-.strtab נמחקים, אבל .dynsym ו-.dynstr נשארים - כי הם הכרחיים להרצה.
סקשנים rel.plt. ו-rela.plt. - רלוקציות¶
הסקשנים .rel.plt או .rela.plt מכילים הוראות relocation עבור הPLT. כפי שלמדנו בפרק 5.10, כשהתוכנית קוראת לprintf, היא קופצת דרך הPLT שבתורו ניגש לGOT. הרשומות בסקשנים האלה אומרות לdynamic linker: "תכתוב את הכתובת האמיתית של printf בכניסה הזו בGOT".
סקשנים got. ו-got.plt. - טבלת הכתובות הגלובלית¶
הGlobal Offset Table - כבר הכרנו את הסקשנים האלה בפרק 5.10. הם מכילים את הכתובות בפועל של פונקציות ומשתנים מספריות משותפות. בפעם הראשונה שפונקציה נקראת, הdynamic linker כותב את הכתובת האמיתית לGOT, ומהפעם השניה הגישה ישירה.
סקשן plt. - סטאבים של PLT¶
הProcedure Linkage Table - גם על זה דיברנו בפרק 5.10. הסקשן .plt מכיל קטעי קוד קצרים (stubs) שמבצעים את הקפיצה דרך הGOT.
סקשנים init. ו-fini. - קוד אתחול וסיום¶
הסקשנים .init ו-.fini מכילים קוד שרץ לפני ואחרי main.
בנוסף, יש את .init_array ו-.fini_array - מערכים של מצביעים לפונקציות שצריכות לרוץ לפני/אחרי main. אפשר להוסיף פונקציות משלנו לשם:
#include <stdio.h>
__attribute__((constructor))
void before_main(void) {
printf("this runs BEFORE main\n");
}
__attribute__((destructor))
void after_main(void) {
printf("this runs AFTER main\n");
}
int main(void) {
printf("this is main\n");
return 0;
}
פלט:
זה שימושי לאתחול ספריות, ניקוי משאבים, ובהקשר של אבטחה - גם לתוקפים שרוצים להריץ קוד לפני שmain מתחיל.
סקשני debug_*. - מידע דיבוג¶
אם מקמפלים עם הדגל -g, הקומפיילר מוסיף סקשנים כמו .debug_info, .debug_line, .debug_abbrev ועוד. הסקשנים האלה מכילים את המיפוי בין הוראות מכונה לשורות בקוד המקור, שמות משתנים מקומיים, טיפוסי נתונים, וכל המידע שדיבאגר צריך כדי לתת לנו חוויית דיבוג נוחה.
הסקשנים האלה לא נטענים לזיכרון בזמן ריצה - הם רק נקראים על ידי כלי דיבוג.
שימוש בreadelf - חקירת קבצי ELF¶
הכלי readelf הוא הכלי המרכזי לחקירת קבצי ELF. נעבור על הדגלים החשובים:
צפייה בסקשנים - readelf -S¶
הפלט מציג טבלה של כל הסקשנים בקובץ:
There are 31 section headers, starting at offset 0x3978:
Section Headers:
[Nr] Name Type Address Offset Size
[ 0] NULL 0000000000000000 00000000 0000000000000000
[ 1] .interp PROGBITS 0000000000000318 00000318 000000000000001c
[ 2] .note.gnu.build-i NOTE 0000000000000338 00000338 0000000000000024
...
[14] .text PROGBITS 0000000000001060 00001060 0000000000000185
[15] .fini PROGBITS 00000000000011e8 000011e8 000000000000000d
[16] .rodata PROGBITS 0000000000002000 00002000 0000000000000012
...
[24] .data PROGBITS 0000000000004000 00003000 0000000000000010
[25] .bss NOBITS 0000000000004010 00003010 0000000000000008
...
שימו לב ש-.bss מסומן כNOBITS - זה אומר שהוא לא תופס מקום בקובץ.
צפייה בסמלים - readelf -s¶
Symbol table '.symtab' contains 67 entries:
Num: Value Size Type Bind Vis Ndx Name
...
50: 0000000000001149 38 FUNC GLOBAL DEFAULT 14 main
51: 0000000000004000 4 OBJECT GLOBAL DEFAULT 24 counter
52: 0000000000004010 4 OBJECT GLOBAL DEFAULT 25 uninitialized_var
...
כאן רואים שmain היא פונקציה (FUNC) בסקשן מספר 14 (שזה .text), ו-counter הוא אובייקט (OBJECT) בסקשן 24 (שזה .data).
צפייה בסגמנטים - readelf -l¶
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg
PHDR 0x000040 0x0000000000000040 0x0000000000000040 0x0002d8 0x0002d8 R
INTERP 0x000318 0x0000000000000318 0x0000000000000318 0x00001c 0x00001c R
LOAD 0x000000 0x0000000000000000 0x0000000000000000 0x000628 0x000628 R
LOAD 0x001000 0x0000000000001000 0x0000000000001000 0x0001f5 0x0001f5 R E
LOAD 0x002000 0x0000000000002000 0x0000000000002000 0x000160 0x000160 R
LOAD 0x002db8 0x0000000000003db8 0x0000000000003db8 0x000258 0x000260 RW
DYNAMIC 0x002dc8 0x0000000000003dc8 0x0000000000003dc8 0x0001f0 0x0001f0 RW
...
Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .note.gnu.build-id ...
03 .init .plt .plt.got .text .fini
04 .rodata .eh_frame_hdr .eh_frame
05 .init_array .fini_array .dynamic .got .data .bss
...
שימו לב לחלק התחתון - section to segment mapping. זה מראה בדיוק אילו סקשנים נמצאים בתוך כל סגמנט. סגמנט 03 (עם הרשאות R E - קריאה והרצה) מכיל את .text, .plt ועוד קטעי קוד.
צפייה ברלוקציות - readelf -r¶
Relocation section '.rela.plt' at offset 0x578 contains 2 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000003fd0 000200000007 R_X86_64_JUMP_SL 0000000000000000 printf@GLIBC_2.2.5 + 0
000000003fd8 000300000007 R_X86_64_JUMP_SL 0000000000000000 puts@GLIBC_2.2.5 + 0
זה מראה שיש רלוקציות עבור printf ו-puts - הdynamic linker צריך לכתוב את הכתובות האמיתיות שלהן בGOT בכתובות 0x3fd0 ו-0x3fd8.
צפייה בסקשן הדינמי - readelf -d¶
Dynamic section at offset 0x2dc8 contains 27 entries:
Tag Type Name/Value
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
0x000000000000000c (INIT) 0x1000
0x000000000000000d (FINI) 0x11e8
...
זה מראה את הספריות שהתוכנית תלויה בהן (NEEDED), כתובות הinit ו-fini, ועוד מידע שהdynamic linker צריך.
שימוש בobjdump - דיסאסמבלי וניתוח¶
הכלי objdump משלים את readelf ומאפשר בעיקר דיסאסמבלי - הפיכת הוראות מכונה חזרה לאסמבלי.
דיסאסמבלי - objdump -d¶
0000000000001149 <main>:
1149: f3 0f 1e fa endbr64
114d: 55 push %rbp
114e: 48 89 e5 mov %rsp,%rbp
1151: 48 8d 05 ac 0e 00 00 lea 0xeac(%rip),%rax
1158: 48 89 c7 mov %rax,%rdi
115b: e8 d0 fe ff ff call 1030 <puts@plt>
1160: b8 00 00 00 00 mov $0x0,%eax
1165: 5d pop %rbp
1166: c3 ret
זה מראה לנו את הקוד של main בשפת אסמבלי x86-64. שימו לב איך הקריאה ל-puts עוברת דרך הPLT (כפי שלמדנו בפרק 5.10).
טבלת סמלים - objdump -t¶
דומה ל-readelf -s אבל בפורמט קצת שונה. שימושי כשרוצים לראות את כל הסמלים עם הכתובות שלהם.
כל הכותרות - objdump -x¶
מציג את כל הכותרות - ELF header, program headers, section headers, סמלים ורלוקציות. כמו readelf -a אבל בפורמט אחר.
דוגמה מעשית - חקירת תוכנית פשוטה¶
נכתוב תוכנית פשוטה ונחקור אותה:
#include <stdio.h>
int initialized_global = 42;
int uninitialized_global;
const char *message = "Hello from .rodata";
void helper(void) {
printf("I am helper\n");
}
int main(void) {
printf("%s\n", message);
printf("global = %d\n", initialized_global);
helper();
return 0;
}
נקמפל עם מידע דיבוג:
עכשיו נחקור:
איפה main נמצא?
main נמצא בסקשן 14 (שזה .text) בכתובת 0x1169, ותופס 50 בתים.
איפה המשתנה הגלובלי?
נמצא בסקשן 24 (שזה .data) - הגיוני, כי נתנו לו ערך התחלתי.
איפה המשתנה הלא מאותחל?
נמצא בסקשן 25 (שזה .bss) - בדיוק כמו שציפינו.
איפה המחרוזת?
Contents of section .rodata:
2000 01000200 48656c6c 6f206672 6f6d202e ....Hello from .
2010 726f6461 74610049 20616d20 68656c70 rodata.I am help
2020 65720a00 25730a00 676c6f62 616c203d er..%s..global =
2030 2025640a 00 %d..
הנה המחרוזות שלנו ב-.rodata - גם "Hello from .rodata" וגם "I am helper\n" וגם פורמטי הprintf.
רזולוציית סמלים - symbol resolution¶
כשהלינקר מחבר מספר קבצי אובייקט (.o) לקובץ הרצה אחד, הוא צריך לפתור הפניות - כשקובץ אחד קורא לפונקציה שמוגדרת בקובץ אחר, הלינקר צריך למצוא את הכתובת הנכונה.
ישנם שלושה סוגי סמלים:
סמלים גלובליים - global symbols
פונקציות ומשתנים שמוגדרים בקובץ אחד ונגישים מקבצים אחרים. כל פונקציה "רגילה" שאנחנו כותבים היא סמל גלובלי.
סמלים מקומיים - local symbols
פונקציות ומשתנים שמוגדרים עם static - הם נגישים רק מתוך הקובץ שבו הם מוגדרים. הלינקר לא חושף אותם לקבצים אחרים.
static void internal_helper(void) { // סמל מקומי - לא נגיש מקבצים אחרים
// ...
}
void public_function(void) { // סמל גלובלי - נגיש מכל מקום
internal_helper();
}
סמלים חלשים - weak symbols
סמלים שאפשר "לדרוס" אותם. אם קובץ מגדיר סמל חלש ומגדיר גם סמל גלובלי עם אותו שם, הלינקר ישתמש בגלובלי. זה שימושי לספריות שרוצות לספק מימוש ברירת מחדל שאפשר להחליף:
רלוקציות - relocations¶
כשהקומפיילר מקמפל קובץ מקור לקובץ אובייקט (.o), הוא עדיין לא יודע את הכתובות הסופיות. למשל, אם בקובץ main.c יש קריאה לפונקציה מקובץ utils.c, הקומפיילר לא יודע באיזו כתובת הפונקציה הזו תהיה בקובץ הסופי.
במקום זה, הקומפיילר יוצר רשומת relocation שאומרת: "בoffset הזה בסקשן .text, צריך למלא את הכתובת של הסמל X".
הלינקר עובר על כל הרלוקציות, מוצא את הכתובות הסופיות של כל הסמלים, ו"ממלא את החורים".
נראה דוגמה:
Relocation section '.rela.text' at offset 0x2b0 contains 5 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000000000000a 000500000002 R_X86_64_PC32 0000000000000000 message - 4
00000000000000014 000b00000004 R_X86_64_PLT32 0000000000000000 printf - 4
...
זה אומר: "בoffset 0xa בסקשן .text, תמלא הפניה לסמל message. ב-offset 0x14, תמלא הפניה לסמל printf."
ברלוקציות דינמיות (שנמצאות ב-.rela.plt), זה עובד קצת אחרת - הdynamic linker ממלא את הכתובות בGOT בזמן ריצה, כפי שלמדנו בפרק 5.10.
הסרת סמלים - strip¶
הפקודה strip מסירה את טבלת הסמלים (.symtab ו-.strtab) מקובץ ELF:
מה נמחק?
- הסקשן .symtab - שמות כל הפונקציות והמשתנים
- הסקשן .strtab - המחרוזות של שמות הסמלים
- סקשני דיבוג (.debug_*)
מה נשאר?
- הסקשנים .dynsym ו-.dynstr - כי הם הכרחיים לdynamic linker בזמן ריצה
- כל שאר הסקשנים שנחוצים להרצה
למה עושים strip?
- להקטין את הקובץ - טבלת סמלים ומידע דיבוג יכולים לתפוס הרבה מקום
- להקשות על הנדסה לאחור - בלי שמות פונקציות, הרבה יותר קשה להבין מה הקוד עושה
# לפני strip
ls -la my_program
-rwxr-xr-x 1 user user 16696 my_program
# אחרי strip
strip my_program
ls -la my_program
-rwxr-xr-x 1 user user 14472 my_program
שימו לב - התוכנית ממשיכה לעבוד בדיוק אותו דבר. הstrip לא משנה את ההתנהגות, רק מסיר מידע שלא נחוץ בזמן ריצה.
הפקודה nm - הצגת סמלים¶
הפקודה nm היא דרך נוחה לראות את הסמלים בקובץ:
0000000000004010 D initialized_global
0000000000004020 B uninitialized_global
0000000000001149 T main
0000000000001139 T helper
U printf@GLIBC_2.2.5
האותיות מציינות את סוג הסמל:
- T - סמל בסקשן .text (קוד) - גלובלי
- t - סמל בסקשן .text - מקומי (static)
- D - סמל בסקשן .data (נתונים מאותחלים) - גלובלי
- B - סמל בסקשן .bss (נתונים לא מאותחלים) - גלובלי
- R - סמל בסקשן .rodata (קריאה בלבד) - גלובלי
- U - סמל לא מוגדר (undefined) - מוגדר בספריה חיצונית
- W - סמל חלש (weak)
אם הקובץ עבר strip, הפקודה nm תחזיר שגיאה:
במקרה כזה אפשר להשתמש ב:
הדגל -D מציג רק סמלים דינמיים (.dynsym) שנשארים גם אחרי strip.
יישום מעשי - למה כל זה חשוב?¶
הידע שלמדנו בהרצאה הזו הוא הבסיס לתחומים רבים:
הנדסה לאחור - reverse engineering
כשמנתחים תוכנה שאין לנו את קוד המקור שלה, הדבר הראשון שעושים הוא לבדוק את מבנה הELF - אילו סקשנים יש, אילו סמלים יש (אם לא עשו strip), ואיפה הקוד המעניין נמצא.
פיתוח exploit-ים
הבנת הRLF חיונית לכתיבת exploit-ים - צריך להבין את מיפוי הזיכרון, לדעת איפה הGOT נמצא (כדי לדרוס כתובות), להבין relocation-ים, ועוד.
דיבוג
כלי דיבוג כמו gdb משתמשים במידע מתוך הELF - טבלת הסמלים, מידע הדיבוג, ומידע השורות. הבנת המבנה עוזרת להבין מה gdb מראה לנו ולמה.
בניית כלים
כלים כמו readelf, objdump, nm, strip - כולם פשוט קוראים את מבנה הELF. עכשיו שאנחנו מבינים את המבנה, אנחנו יכולים לבנות כלים משלנו.
סיכום¶
בהרצאה הזו למדנו:
- קובץ ELF ניתן להסתכל עליו משתי נקודות מבט - סגמנטים (עבור הloader) וסקשנים (עבור הלינקר)
- הסקשנים העיקריים: .text (קוד), .data (נתונים מאותחלים), .bss (נתונים לא מאותחלים), .rodata (קריאה בלבד), .symtab/.strtab (סמלים), .dynsym/.dynstr (סמלים דינמיים), .plt/.got (PLT/GOT), .init/.fini (אתחול וסיום)
- כלי readelf עם הדגלים -h, -S, -s, -l, -r, -d
- כלי objdump עם הדגלים -d, -t, -x
- רזולוציית סמלים - גלובליים, מקומיים, וחלשים
- רלוקציות - איך הלינקר ממלא כתובות
- הפקודה strip - הסרת סמלים
- הפקודה nm - הצגת סמלים מקובץ