לדלג לתוכן

5.10 ספריות משותפות הרצאה

הקדמה

כשאנחנו כותבים תוכנית בC שמשתמשת ב-printf, מאיפה הקוד של printf בא? הוא לא חלק מהקוד שלנו - הוא נמצא בlibc, שהיא ספריה משותפת (shared library).

בהרצאה על הloader (פרק 5.3) כבר ראינו שקובץ ELF יכול להיות סטטי או דינמי, והזכרנו את הdynamic linker ואת PT_INTERP. עכשיו נצלול לעומק ונבין איך הכל עובד - מה זה ספריות משותפות, איך הן נטענות לזיכרון, ואיך אפשר ליצור ולהשתמש בהן.


קישור סטטי מול דינמי - static linking vs dynamic linking

כשאנחנו מקמפלים תוכנית, הlinker צריך לחבר את הקוד שלנו עם הפונקציות שאנחנו משתמשים בהן (כמו printf, malloc וכו'). יש שתי דרכים לעשות את זה:

קישור סטטי - static linking

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

gcc -static -o my_program my_program.c

יתרונות:
- אין תלות חיצונית - התוכנית עובדת על כל מכונה
- לא צריך לוודא שספריות מותקנות

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

קישור דינמי - dynamic linking

בקישור דינמי (וזה ברירת המחדל), קובץ ההרצה מכיל רק את הקוד של התוכנית עצמה, ואת השמות של הספריות שהוא צריך. בזמן ריצה, הdynamic linker טוען את הספריות לזיכרון ומחבר הכל ביחד.

gcc -o my_program my_program.c    # ברירת מחדל - דינמי

יתרונות:
- קובץ קטן יותר
- כל התהליכים שמשתמשים באותה ספריה חולקים אותה בזיכרון הפיזי (חיסכון בRAM)
- עדכון ספריה משפיע על כל התוכניות שמשתמשות בה - בלי לקמפל מחדש

חסרונות:
- תלות בספריות חיצוניות - אם חסרה ספריה, התוכנית לא תרוץ
- טעינה ראשונית קצת יותר איטית


ספריות משותפות - shared objects

ספריות משותפות בלינוקס הן קבצים עם סיומת .so (שזה Shared Object). הן נמצאות בדרך כלל ב:
- /lib/ או /lib64/
- /usr/lib/ או /usr/lib64/
- /usr/local/lib/

הספריה הכי חשובה והכי נפוצה היא libc.so - ספריית C הסטנדרטית שכמעט כל תוכנית משתמשת בה.

שם הספריה תמיד מתחיל ב-lib ומסתיים ב-.so, ולפעמים גם מספר גרסה. למשל: libm.so.6 (ספריית המתמטיקה), libpthread.so.0 (ספריית הthread-ים).


הפקודה ldd - הצגת ספריות נדרשות

הפקודה ldd מראה אילו ספריות משותפות קובץ הרצה צריך:

ldd /bin/ls

פלט לדוגמה:

linux-vdso.so.1 (0x00007ffd4a5f0000)
libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1 (0x00007f1a2c800000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f1a2c400000)
/lib64/ld-linux-x86-64.so.2 (0x00007f1a2cc00000)

כל שורה מציגה:
- שם הספריה
- הנתיב שבו היא נמצאה (אחרי =>)
- הכתובת שבה היא נטענה בזיכרון

שימו לב ל-ld-linux-x86-64.so.2 - זה הdynamic linker עצמו, ועליו נדבר עכשיו.

אם נריץ ldd על קובץ סטטי, נקבל:

gcc -static -o static_program main.c
ldd ./static_program

not a dynamic executable


הdynamic linker - ld-linux.so

הdynamic linker (או בשמות אחרים: runtime linker, program interpreter) הוא התוכנה שרצה לפני הmain() שלנו. התפקיד שלו הוא:
1. לטעון את כל הספריות המשותפות שהתוכנית צריכה
2. לפתור סמלים (symbols) - לחבר את הקריאות ל-printf בקוד שלנו לכתובת האמיתית של printf בlibc
3. לקפוץ לentry point של התוכנית שלנו

זוכרים את PT_INTERP מהרצאה 5.3 על הloader? הסגמנט הזה בקובץ ELF מכיל את הנתיב של הdynamic linker, בדרך כלל /lib64/ld-linux-x86-64.so.2.

כשהקרנל טוען קובץ ELF דינמי, הוא לא קופץ ישר לקוד שלנו. במקום זה הוא מעביר שליטה לdynamic linker, שעושה את כל העבודה הכבדה של טעינת ספריות, ורק אז מעביר שליטה לתוכנית.

משתנה הסביבה LD_LIBRARY_PATH

הdynamic linker מחפש ספריות בנתיבים קבועים (/lib, /usr/lib וכו'). אם הספריה שלנו נמצאת במקום אחר, אפשר להגיד לו איפה לחפש באמצעות משתנה הסביבה LD_LIBRARY_PATH:

LD_LIBRARY_PATH=/path/to/my/libs ./my_program

איך קישור דינמי עובד - PLT ו-GOT

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

הבעיה

כשאנחנו מקמפלים את התוכנית, הקומפיילר לא יודע באיזו כתובת printf תהיה בזיכרון בזמן ריצה. הכתובת משתנה בכל הרצה (בגלל ASLR - נדבר על זה בהמשך). אז איך התוכנית יודעת לאן לקפוץ כשהיא קוראת ל-printf?

הפתרון: GOT ו-PLT

יש שני מבנים שעובדים ביחד:

טבלת GOT - Global Offset Table:
זו טבלה בזיכרון של התהליך שמכילה את הכתובות האמיתיות של פונקציות חיצוניות. בהתחלה הטבלה לא מכילה את הכתובות הסופיות - הdynamic linker ממלא אותן.

טבלת PLT - Procedure Linkage Table:
אלה הם קטעי קוד קטנים (stubs) - כל פונקציה חיצונית מקבלת entry בPLT. כשהתוכנית קוראת ל-printf, היא בעצם קופצת לentry של printf בPLT.

איך זה עובד בפועל - lazy binding

  1. הקוד שלנו קורא ל-printf - זה בעצם קופץ לentry של printf בPLT
  2. הPLT קופץ לכתובת שרשומה בGOT
  3. בפעם הראשונה, הGOT מצביע חזרה לdynamic linker
  4. הdynamic linker מחפש את הכתובת האמיתית של printf בlibc
  5. הdynamic linker כותב את הכתובת האמיתית לתוך הGOT
  6. הdynamic linker קופץ ל-printf האמיתית

מהפעם השניה ואילך:
1. הקוד שלנו קורא ל-printf - קופץ לPLT
2. הPLT קופץ לכתובת שבGOT
3. הGOT כבר מכיל את הכתובת האמיתית - אז הקפיצה הולכת ישר ל-printf

המנגנון הזה נקרא lazy binding - הכתובות נפתרות רק בפעם הראשונה שקוראים לפונקציה, ולא בזמן הטעינה. זה מאיץ את ההפעלה של תוכניות שמשתמשות בהרבה פונקציות חיצוניות.

למה זה חשוב לאבטחה?

הGOT נמצא באזור זיכרון עם הרשאת כתיבה (כדי שהdynamic linker יוכל לעדכן אותו). זה אומר שאם תוקף מצליח לכתוב לGOT, הוא יכול להחליף כתובת של פונקציה לגיטימית בכתובת של קוד זדוני.

לכן קיים מנגנון הגנה שנקרא RELRO (Relocation Read-Only) שהופך את הGOT לread-only אחרי שהdynamic linker סיים לעדכן אותו.

בנוסף, מנגנון ASLR (Address Space Layout Randomization) דואג שהספריות המשותפות ייטענו לכתובות אקראיות בכל הרצה, מה שמקשה על תוקף לנחש את הכתובות בGOT.


יצירת ספריה משותפת משלנו

אפשר ליצור ספריות משותפות בעצמנו. הנה דוגמה מלאה:

שלב 1 - כותבים את קוד הספריה

הקובץ mylib.c:

#include <stdio.h>

int add(int a, int b) {
    return a + b;
}

int multiply(int a, int b) {
    return a * b;
}

void greet(const char *name) {
    printf("שלום %s!\n", name);
}

שלב 2 - מקמפלים לספריה משותפת

gcc -shared -fPIC -o libmylib.so mylib.c

הדגלים:
- -shared - אומר לקומפיילר ליצור ספריה משותפת (קובץ .so) במקום קובץ הרצה
- -fPIC - מייצר קוד שעובד בכל כתובת בזיכרון (Position Independent Code). נסביר על זה בהמשך

שלב 3 - כותבים תוכנית שמשתמשת בספריה

הקובץ main.c:

#include <stdio.h>

/* הצהרות על הפונקציות מהספריה */
int add(int a, int b);
int multiply(int a, int b);
void greet(const char *name);

int main() {
    printf("3 + 5 = %d\n", add(3, 5));
    printf("3 * 5 = %d\n", multiply(3, 5));
    greet("student");
    return 0;
}

שלב 4 - מקמפלים ומריצים

gcc main.c -L. -lmylib -o program

הדגלים:
- -L. - חפש ספריות גם בספריה הנוכחית (.)
- -lmylib - קשר עם הספריה libmylib.so (שימו לב שמשמיטים את lib ואת .so)

כדי להריץ, צריך לוודא שהdynamic linker ימצא את הספריה:

LD_LIBRARY_PATH=. ./program

פלט:

3 + 5 = 8
3 * 5 = 15
שלום student!


טעינת ספריות בזמן ריצה - dlopen ו-dlsym

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

לשם כך יש לנו ארבע פונקציות:
- dlopen() - טוענת ספריה משותפת לזיכרון
- dlsym() - מחפשת פונקציה (סמל) בתוך ספריה שנטענה
- dlclose() - משחררת ספריה שנטענה
- dlerror() - מחזירה הודעת שגיאה אם משהו נכשל

ככה plugins עובדים! תוכנית יכולה לטעון מודולים בזמן ריצה בלי שהיא הידעה עליהם בזמן הקימפול.

דוגמה - טעינת ספריה בזמן ריצה

#include <stdio.h>
#include <dlfcn.h>

int main() {
    /* טוענים את ספריית המתמטיקה */
    void *handle = dlopen("libm.so.6", RTLD_LAZY);
    if (handle == NULL) {
        printf("שגיאה בטעינת הספריה: %s\n", dlerror());
        return 1;
    }

    /* מחפשים את הפונקציה cos */
    double (*cos_func)(double) = dlsym(handle, "cos");
    if (cos_func == NULL) {
        printf("שגיאה במציאת הפונקציה: %s\n", dlerror());
        dlclose(handle);
        return 1;
    }

    /* קוראים לפונקציה דרך פוינטר */
    double result = cos_func(0.0);
    printf("cos(0) = %f\n", result);

    result = cos_func(3.14159265);
    printf("cos(pi) = %f\n", result);

    /* משחררים את הספריה */
    dlclose(handle);
    return 0;
}

קימפול:

gcc main.c -ldl -o program

הדגל -ldl מקשר עם ספריית libdl שמכילה את dlopen, dlsym וכו'.

פלט:

cos(0) = 1.000000
cos(pi) = -1.000000

שימו לב למה שקורה כאן:
1. אנחנו טוענים את libm.so.6 בזמן ריצה עם dlopen
2. אנחנו מחפשים את הפונקציה cos לפי שם עם dlsym
3. אנחנו מקבלים פוינטר לפונקציה ומשתמשים בו כדי לקרוא לה
4. בסוף משחררים את הספריה עם dlclose

הדגל RTLD_LAZY אומר לdynamic linker לבצע lazy binding - לפתור סמלים רק כשקוראים להם.


קוד בלתי תלוי במיקום - fPIC

כשדיברנו על קימפול ספריה משותפת, ראינו שצריך להשתמש בדגל -fPIC. מה זה אומר?

PIC זה ראשי תיבות של Position Independent Code - קוד שעובד לא משנה באיזו כתובת הוא נטען בזיכרון.

בקוד רגיל, הקומפיילר עשוי לייצר הוראות שמתייחסות לכתובות מוחלטות - למשל jmp 0x401234. אבל ספריה משותפת יכולה להיטען לכתובת שונה בכל תהליך ובכל הרצה (בגלל ASLR). אם הקוד היה משתמש בכתובות מוחלטות, הוא היה נשבר.

עם -fPIC, הקומפיילר מייצר קוד שמשתמש בכתובות יחסיות - כלומר "קפוץ 100 בתים קדימה" במקום "קפוץ לכתובת 0x401234". ככה הקוד עובד לא משנה איפה הוא יושב בזיכרון.


סיכום

  • ספריות משותפות (.so) מאפשרות לתוכניות רבות לחלוק קוד בזיכרון
  • הdynamic linker (ld-linux.so) טוען ספריות ופותר סמלים בזמן ריצה
  • PLT ו-GOT הם המנגנון שמאפשר לקוד לקרוא לפונקציות בספריות משותפות
  • אפשר ליצור ספריות משותפות משלנו עם -shared -fPIC
  • אפשר לטעון ספריות בזמן ריצה עם dlopen ו-dlsym - ככה plugins עובדים
  • הבנת ספריות משותפות חשובה לאבטחה (ASLR, GOT overwrite) ולהנדסה הפוכה